@mevdragon/vidfarm-devcli 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/GETTING_STARTED.developers.md +14 -18
  2. package/README.md +19 -5
  3. package/SKILL.developer.md +140 -11
  4. package/dist/src/account-pages.js +1 -1
  5. package/dist/src/app.js +305 -9
  6. package/dist/src/cli.js +142 -6
  7. package/dist/src/config.js +2 -5
  8. package/dist/src/context.js +16 -0
  9. package/dist/src/db.js +33 -2
  10. package/dist/src/homepage.js +33 -1
  11. package/dist/src/lib/template-paths.js +10 -4
  12. package/dist/src/registry.js +9 -2
  13. package/dist/src/services/auth.js +2 -6
  14. package/dist/src/services/job-logs.js +4 -4
  15. package/dist/src/services/providers.js +470 -0
  16. package/dist/src/services/storage.js +13 -0
  17. package/dist/src/services/template-certification.js +16 -2
  18. package/dist/src/services/template-loader.js +17 -1
  19. package/dist/src/services/template-sources.js +249 -5
  20. package/{templates/template_0000 → dist/templates/vidfarm_template_0000}/src/remotion/Root.js +1 -0
  21. package/dist/templates/{template_0000 → vidfarm_template_0000}/src/template.js +9 -3
  22. package/package.json +4 -3
  23. package/templates/{template_0000 → vidfarm_template_0000}/README.md +23 -0
  24. package/templates/{template_0000 → vidfarm_template_0000}/package.json +0 -1
  25. package/templates/{template_0000 → vidfarm_template_0000}/src/sdk.ts +18 -0
  26. package/templates/{template_0000 → vidfarm_template_0000}/src/template.js +9 -3
  27. package/templates/{template_0000 → vidfarm_template_0000}/src/template.ts +10 -3
  28. package/templates/vidfarm_template_0000/tmp/solobacterium-moorei-slideshow.request.json +31 -0
  29. /package/dist/templates/{template_0000 → vidfarm_template_0000}/src/lib/images.js +0 -0
  30. /package/dist/templates/{template_0000 → vidfarm_template_0000}/src/remotion/index.js +0 -0
  31. /package/dist/templates/{template_0000 → vidfarm_template_0000}/src/sdk.js +0 -0
  32. /package/dist/templates/{template_0000 → vidfarm_template_0000}/src/style-options.js +0 -0
  33. /package/dist/templates/{template_0000 → vidfarm_template_0000}/src/template-dna.js +0 -0
  34. /package/templates/{template_0000 → vidfarm_template_0000}/SKILL.md +0 -0
  35. /package/templates/{template_0000 → vidfarm_template_0000}/assets/Abel-Regular.ttf +0 -0
  36. /package/templates/{template_0000 → vidfarm_template_0000}/assets/DMSerifDisplay-Regular.ttf +0 -0
  37. /package/templates/{template_0000 → vidfarm_template_0000}/assets/Montserrat[wght].ttf +0 -0
  38. /package/templates/{template_0000 → vidfarm_template_0000}/assets/SourceCodePro[wght].ttf +0 -0
  39. /package/templates/{template_0000 → vidfarm_template_0000}/assets/TikTokSans-SemiBold.ttf +0 -0
  40. /package/templates/{template_0000 → vidfarm_template_0000}/assets/Yesteryear-Regular.ttf +0 -0
  41. /package/templates/{template_0000 → vidfarm_template_0000}/composition.json +0 -0
  42. /package/templates/{template_0000 → vidfarm_template_0000}/research/preview/.gitkeep +0 -0
  43. /package/templates/{template_0000 → vidfarm_template_0000}/research/source_notes.md +0 -0
  44. /package/templates/{template_0000 → vidfarm_template_0000}/src/lib/images.js +0 -0
  45. /package/templates/{template_0000 → vidfarm_template_0000}/src/lib/images.ts +0 -0
  46. /package/{dist/templates/template_0000 → templates/vidfarm_template_0000}/src/remotion/Root.js +0 -0
  47. /package/templates/{template_0000 → vidfarm_template_0000}/src/remotion/Root.tsx +0 -0
  48. /package/templates/{template_0000 → vidfarm_template_0000}/src/remotion/index.js +0 -0
  49. /package/templates/{template_0000 → vidfarm_template_0000}/src/remotion/index.tsx +0 -0
  50. /package/templates/{template_0000 → vidfarm_template_0000}/src/sdk.js +0 -0
  51. /package/templates/{template_0000 → vidfarm_template_0000}/src/style-options.js +0 -0
  52. /package/templates/{template_0000 → vidfarm_template_0000}/src/style-options.ts +0 -0
  53. /package/templates/{template_0000 → vidfarm_template_0000}/src/template-dna.js +0 -0
  54. /package/templates/{template_0000 → vidfarm_template_0000}/src/template-dna.ts +0 -0
  55. /package/templates/{template_0000 → vidfarm_template_0000}/template.config.json +0 -0
  56. /package/templates/{template_0000 → vidfarm_template_0000}/tsconfig.json +0 -0
