@mevdragon/vidfarm-devcli 0.10.0 → 0.11.0

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.
@@ -7,6 +7,7 @@ import { config } from "./config.js";
7
7
  import { definePrimitive } from "./primitive-sdk.js";
8
8
  import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
9
9
  import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
10
+ import { buildSrtFromSegments, defaultSpeechModelFor } from "./services/speech.js";
10
11
  import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
11
12
  const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
12
13
  const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
@@ -358,6 +359,47 @@ const trimAudioPayloadSchema = z.object({
358
359
  message: "Either duration_ms or end_ms is required.",
359
360
  path: ["duration_ms"]
360
361
  });
362
+ const speechProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
363
+ const ttsPayloadSchema = z.preprocess((raw) => {
364
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
365
+ return raw;
366
+ const payload = raw;
367
+ return {
368
+ ...payload,
369
+ text: payload.text ?? payload.input ?? payload.script,
370
+ instructions: payload.instructions ?? payload.style ?? payload.style_instructions ?? payload.voice_style
371
+ };
372
+ }, z.object({
373
+ provider: speechProviderSchema.optional(),
374
+ model: z.string().min(1).optional(),
375
+ text: z.string().min(1).max(8000),
376
+ voice: z.string().min(1).max(120).optional(),
377
+ instructions: z.string().min(1).max(4000).optional(),
378
+ output_format: z.enum(["mp3", "wav"]).default("mp3")
379
+ }));
380
+ const sttPayloadSchema = z.preprocess((raw) => {
381
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
382
+ return raw;
383
+ const payload = raw;
384
+ return {
385
+ ...payload,
386
+ source_url: payload.source_url ??
387
+ payload.source_video_url ??
388
+ payload.source_audio_url ??
389
+ payload.source_media_url ??
390
+ payload.video_url ??
391
+ payload.audio_url ??
392
+ payload.url
393
+ };
394
+ }, z.object({
395
+ provider: speechProviderSchema.optional(),
396
+ model: z.string().min(1).optional(),
397
+ source_url: mediaSourceUrlSchema,
398
+ media_type: z.enum(["auto", "video", "audio"]).default("auto"),
399
+ diarize: z.boolean().default(true),
400
+ language: z.string().min(2).max(16).optional(),
401
+ prompt: z.string().min(1).max(2000).optional()
402
+ }));
361
403
  const probeVideoPayloadSchema = z.object({
362
404
  source_video_url: mediaSourceUrlSchema
363
405
  });
@@ -508,6 +550,14 @@ const PRIMITIVE_BRAINSTORM_ANGLES_ID = "primitive:brainstorm_angles";
508
550
  const PRIMITIVE_BRAINSTORM_COLDSTART_ID = "primitive:brainstorm_coldstart";
509
551
  const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_placement";
510
552
  const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
553
+ const PRIMITIVE_TTS_ID = "primitive:tts";
554
+ const PRIMITIVE_STT_ID = "primitive:stt";
555
+ // Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
556
+ // that is roughly 40 minutes of speech.
557
+ const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
558
+ // Keep job results comfortably under the DynamoDB item limit: transcripts
559
+ // longer than this inline budget stay artifact-only (transcript.json).
560
+ const MAX_INLINE_TRANSCRIPT_CHARS = 20_000;
511
561
  const primitiveMediaService = new PrimitiveMediaLambdaService();
512
562
  const hooksReferenceText = readPrimitiveAsset("SELLING_WITH_HOOKS.md");
513
563
  const awarenessReferenceText = readPrimitiveAsset("SELLING_AWARENESS_STAGES.md");
@@ -1566,6 +1616,181 @@ const audioTrimPrimitive = definePrimitive({
1566
1616
  }
1567
1617
  }
1568
1618
  });