@@ -15,6 +15,16 @@ export class ProviderRateLimitError extends Error {
15
15
  this.retryAfterSeconds = retryAfterSeconds;
16
16
  }
17
17
  }
18
+ const SUPPORTED_TTS_MODELS = {
19
+ openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
20
+ gemini: new Set(["gemini-3.1-flash-tts-preview"]),
21
+ openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
22
+ };
23
+ const SUPPORTED_STT_MODELS = {
24
+ openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
25
+ gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
26
+ openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
27
+ };
18
28
  function readStoredProviderSecret(value) {
19
29
  try {
20
30
  return decryptString(value, config.ENCRYPTION_SECRET);
@@ -129,6 +139,103 @@ export class ProviderService {
129
139
  await this.releaseLease(lease);
130
140
  }
131
141
  }
142
+ async generateSpeech(input) {
143
+ assertSupportedSpeechModel("tts", input.provider, input.model);
144
+ const lease = await this.leaseCustomerKey({
145
+ customerId: input.customerId,
146
+ provider: input.provider,
147
+ jobId: input.jobId,
148
+ workerId: input.workerId
149
+ });
150
+ try {
151
+ if (config.mockProviders) {
152
+ const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
153
+ database.recordProviderKeyUsage({
154
+ id: createId("usage"),
155
+ keyId: lease.keyId,
156
+ jobId: input.jobId,
157
+ provider: input.provider,
158
+ model: input.model,
159
+ eventType: "success",
160
+ inputTokens: 0,
161
+ outputTokens: 0,
162
+ costUsd: 0.01,
163
+ metadata: { mock: true, type: "speech_generation" }
164
+ });
165
+ database.touchProviderKey(lease.keyId);
166
+ return {
167
+ bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
168
+ contentType,
169
+ usage: {
170
+ inputTokens: 0,
171
+ outputTokens: 0,
172
+ costUsd: 0.01
173
+ }
174
+ };
175
+ }
176
+ const result = input.provider === "gemini"
177
+ ? await this.callGeminiSpeechGeneration({
178
+ model: input.model,
179
+ text: input.text,
180
+ voice: input.voice,
181
+ instructions: input.instructions,
182
+ apiKey: lease.secret
183
+ })
184
+ : await this.callOpenAICompatibleSpeechGeneration({
185
+ provider: input.provider === "openrouter" ? "openrouter" : "openai",
186
+ model: input.model,
187
+ text: input.text,
188
+ voice: input.voice,
189
+ instructions: input.instructions,
190
+ responseFormat: input.responseFormat,
191
+ apiKey: lease.secret
192
+ });
193
+ database.recordProviderKeyUsage({
194
+ id: createId("usage"),
195
+ keyId: lease.keyId,
196
+ jobId: input.jobId,
197
+ provider: input.provider,
198
+ model: input.model,
199
+ eventType: "success",
200
+ inputTokens: result.usage.inputTokens,
201
+ outputTokens: result.usage.outputTokens,
202
+ costUsd: result.usage.costUsd,
203
+ metadata: { type: "speech_generation" }
204
+ });
205
+ database.touchProviderKey(lease.keyId);
206
+ return result;
207
+ }
208
+ catch (error) {
209
+ if (error instanceof ProviderRateLimitError) {
210
+ database.recordProviderKeyUsage({
211
+ id: createId("usage"),
212
+ keyId: lease.keyId,
213
+ jobId: input.jobId,
214
+ provider: input.provider,
215
+ model: input.model,
216
+ eventType: "rate_limit",
217
+ metadata: { retryAfterSeconds: error.retryAfterSeconds, type: "speech_generation" }
218
+ });
219
+ database.setProviderKeyCooldown(lease.keyId, addSeconds(new Date(), error.retryAfterSeconds));
220
+ }
221
+ else if (error instanceof ProviderAuthError) {
222
+ database.recordProviderKeyUsage({
223
+ id: createId("usage"),
224
+ keyId: lease.keyId,
225
+ jobId: input.jobId,
226
+ provider: input.provider,
227
+ model: input.model,
228
+ eventType: "auth_error",
229
+ metadata: { type: "speech_generation" }
230
+ });
231
+ database.setProviderKeyCooldown(lease.keyId, null, "invalid", "auth_error");
232
+ }
233
+ throw error;
234
+ }
235
+ finally {
236
+ await this.releaseLease(lease);
237
+ }
238
+ }
132
239
  async generateImage(input) {
133
240
  const lease = await this.leaseCustomerKey({
134
241
  customerId: input.customerId,
@@ -265,6 +372,110 @@ export class ProviderService {
265
372
  await this.releaseLease(lease);
266
373
  }
267
374
  }
375
+ async transcribeSpeech(input) {
376
+ assertSupportedSpeechModel("stt", input.provider, input.model);
377
+ const lease = await this.leaseCustomerKey({
378
+ customerId: input.customerId,
379
+ provider: input.provider,
380
+ jobId: input.jobId,
381
+ workerId: input.workerId
382
+ });
383
+ try {
384
+ if (config.mockProviders) {
385
+ database.recordProviderKeyUsage({
386
+ id: createId("usage"),
387
+ keyId: lease.keyId,
388
+ jobId: input.jobId,
389
+ provider: input.provider,
390
+ model: input.model,
391
+ eventType: "success",
392
+ inputTokens: 0,
393
+ outputTokens: 0,
394
+ costUsd: 0.01,
395
+ metadata: { mock: true, type: "speech_transcription" }
396
+ });
397
+ database.touchProviderKey(lease.keyId);
398
+ return {
399
+ text: "Mock transcription output.",
400
+ usage: {
401
+ inputTokens: 0,
402
+ outputTokens: 0,
403
+ costUsd: 0.01
404
+ }
405
+ };
406
+ }
407
+ const result = input.provider === "gemini"
408
+ ? await this.callGeminiSpeechTranscription({
409
+ model: input.model,
410
+ audio: input.audio,
411
+ contentType: input.contentType,
412
+ prompt: input.prompt,
413
+ language: input.language,
414
+ apiKey: lease.secret
415
+ })
416
+ : input.provider === "openrouter"
417
+ ? await this.callOpenRouterSpeechTranscription({
418
+ model: input.model,
419
+ audio: input.audio,
420
+ contentType: input.contentType,
421
+ prompt: input.prompt,
422
+ language: input.language,
423
+ apiKey: lease.secret
424
+ })
425
+ : await this.callOpenAISpeechTranscription({
426
+ model: input.model,
427
+ audio: input.audio,
428
+ contentType: input.contentType,
429
+ prompt: input.prompt,
430
+ language: input.language,
431
+ apiKey: lease.secret
432
+ });
433
+ database.recordProviderKeyUsage({
434
+ id: createId("usage"),
435
+ keyId: lease.keyId,
436
+ jobId: input.jobId,
437
+ provider: input.provider,
438
+ model: input.model,
439
+ eventType: "success",
440
+ inputTokens: result.usage.inputTokens,
441
+ outputTokens: result.usage.outputTokens,
442
+ costUsd: result.usage.costUsd,
443
+ metadata: { type: "speech_transcription" }
444
+ });
445
+ database.touchProviderKey(lease.keyId);
446
+ return result;
447
+ }
448
+ catch (error) {
449
+ if (error instanceof ProviderRateLimitError) {
450
+ database.recordProviderKeyUsage({
451
+ id: createId("usage"),
452
+ keyId: lease.keyId,
453
+ jobId: input.jobId,
454
+ provider: input.provider,
455
+ model: input.model,
456
+ eventType: "rate_limit",
457
+ metadata: { retryAfterSeconds: error.retryAfterSeconds, type: "speech_transcription" }
458
+ });
459
+ database.setProviderKeyCooldown(lease.keyId, addSeconds(new Date(), error.retryAfterSeconds));
460
+ }
461
+ else if (error instanceof ProviderAuthError) {
462
+ database.recordProviderKeyUsage({
463
+ id: createId("usage"),
464
+ keyId: lease.keyId,
465
+ jobId: input.jobId,
466
+ provider: input.provider,
467
+ model: input.model,
468
+ eventType: "auth_error",
469
+ metadata: { type: "speech_transcription" }
470
+ });
471
+ database.setProviderKeyCooldown(lease.keyId, null, "invalid", "auth_error");
472
+ }
473
+ throw error;
474
+ }
475
+ finally {
476
+ await this.releaseLease(lease);
477
+ }
478
+ }
268
479
  async callOpenAIImageGeneration(input) {
269
480
  if (input.promptAttachments?.length) {
270
481
  throw new Error("OpenAI image generation attachments are not implemented in this provider path. Use Gemini for reference attachments.");
@@ -355,6 +566,94 @@ export class ProviderService {
355
566
  revisedPrompt: null
356
567
  };
357
568
  }
569
+ async callOpenAICompatibleSpeechGeneration(input) {
570
+ const responseFormat = input.responseFormat ?? "mp3";
571
+ const endpoint = input.provider === "openrouter"
572
+ ? "https://openrouter.ai/api/v1/audio/speech"
573
+ : "https://api.openai.com/v1/audio/speech";
574
+ const response = await fetch(endpoint, {
575
+ method: "POST",
576
+ headers: {
577
+ "Content-Type": "application/json",
578
+ Authorization: `Bearer ${input.apiKey}`
579
+ },
580
+ body: JSON.stringify({
581
+ model: input.model,
582
+ input: input.text,
583
+ voice: input.voice ?? "alloy",
584
+ instructions: input.instructions,
585
+ response_format: responseFormat
586
+ })
587
+ });
588
+ if (response.status === 401 || response.status === 403) {
589
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
590
+ }
591
+ if (response.status === 429) {
592
+ throw new ProviderRateLimitError(`${input.provider} rate limited`);
593
+ }
594
+ if (!response.ok) {
595
+ const details = await response.text();
596
+ throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
597
+ }
598
+ return {
599
+ bytes: Buffer.from(await response.arrayBuffer()),
600
+ contentType: response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat),
601
+ usage: {
602
+ inputTokens: 0,
603
+ outputTokens: 0,
604
+ costUsd: 0
605
+ }
606
+ };
607
+ }
608
+ async callGeminiSpeechGeneration(input) {
609
+ const prompt = input.instructions?.trim()
610
+ ? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
611
+ : input.text;
612
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
613
+ method: "POST",
614
+ headers: {
615
+ "Content-Type": "application/json"
616
+ },
617
+ body: JSON.stringify({
618
+ contents: [{ parts: [{ text: prompt }] }],
619
+ generationConfig: {
620
+ responseModalities: ["AUDIO"],
621
+ speechConfig: {
622
+ voiceConfig: {
623
+ prebuiltVoiceConfig: {
624
+ voiceName: input.voice ?? "Kore"
625
+ }
626
+ }
627
+ }
628
+ }
629
+ })
630
+ });
631
+ if (response.status === 401 || response.status === 403) {
632
+ throw new ProviderAuthError("gemini authentication failed");
633
+ }
634
+ if (response.status === 429) {
635
+ throw new ProviderRateLimitError("gemini rate limited");
636
+ }
637
+ if (!response.ok) {
638
+ const details = await response.text();
639
+ throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
640
+ }
641
+ const data = await response.json();
642
+ const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
643
+ const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
644
+ if (!encoded) {
645
+ throw new Error("gemini speech generation returned no audio payload");
646
+ }
647
+ return {
648
+ bytes: Buffer.from(encoded, "base64"),
649
+ contentType: part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000",
650
+ usage: {
651
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
652
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
653
+ costUsd: 0
654
+ }
655
+ };
656
+ }
358
657
  async callOpenAICompatibleLayoutAnalysis(input) {
359
658
  const endpoint = input.provider === "openrouter"
360
659
  ? "https://openrouter.ai/api/v1/chat/completions"
@@ -458,6 +757,128 @@ export class ProviderService {
458
757
  const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
459
758
  return String(text);
460
759
  }
760
+ async callOpenAISpeechTranscription(input) {
761
+ const contentType = input.contentType ?? "audio/mpeg";
762
+ const form = new FormData();
763
+ form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
764
+ form.set("model", input.model);
765
+ form.set("response_format", "json");
766
+ if (input.prompt) {
767
+ form.set("prompt", input.prompt);
768
+ }
769
+ if (input.language) {
770
+ form.set("language", input.language);
771
+ }
772
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
773
+ method: "POST",
774
+ headers: {
775
+ Authorization: `Bearer ${input.apiKey}`
776
+ },
777
+ body: form
778
+ });
779
+ if (response.status === 401 || response.status === 403) {
780
+ throw new ProviderAuthError("openai authentication failed");
781
+ }
782
+ if (response.status === 429) {
783
+ throw new ProviderRateLimitError("openai rate limited");
784
+ }
785
+ if (!response.ok) {
786
+ const details = await response.text();
787
+ throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
788
+ }
789
+ const data = await response.json();
790
+ return {
791
+ text: String(data.text ?? ""),
792
+ usage: {
793
+ inputTokens: Number(data.usage?.input_tokens ?? 0),
794
+ outputTokens: Number(data.usage?.output_tokens ?? 0),
795
+ costUsd: Number(data.usage?.cost ?? 0)
796
+ }
797
+ };
798
+ }
799
+ async callOpenRouterSpeechTranscription(input) {
800
+ const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
801
+ method: "POST",
802
+ headers: {
803
+ "Content-Type": "application/json",
804
+ Authorization: `Bearer ${input.apiKey}`
805
+ },
806
+ body: JSON.stringify({
807
+ model: input.model,
808
+ input_audio: {
809
+ data: Buffer.from(input.audio).toString("base64"),
810
+ format: audioFormatFromContentType(input.contentType)
811
+ },
812
+ ...(input.prompt ? { prompt: input.prompt } : {}),
813
+ ...(input.language ? { language: input.language } : {})
814
+ })
815
+ });
816
+ if (response.status === 401 || response.status === 403) {
817
+ throw new ProviderAuthError("openrouter authentication failed");
818
+ }
819
+ if (response.status === 429) {
820
+ throw new ProviderRateLimitError("openrouter rate limited");
821
+ }
822
+ if (!response.ok) {
823
+ const details = await response.text();
824
+ throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
825
+ }
826
+ const data = await response.json();
827
+ return {
828
+ text: String(data.text ?? ""),
829
+ usage: {
830
+ inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
831
+ outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
832
+ costUsd: Number(data.usage?.cost ?? 0)
833
+ }
834
+ };
835
+ }
836
+ async callGeminiSpeechTranscription(input) {
837
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
838
+ method: "POST",
839
+ headers: {
840
+ "Content-Type": "application/json"
841
+ },
842
+ body: JSON.stringify({
843
+ contents: [{
844
+ parts: [
845
+ {
846
+ inline_data: {
847
+ mime_type: input.contentType ?? "audio/mpeg",
848
+ data: Buffer.from(input.audio).toString("base64")
849
+ }
850
+ },
851
+ {
852
+ text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
853
+ }
854
+ ]
855
+ }],
856
+ generationConfig: {
857
+ temperature: 0,
858
+ responseMimeType: "text/plain"
859
+ }
860
+ })
861
+ });
862
+ if (response.status === 401 || response.status === 403) {
863
+ throw new ProviderAuthError("gemini authentication failed");
864
+ }
865
+ if (response.status === 429) {
866
+ throw new ProviderRateLimitError("gemini rate limited");
867
+ }
868
+ if (!response.ok) {
869
+ const details = await response.text();
870
+ throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
871
+ }
872
+ const data = await response.json();
873
+ return {
874
+ text: extractGeminiText(data),
875
+ usage: {
876
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
877
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
878
+ costUsd: 0
879
+ }
880
+ };
881
+ }
461
882
  async callProvider(input) {
462
883
  if (input.provider === "gemini") {
463
884
  return this.callGemini({
@@ -543,6 +964,55 @@ export class ProviderService {
543
964
  function clamp(value, min, max) {
544
965
  return Math.max(min, Math.min(max, value));
545
966
  }
967
+ function assertSupportedSpeechModel(kind, provider, model) {
968
+ const supported = kind === "tts" ? SUPPORTED_TTS_MODELS[provider] : SUPPORTED_STT_MODELS[provider];
969
+ if (!supported?.has(model)) {
970
+ throw new Error(`Unsupported ${kind.toUpperCase()} model for provider ${provider}: ${model}`);
971
+ }
972
+ }
973
+ function inferSpeechContentType(format) {
974
+ return format === "wav" ? "audio/wav" : "audio/mpeg";
975
+ }
976
+ function audioFormatFromContentType(contentType) {
977
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
978
+ switch (normalized) {
979
+ case "audio/wav":
980
+ case "audio/x-wav":
981
+ return "wav";
982
+ case "audio/mp4":
983
+ case "audio/m4a":
984
+ return "mp4";
985
+ case "audio/webm":
986
+ return "webm";
987
+ case "audio/ogg":
988
+ return "ogg";
989
+ default:
990
+ return "mp3";
991
+ }
992
+ }
993
+ function audioExtensionFromContentType(contentType) {
994
+ switch (audioFormatFromContentType(contentType)) {
995
+ case "wav":
996
+ return "wav";
997
+ case "mp4":
998
+ return "m4a";
999
+ case "webm":
1000
+ return "webm";
1001
+ case "ogg":
1002
+ return "ogg";
1003
+ default:
1004
+ return "mp3";
1005
+ }
1006
+ }
1007
+ function buildGeminiTranscriptionPrompt(prompt, language) {
1008
+ const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
1009
+ return language?.trim()
1010
+ ? `${basePrompt}\n\nExpected language: ${language.trim()}.`
1011
+ : basePrompt;
1012
+ }
1013
+ function extractGeminiText(data) {
1014
+ return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
1015
+ }
546
1016
  function supportsGeminiImageSize(model) {
547
1017
  return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
548
1018
  }
@@ -115,6 +115,19 @@ export class StorageService {
115
115
  }
116
116
  return readFileSync(path.join(this.localRoot, key), "utf8");
117
117
  }
118
+ async readBuffer(key) {
119
+ if (this.s3 && config.AWS_S3_BUCKET) {
120
+ const response = await this.s3.send(new GetObjectCommand({
121
+ Bucket: config.AWS_S3_BUCKET,
122
+ Key: key
123
+ }));
124
+ if (!response.Body) {
125
+ return null;
126
+ }
127
+ return Buffer.from(await response.Body.transformToByteArray());
128
+ }
129
+ return readFileSync(path.join(this.localRoot, key));
130
+ }
118
131
  async listKeys(prefix) {
119
132
  if (this.s3 && config.AWS_S3_BUCKET) {
120
133
  const keys = [];
@@ -13,11 +13,17 @@ export class TemplateCertificationService {
13
13
  typeof template.about.visual_dna === "string" &&
14
14
  Array.isArray(template.about.preview_media) &&
15
15
  template.about.preview_media.every((entry) => typeof entry === "string") &&
16
- typeof template.about.link_to_original === "string"), "Template must define id, slugId, version, and complete about metadata.", {
16
+ typeof template.about.link_to_original === "string" &&
17
+ typeof template.about.proposal_generator === "string" &&
18
+ Array.isArray(template.about.sample_prompts) &&
19
+ template.about.sample_prompts.every((entry) => typeof entry === "string") &&
20
+ template.about.sample_prompts.length > 0), "Template must define id, slugId, version, and complete about metadata, including proposal_generator and sample prompts.", {
17
21
  templateId: template.id,
18
22
  slugId: template.slugId,
19
23
  version: template.version,
20
- previewMediaCount: template.about?.preview_media?.length ?? 0
24
+ previewMediaCount: template.about?.preview_media?.length ?? 0,
25
+ hasProposalGenerator: typeof template.about?.proposal_generator === "string" && template.about.proposal_generator.length > 0,
26
+ samplePromptCount: template.about?.sample_prompts?.length ?? 0
21
27
  }));
22
28
  checks.push(this.check("template.operations", Object.keys(template.operations).length > 0, "Template must define at least one operation."));
23
29
  checks.push(this.checkSkill(skillPath));
@@ -124,6 +130,10 @@ function createSmokeContext(templateId) {
124
130
  providerCalls += 1;
125
131
  return { text: "certification text", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
126
132
  },
133
+ async generateSpeech() {
134
+ providerCalls += 1;
135
+ return { bytes: Uint8Array.from([0, 1, 2, 3]), contentType: "audio/mpeg", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
136
+ },
127
137
  async generateImage() {
128
138
  providerCalls += 1;
129
139
  return { bytes: MOCK_PNG_BYTES, contentType: "image/png", revisedPrompt: null };
@@ -136,6 +146,10 @@ function createSmokeContext(templateId) {
136
146
  maxWidthPercent: 80,
137
147
  justification: "Certification mock layout."
138
148
  };
149
+ },
150
+ async transcribeSpeech() {
151
+ providerCalls += 1;
152
+ return { text: "certification transcript", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
139
153
  }
140
154
  },
141
155
  remotion: {
@@ -4,11 +4,22 @@ export async function loadTemplateFromModule(modulePath) {
4
4
  const candidates = Object.values(loaded);
5
5
  for (const candidate of candidates) {
6
6
  if (isTemplateDefinition(candidate)) {
7
- return candidate;
7
+ return normalizeTemplateDefinition(candidate);
8
8
  }
9
9
  }
10
10
  throw new Error(`No template definition export found in ${modulePath}`);
11
11
  }
12
+ function normalizeTemplateDefinition(template) {
13
+ return {
14
+ ...template,
15
+ about: {
16
+ ...template.about,
17
+ // Older runtime template releases may predate proposal metadata fields.
18
+ proposal_generator: template.about.proposal_generator ?? "",
19
+ sample_prompts: template.about.sample_prompts ?? []
20
+ }
21
+ };
22
+ }
12
23
  function isTemplateDefinition(value) {
13
24
  if (!value || typeof value !== "object") {
14
25
  return false;
@@ -20,6 +31,9 @@ function isTemplateDefinition(value) {
20
31
  const previewMedia = about && Array.isArray(about.preview_media)
21
32
  ? about.preview_media
22
33
  : null;
34
+ const samplePrompts = about && Array.isArray(about.sample_prompts)
35
+ ? about.sample_prompts
36
+ : null;
23
37
  return (typeof candidate.id === "string" &&
24
38
  typeof candidate.slugId === "string" &&
25
39
  typeof candidate.version === "string" &&
@@ -31,6 +45,8 @@ function isTemplateDefinition(value) {
31
45
  Array.isArray(previewMedia) &&
32
46
  previewMedia.every((entry) => typeof entry === "string") &&
33
47
  typeof about.link_to_original === "string" &&
48
+ (typeof about.proposal_generator === "string" || typeof about.proposal_generator === "undefined") &&
49
+ (samplePrompts === null || samplePrompts.every((entry) => typeof entry === "string")) &&
34
50
  typeof candidate.configSchema === "object" &&
35
51
  typeof candidate.operations === "object" &&
36
52
  typeof candidate.jobs === "object");