1619
+ const ttsPrimitive = definePrimitive({
1620
+ id: PRIMITIVE_TTS_ID,
1621
+ kind: "tts",
1622
+ operations: {
1623
+ run: {
1624
+ description: "Generate spoken narration audio from text (TTS) with a promptable voice style, persisted as a reusable platform audio URL.",
1625
+ inputSchema: ttsPayloadSchema,
1626
+ workflow: "run"
1627
+ }
1628
+ },
1629
+ jobs: {
1630
+ async run(ctx, input) {
1631
+ const payload = ttsPayloadSchema.parse(input);
1632
+ ctx.logger.progress(0.12, "Generating speech audio");
1633
+ const provider = payload.provider ?? "openai";
1634
+ const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
1635
+ const generated = await ctx.providers.generateSpeech({
1636
+ provider,
1637
+ model,
1638
+ text: payload.text,
1639
+ voice: payload.voice,
1640
+ instructions: payload.instructions,
1641
+ responseFormat: payload.output_format
1642
+ });
1643
+ // Gemini TTS answers in wav regardless of the requested format; name the
1644
+ // artifact after what actually came back.
1645
+ const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
1646
+ const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
1647
+ contentType: generated.contentType,
1648
+ kind: "audio",
1649
+ metadata: {
1650
+ primitive: "tts",
1651
+ provider,
1652
+ model,
1653
+ voice: payload.voice ?? null,
1654
+ styleInstructions: payload.instructions ?? null
1655
+ }
1656
+ });
1657
+ ctx.logger.progress(1, "Speech generation complete");
1658
+ return {
1659
+ progress: 1,
1660
+ output: {
1661
+ files: compactUrls([stored.url]),
1662
+ primary_file_url: stored.url,
1663
+ audioUrl: stored.url,
1664
+ audio: {
1665
+ file_url: stored.url,
1666
+ content_type: generated.contentType,
1667
+ provider,
1668
+ model,
1669
+ voice: payload.voice ?? null,
1670
+ style_instructions: payload.instructions ?? null,
1671
+ text_length: payload.text.length
1672
+ }
1673
+ }
1674
+ };
1675
+ }
1676
+ }
1677
+ });
1678
+ const sttPrimitive = definePrimitive({
1679
+ id: PRIMITIVE_STT_ID,
1680
+ kind: "stt",
1681
+ operations: {
1682
+ run: {
1683
+ description: "Transcribe speech from a video or audio URL (STT) into a simple subtitle transcript plus speaker-attributed multi-narration segments.",
1684
+ inputSchema: sttPayloadSchema,
1685
+ workflow: "run"
1686
+ }
1687
+ },
1688
+ jobs: {
1689
+ async run(ctx, input) {
1690
+ const payload = sttPayloadSchema.parse(input);
1691
+ const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
1692
+ let audioBytes;
1693
+ let audioContentType;
1694
+ let extractionMetadata = null;
1695
+ if (sourceKind === "audio") {
1696
+ ctx.logger.progress(0.1, "Fetching source audio");
1697
+ const response = await fetch(payload.source_url);
1698
+ if (!response.ok) {
1699
+ throw new Error(`Could not fetch source audio (${response.status}).`);
1700
+ }
1701
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1702
+ audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
1703
+ }
1704
+ else {
1705
+ // Videos (and unknown extensions) go through the ffmpeg seam: it
1706
+ // demuxes video AND normalizes odd audio containers into a small
1707
+ // mono mp3 the speech providers accept.
1708
+ ctx.logger.progress(0.1, "Extracting audio track from source", {
1709
+ executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
1710
+ });
1711
+ const extracted = await executePrimitiveMediaOperation(ctx, {
1712
+ operation: "video_extract_audio",
1713
+ payload: {
1714
+ source_video_url: payload.source_url,
1715
+ output_format: "mp3",
1716
+ audio_bitrate_kbps: 64
1717
+ },
1718
+ outputKey: "source-audio.mp3"
1719
+ });
1720
+ if (!extracted.publicUrl) {
1721
+ throw new Error("Audio extraction produced no readable audio URL.");
1722
+ }
1723
+ const response = await fetch(extracted.publicUrl);
1724
+ if (!response.ok) {
1725
+ throw new Error(`Could not read extracted audio (${response.status}).`);
1726
+ }
1727
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1728
+ audioContentType = extracted.contentType || "audio/mpeg";
1729
+ extractionMetadata = extracted.metadata ?? null;
1730
+ }
1731
+ if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
1732
+ const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
1733
+ const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
1734
+ throw new Error(`Audio is too large to transcribe (${sizeMb}MB > ${maxMb}MB, roughly 40 minutes of speech). Trim the source with /videos/trim or /audio/trim and transcribe in parts.`);
1735
+ }
1736
+ ctx.logger.progress(0.45, "Transcribing speech");
1737
+ const provider = payload.provider ?? "gemini";
1738
+ const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
1739
+ const transcription = await ctx.providers.transcribeSpeech({
1740
+ provider,
1741
+ model,
1742
+ audio: audioBytes,
1743
+ contentType: audioContentType,
1744
+ prompt: payload.prompt,
1745
+ language: payload.language,
1746
+ diarize: payload.diarize
1747
+ });
1748
+ ctx.logger.progress(0.85, "Storing transcript artifacts");
1749
+ const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
1750
+ const srt = buildSrtFromSegments(transcription.segments);
1751
+ const storedText = await ctx.storage.putText("transcript.txt", transcription.text || "");
1752
+ const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
1753
+ const storedJson = await ctx.storage.putJson("transcript.json", {
1754
+ source_url: payload.source_url,
1755
+ provider,
1756
+ model,
1757
+ language: transcription.language,
1758
+ diarization: transcription.diarization,
1759
+ text: transcription.text,
1760
+ speakers,
1761
+ segments: transcription.segments,
1762
+ ...(extractionMetadata ? { extraction: extractionMetadata } : {})
1763
+ });
1764
+ const inlineOk = (transcription.text?.length ?? 0) <= MAX_INLINE_TRANSCRIPT_CHARS;
1765
+ ctx.logger.progress(1, "Speech transcription complete");
1766
+ return {
1767
+ progress: 1,
1768
+ output: {
1769
+ files: compactUrls([storedJson.url, storedSrt?.url, storedText.url]),
1770
+ primary_file_url: storedJson.url,
1771
+ sourceUrl: payload.source_url,
1772
+ text: inlineOk ? transcription.text : transcription.text.slice(0, MAX_INLINE_TRANSCRIPT_CHARS),
1773
+ text_truncated: !inlineOk,
1774
+ language: transcription.language,
1775
+ diarization: transcription.diarization,
1776
+ // Format 1 — SIMPLE SUBTITLE VERSION: plain transcript + SRT cues (no speakers).
1777
+ subtitles: {
1778
+ srt: inlineOk ? srt : null,
1779
+ srt_url: storedSrt?.url ?? null,
1780
+ text_url: storedText.url
1781
+ },
1782
+ // Format 2 — ADVANCED MULTI-SPEAKER VERSION: speaker-attributed timed segments.
1783
+ speakers,
1784
+ segments: inlineOk ? transcription.segments : undefined,
1785
+ transcript: {
1786
+ json_url: storedJson.url,
1787
+ content_type: "application/json"
1788
+ }
1789
+ }
1790
+ };
1791
+ }
1792
+ }
1793
+ });
1569
1794
  const videoProbePrimitive = definePrimitive({
1570
1795
  id: PRIMITIVE_VIDEO_PROBE_ID,
1571
1796
  kind: "video_probe",
@@ -2142,6 +2367,8 @@ class PrimitiveRegistry {
2142
2367
  [videoConcatPrimitive.id, videoConcatPrimitive],
2143
2368
  [audioConcatPrimitive.id, audioConcatPrimitive],
2144
2369
  [audioNormalizePrimitive.id, audioNormalizePrimitive],
2370
+ [ttsPrimitive.id, ttsPrimitive],
2371
+ [sttPrimitive.id, sttPrimitive],
2145
2372
  [hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
2146
2373
  [brainstormHooksPrimitive.id, brainstormHooksPrimitive],
2147
2374
  [brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
@@ -2266,6 +2493,11 @@ function defaultTextModelForProvider(provider) {
2266
2493
  return "gpt-5.4-mini";
2267
2494
  }
2268
2495
  }
2496
+ // Extensions that are safe to feed a speech provider without the ffmpeg
2497
+ // normalization pass; everything else (video or unknown) gets demuxed first.
2498
+ function inferSpeechSourceKind(sourceUrl) {
2499
+ return /\.(mp3|m4a|aac|ogg|oga|flac|wav)(\?|#|$)/i.test(sourceUrl) ? "audio" : "video";
2500
+ }
2269
2501
  function normalizeAiVideoDurationSeconds(explicitSeconds, milliseconds) {
2270
2502
  const seconds = Number(explicitSeconds);
2271
2503
  if (Number.isFinite(seconds) && seconds > 0) {
Binary file
@@ -0,0 +1,74 @@
1
+ // Provider error taxonomy + HTTP-response classification, shared by every
2
+ // surface that talks to an AI provider directly: ProviderService (cloud, leased
3
+ // BYOK keys) and the speech module (also driven key-less from the devcli with a
4
+ // local env key). Lives outside providers.ts so speech.ts can import it without
5
+ // a circular dependency.
6
+ export class ProviderKeyUnavailableError extends Error {
7
+ }
8
+ export class ProviderAuthError extends Error {
9
+ }
10
+ export class ProviderQuotaExceededError extends Error {
11
+ }
12
+ export class ProviderWaitExceededError extends Error {
13
+ }
14
+ export class ProviderLeaseTimeoutError extends Error {
15
+ retryAfterSeconds;
16
+ constructor(message, retryAfterSeconds = 5) {
17
+ super(message);
18
+ this.retryAfterSeconds = retryAfterSeconds;
19
+ }
20
+ }
21
+ export class ProviderRateLimitError extends Error {
22
+ retryAfterSeconds;
23
+ constructor(message, retryAfterSeconds = 60) {
24
+ super(message);
25
+ this.retryAfterSeconds = retryAfterSeconds;
26
+ }
27
+ }
28
+ export class ProviderTransientError extends Error {
29
+ retryAfterSeconds;
30
+ constructor(message, retryAfterSeconds = 5) {
31
+ super(message);
32
+ this.retryAfterSeconds = retryAfterSeconds;
33
+ }
34
+ }
35
+ export function parseRetryAfterSeconds(value) {
36
+ if (!value) {
37
+ return 60;
38
+ }
39
+ const numeric = Number(value);
40
+ if (Number.isFinite(numeric) && numeric > 0) {
41
+ return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
42
+ }
43
+ const parsedDate = Date.parse(value);
44
+ if (Number.isFinite(parsedDate)) {
45
+ return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
46
+ }
47
+ return 60;
48
+ }
49
+ export function isHardQuotaError(details) {
50
+ const normalized = details.toLowerCase();
51
+ return [
52
+ "insufficient_quota",
53
+ "quota_exceeded",
54
+ "exceeded your current quota",
55
+ "billing",
56
+ "insufficient credits",
57
+ "not enough credits",
58
+ "credit balance",
59
+ "prepaid credits",
60
+ "resource_exhausted"
61
+ ].some((pattern) => normalized.includes(pattern));
62
+ }
63
+ export function conciseProviderDetails(details) {
64
+ const compact = details.replace(/\s+/g, " ").trim();
65
+ return compact ? `: ${compact.slice(0, 500)}` : "";
66
+ }
67
+ export async function rateLimitOrQuotaError(provider, response) {
68
+ const details = await response.text();
69
+ if (isHardQuotaError(details)) {
70
+ return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
71
+ }
72
+ return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
73
+ }
74
+ //# sourceMappingURL=provider-errors.js.map