@mevdragon/vidfarm-devcli 0.10.0 → 0.12.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, inferSpeechProviderForVoice, speechVoiceMismatch } 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,56 @@ 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
+ }).superRefine((value, ctx) => {
380
+ // Reject an explicit provider/voice contradiction at enqueue (400) instead
381
+ // of queueing a job that can only fail at the provider.
382
+ if (!value.provider || !value.voice)
383
+ return;
384
+ const mismatch = speechVoiceMismatch({ provider: value.provider, model: value.model, voice: value.voice });
385
+ if (mismatch) {
386
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["voice"], message: mismatch });
387
+ }
388
+ }));
389
+ const sttPayloadSchema = z.preprocess((raw) => {
390
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
391
+ return raw;
392
+ const payload = raw;
393
+ return {
394
+ ...payload,
395
+ source_url: payload.source_url ??
396
+ payload.source_video_url ??
397
+ payload.source_audio_url ??
398
+ payload.source_media_url ??
399
+ payload.video_url ??
400
+ payload.audio_url ??
401
+ payload.url
402
+ };
403
+ }, z.object({
404
+ provider: speechProviderSchema.optional(),
405
+ model: z.string().min(1).optional(),
406
+ source_url: mediaSourceUrlSchema,
407
+ media_type: z.enum(["auto", "video", "audio"]).default("auto"),
408
+ diarize: z.boolean().default(true),
409
+ language: z.string().min(2).max(16).optional(),
410
+ prompt: z.string().min(1).max(2000).optional()
411
+ }));
361
412
  const probeVideoPayloadSchema = z.object({
362
413
  source_video_url: mediaSourceUrlSchema
363
414
  });
@@ -508,6 +559,14 @@ const PRIMITIVE_BRAINSTORM_ANGLES_ID = "primitive:brainstorm_angles";
508
559
  const PRIMITIVE_BRAINSTORM_COLDSTART_ID = "primitive:brainstorm_coldstart";
509
560
  const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_placement";
510
561
  const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
562
+ const PRIMITIVE_TTS_ID = "primitive:tts";
563
+ const PRIMITIVE_STT_ID = "primitive:stt";
564
+ // Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
565
+ // that is roughly 40 minutes of speech.
566
+ const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
567
+ // Keep job results comfortably under the DynamoDB item limit: transcripts
568
+ // longer than this inline budget stay artifact-only (transcript.json).
569
+ const MAX_INLINE_TRANSCRIPT_CHARS = 20_000;
511
570
  const primitiveMediaService = new PrimitiveMediaLambdaService();
512
571
  const hooksReferenceText = readPrimitiveAsset("SELLING_WITH_HOOKS.md");
513
572
  const awarenessReferenceText = readPrimitiveAsset("SELLING_AWARENESS_STAGES.md");
@@ -1566,6 +1625,183 @@ const audioTrimPrimitive = definePrimitive({
1566
1625
  }
1567
1626
  }
1568
1627
  });
1628
+ const ttsPrimitive = definePrimitive({
1629
+ id: PRIMITIVE_TTS_ID,
1630
+ kind: "tts",
1631
+ operations: {
1632
+ run: {
1633
+ description: "Generate spoken narration audio from text (TTS) with a promptable voice style, persisted as a reusable platform audio URL.",
1634
+ inputSchema: ttsPayloadSchema,
1635
+ workflow: "run"
1636
+ }
1637
+ },
1638
+ jobs: {
1639
+ async run(ctx, input) {
1640
+ const payload = ttsPayloadSchema.parse(input);
1641
+ ctx.logger.progress(0.12, "Generating speech audio");
1642
+ // A bare voice preset implies its provider ("Puck" → gemini) — blindly
1643
+ // defaulting to openai would guarantee a cross-family voice failure.
1644
+ const provider = payload.provider ?? inferSpeechProviderForVoice(payload.voice) ?? "openai";
1645
+ const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
1646
+ const generated = await ctx.providers.generateSpeech({
1647
+ provider,
1648
+ model,
1649
+ text: payload.text,
1650
+ voice: payload.voice,
1651
+ instructions: payload.instructions,
1652
+ responseFormat: payload.output_format
1653
+ });
1654
+ // Gemini TTS answers in wav regardless of the requested format; name the
1655
+ // artifact after what actually came back.
1656
+ const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
1657
+ const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
1658
+ contentType: generated.contentType,
1659
+ kind: "audio",
1660
+ metadata: {
1661
+ primitive: "tts",
1662
+ provider,
1663
+ model,
1664
+ voice: payload.voice ?? null,
1665
+ styleInstructions: payload.instructions ?? null
1666
+ }
1667
+ });
1668
+ ctx.logger.progress(1, "Speech generation complete");
1669
+ return {
1670
+ progress: 1,
1671
+ output: {
1672
+ files: compactUrls([stored.url]),
1673
+ primary_file_url: stored.url,
1674
+ audioUrl: stored.url,
1675
+ audio: {
1676
+ file_url: stored.url,
1677
+ content_type: generated.contentType,
1678
+ provider,
1679
+ model,
1680
+ voice: payload.voice ?? null,
1681
+ style_instructions: payload.instructions ?? null,
1682
+ text_length: payload.text.length
1683
+ }
1684
+ }
1685
+ };
1686
+ }
1687
+ }
1688
+ });
1689
+ const sttPrimitive = definePrimitive({
1690
+ id: PRIMITIVE_STT_ID,
1691
+ kind: "stt",
1692
+ operations: {
1693
+ run: {
1694
+ description: "Transcribe speech from a video or audio URL (STT) into a simple subtitle transcript plus speaker-attributed multi-narration segments.",
1695
+ inputSchema: sttPayloadSchema,
1696
+ workflow: "run"
1697
+ }
1698
+ },
1699
+ jobs: {
1700
+ async run(ctx, input) {
1701
+ const payload = sttPayloadSchema.parse(input);
1702
+ const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
1703
+ let audioBytes;
1704
+ let audioContentType;
1705
+ let extractionMetadata = null;
1706
+ if (sourceKind === "audio") {
1707
+ ctx.logger.progress(0.1, "Fetching source audio");
1708
+ const response = await fetch(payload.source_url);
1709
+ if (!response.ok) {
1710
+ throw new Error(`Could not fetch source audio (${response.status}).`);
1711
+ }
1712
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1713
+ audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
1714
+ }
1715
+ else {
1716
+ // Videos (and unknown extensions) go through the ffmpeg seam: it
1717
+ // demuxes video AND normalizes odd audio containers into a small
1718
+ // mono mp3 the speech providers accept.
1719
+ ctx.logger.progress(0.1, "Extracting audio track from source", {
1720
+ executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
1721
+ });
1722
+ const extracted = await executePrimitiveMediaOperation(ctx, {
1723
+ operation: "video_extract_audio",
1724
+ payload: {
1725
+ source_video_url: payload.source_url,
1726
+ output_format: "mp3",
1727
+ audio_bitrate_kbps: 64
1728
+ },
1729
+ outputKey: "source-audio.mp3"
1730
+ });
1731
+ if (!extracted.publicUrl) {
1732
+ throw new Error("Audio extraction produced no readable audio URL.");
1733
+ }
1734
+ const response = await fetch(extracted.publicUrl);
1735
+ if (!response.ok) {
1736
+ throw new Error(`Could not read extracted audio (${response.status}).`);
1737
+ }
1738
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1739
+ audioContentType = extracted.contentType || "audio/mpeg";
1740
+ extractionMetadata = extracted.metadata ?? null;
1741
+ }
1742
+ if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
1743
+ const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
1744
+ const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
1745
+ 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.`);
1746
+ }
1747
+ ctx.logger.progress(0.45, "Transcribing speech");
1748
+ const provider = payload.provider ?? "gemini";
1749
+ const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
1750
+ const transcription = await ctx.providers.transcribeSpeech({
1751
+ provider,
1752
+ model,
1753
+ audio: audioBytes,
1754
+ contentType: audioContentType,
1755
+ prompt: payload.prompt,
1756
+ language: payload.language,
1757
+ diarize: payload.diarize
1758
+ });
1759
+ ctx.logger.progress(0.85, "Storing transcript artifacts");
1760
+ const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
1761
+ const srt = buildSrtFromSegments(transcription.segments);
1762
+ const storedText = await ctx.storage.putText("transcript.txt", transcription.text || "");
1763
+ const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
1764
+ const storedJson = await ctx.storage.putJson("transcript.json", {
1765
+ source_url: payload.source_url,
1766
+ provider,
1767
+ model,
1768
+ language: transcription.language,
1769
+ diarization: transcription.diarization,
1770
+ text: transcription.text,
1771
+ speakers,
1772
+ segments: transcription.segments,
1773
+ ...(extractionMetadata ? { extraction: extractionMetadata } : {})
1774
+ });
1775
+ const inlineOk = (transcription.text?.length ?? 0) <= MAX_INLINE_TRANSCRIPT_CHARS;
1776
+ ctx.logger.progress(1, "Speech transcription complete");
1777
+ return {
1778
+ progress: 1,
1779
+ output: {
1780
+ files: compactUrls([storedJson.url, storedSrt?.url, storedText.url]),
1781
+ primary_file_url: storedJson.url,
1782
+ sourceUrl: payload.source_url,
1783
+ text: inlineOk ? transcription.text : transcription.text.slice(0, MAX_INLINE_TRANSCRIPT_CHARS),
1784
+ text_truncated: !inlineOk,
1785
+ language: transcription.language,
1786
+ diarization: transcription.diarization,
1787
+ // Format 1 — SIMPLE SUBTITLE VERSION: plain transcript + SRT cues (no speakers).
1788
+ subtitles: {
1789
+ srt: inlineOk ? srt : null,
1790
+ srt_url: storedSrt?.url ?? null,
1791
+ text_url: storedText.url
1792
+ },
1793
+ // Format 2 — ADVANCED MULTI-SPEAKER VERSION: speaker-attributed timed segments.
1794
+ speakers,
1795
+ segments: inlineOk ? transcription.segments : undefined,
1796
+ transcript: {
1797
+ json_url: storedJson.url,
1798
+ content_type: "application/json"
1799
+ }
1800
+ }
1801
+ };
1802
+ }
1803
+ }
1804
+ });
1569
1805
  const videoProbePrimitive = definePrimitive({
1570
1806
  id: PRIMITIVE_VIDEO_PROBE_ID,
1571
1807
  kind: "video_probe",
@@ -2142,6 +2378,8 @@ class PrimitiveRegistry {
2142
2378
  [videoConcatPrimitive.id, videoConcatPrimitive],
2143
2379
  [audioConcatPrimitive.id, audioConcatPrimitive],
2144
2380
  [audioNormalizePrimitive.id, audioNormalizePrimitive],
2381
+ [ttsPrimitive.id, ttsPrimitive],
2382
+ [sttPrimitive.id, sttPrimitive],
2145
2383
  [hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
2146
2384
  [brainstormHooksPrimitive.id, brainstormHooksPrimitive],
2147
2385
  [brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
@@ -2266,6 +2504,11 @@ function defaultTextModelForProvider(provider) {
2266
2504
  return "gpt-5.4-mini";
2267
2505
  }
2268
2506
  }
2507
+ // Extensions that are safe to feed a speech provider without the ffmpeg
2508
+ // normalization pass; everything else (video or unknown) gets demuxed first.
2509
+ function inferSpeechSourceKind(sourceUrl) {
2510
+ return /\.(mp3|m4a|aac|ogg|oga|flac|wav)(\?|#|$)/i.test(sourceUrl) ? "audio" : "video";
2511
+ }
2269
2512
  function normalizeAiVideoDurationSeconds(explicitSeconds, milliseconds) {
2270
2513
  const seconds = Number(explicitSeconds);
2271
2514
  if (Number.isFinite(seconds) && seconds > 0) {
@@ -0,0 +1,123 @@
1
+ // Turns a transcript into animated-caption cues. Shared by the devcli
2
+ // `captions` command group, the /editor chat agent's set_captions action, and
3
+ // any server surface that wants captions from STT output. Pure — no network,
4
+ // no filesystem — so it is safe to import from the devcli.
5
+ //
6
+ // A "cue" is one caption layer on the timeline: a short run of words shown
7
+ // together (CapCut-style pages of ~3-5 words), with per-word windows relative
8
+ // to the cue start driving the animation. Word timings come from the provider
9
+ // when available (openai whisper-1) and are char-weight estimated inside each
10
+ // segment otherwise (gemini estimates, hand-typed text) — see
11
+ // estimateCaptionWords.
12
+ import { estimateCaptionWords } from "../hyperframes/composition.js";
13
+ const CUE_TAIL_SEC = 0.12;
14
+ const MIN_WORD_SPAN_SEC = 0.05;
15
+ /** Flattens transcript segments into absolute-timed words, estimating windows
16
+ * for segments the provider left word-less (or entirely time-less — those
17
+ * distribute across fallbackDurationSec). */
18
+ export function transcriptWordTimeline(segments, fallbackDurationSec) {
19
+ const words = [];
20
+ const timeless = segments.filter((segment) => typeof segment.start_sec !== "number");
21
+ for (const segment of segments) {
22
+ if (segment.words?.length) {
23
+ words.push(...segment.words.map((word) => ({ ...word, text: word.text.trim() })).filter((word) => word.text));
24
+ continue;
25
+ }
26
+ let base;
27
+ let span;
28
+ if (typeof segment.start_sec === "number") {
29
+ base = segment.start_sec;
30
+ span = typeof segment.end_sec === "number" && segment.end_sec > segment.start_sec
31
+ ? segment.end_sec - segment.start_sec
32
+ : 0;
33
+ }
34
+ else {
35
+ // No timings at all (plain-text providers): spread the blob across the
36
+ // caller-provided window so captions still land somewhere sensible.
37
+ base = 0;
38
+ span = Number.isFinite(fallbackDurationSec) && fallbackDurationSec > 0 && timeless.length
39
+ ? fallbackDurationSec / timeless.length
40
+ : 0;
41
+ if (timeless.length > 1) {
42
+ base = (span || 0) * timeless.indexOf(segment);
43
+ }
44
+ }
45
+ const estimated = estimateCaptionWords(segment.text, span);
46
+ words.push(...estimated.map((word) => ({
47
+ text: word.text,
48
+ start_sec: base + word.start,
49
+ end_sec: base + word.end
50
+ })));
51
+ }
52
+ return words
53
+ .filter((word) => Number.isFinite(word.start_sec) && Number.isFinite(word.end_sec) && word.end_sec > word.start_sec)
54
+ .sort((a, b) => a.start_sec - b.start_sec);
55
+ }
56
+ export function buildCaptionCues(segments, options) {
57
+ const words = transcriptWordTimeline(segments, options?.fallbackDurationSec);
58
+ return cuesFromWordTimeline(words, options);
59
+ }
60
+ export function cuesFromWordTimeline(words, options) {
61
+ const maxWords = Math.max(1, Math.round(options?.maxWordsPerCue ?? 4));
62
+ const maxDuration = Math.max(0.4, options?.maxCueDurationSec ?? 2.6);
63
+ const gapBreak = Math.max(0.15, options?.gapBreakSec ?? 0.6);
64
+ const offset = Number.isFinite(options?.offsetSec) ? options?.offsetSec : 0;
65
+ const cues = [];
66
+ let bucket = [];
67
+ const flush = () => {
68
+ if (!bucket.length)
69
+ return;
70
+ const start = bucket[0].start_sec;
71
+ const end = bucket[bucket.length - 1].end_sec + CUE_TAIL_SEC;
72
+ cues.push({
73
+ text: bucket.map((word) => word.text).join(" "),
74
+ start: round3(start + offset),
75
+ duration: round3(Math.max(MIN_WORD_SPAN_SEC * bucket.length, end - start)),
76
+ words: bucket.map((word) => ({
77
+ text: word.text,
78
+ start: round3(Math.max(0, word.start_sec - start)),
79
+ end: round3(Math.max(word.start_sec - start + MIN_WORD_SPAN_SEC, word.end_sec - start))
80
+ }))
81
+ });
82
+ bucket = [];
83
+ };
84
+ for (const word of words) {
85
+ if (bucket.length) {
86
+ const cueStart = bucket[0].start_sec;
87
+ const previous = bucket[bucket.length - 1];
88
+ const silence = word.start_sec - previous.end_sec;
89
+ const wouldRun = word.end_sec - cueStart;
90
+ const sentenceBreak = /[.!?…]["')\]]?$/.test(previous.text);
91
+ if (bucket.length >= maxWords || silence > gapBreak || wouldRun > maxDuration || sentenceBreak) {
92
+ flush();
93
+ }
94
+ }
95
+ bucket.push(word);
96
+ }
97
+ flush();
98
+ // Clamp each cue against the next so consecutive caption layers never
99
+ // overlap on the shared track (one-clip-per-(track,time) invariant).
100
+ for (let index = 0; index < cues.length - 1; index += 1) {
101
+ const cue = cues[index];
102
+ const next = cues[index + 1];
103
+ const maxEnd = next.start - 0.02;
104
+ if (cue.start + cue.duration > maxEnd) {
105
+ cue.duration = round3(Math.max(MIN_WORD_SPAN_SEC, maxEnd - cue.start));
106
+ }
107
+ }
108
+ return cues.filter((cue) => cue.duration > 0);
109
+ }
110
+ /** Cues straight from plain text spread across a window — the no-transcript
111
+ * path (AI-authored captions, manual cue text). */
112
+ export function cuesFromText(text, startSec, durationSec, options) {
113
+ const words = estimateCaptionWords(text, durationSec).map((word) => ({
114
+ text: word.text,
115
+ start_sec: startSec + word.start,
116
+ end_sec: startSec + word.end
117
+ }));
118
+ return cuesFromWordTimeline(words, options);
119
+ }
120
+ function round3(value) {
121
+ return Number(value.toFixed(3));
122
+ }
123
+ //# sourceMappingURL=captions.js.map
@@ -13,7 +13,7 @@ import ffprobeStatic from "ffprobe-static";
13
13
  import { parseHTML } from "linkedom";
14
14
  import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
- import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
16
+ import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_ADJACENCY_EPSILON, TRANSITION_CSS, TRANSITION_PRESETS, TRANSITION_STYLE_MARKER, buildHyperframeCompositionHtml, clampTransitionDuration } from "../hyperframes/composition.js";
17
17
  import { applyMarkupUsd } from "./billing-pricing.js";
18
18
  // The scene-annotation pass reuses the clip library's controlled vocabulary and
19
19
  // embedding-text recipe verbatim so a source scene's clip-match query is
@@ -2296,6 +2296,102 @@ function compositionHasCoverageGap(intervals, total, minGap = 0.2) {
2296
2296
  }
2297
2297
  return cursor < total - minGap;
2298
2298
  }
2299
+ // Scene transitions: the INCOMING clip carries data-transition and its vf-tr-*
2300
+ // animation plays over the clip's first data-transition-duration seconds — the
2301
+ // render engine's CSS adapter scrubs that on its own. What the render engine
2302
+ // canNOT do is keep the OUTGOING clip on screen beneath it (visibility is
2303
+ // derived strictly from data-start/data-duration), so materialize the overlap
2304
+ // here: extend the previous same-track butt-cut clip's data-duration by the
2305
+ // transition length. The granted extension is recorded in data-transition-hold
2306
+ // so the pass is idempotent and self-healing — every run first restores each
2307
+ // clip's nominal duration (duration - hold), then re-grants from the current
2308
+ // set of [data-transition] clips (so removed/retimed transitions shrink their
2309
+ // neighbor back). Stored drafts stay butt-cut; only publish/render output
2310
+ // carries the overlap. The preview runtime applies the same window live.
2311
+ function materializeTransitionOverlaps(document) {
2312
+ const clips = [];
2313
+ for (const el of Array.from(document.querySelectorAll("[data-start]"))) {
2314
+ if (el.hasAttribute("data-composition-id"))
2315
+ continue;
2316
+ const start = Number.parseFloat(el.getAttribute("data-start") || "");
2317
+ const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
2318
+ if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0)
2319
+ continue;
2320
+ const priorHold = Number.parseFloat(el.getAttribute("data-transition-hold") || "");
2321
+ const hold = Number.isFinite(priorHold) && priorHold > 0 ? Math.min(priorHold, duration) : 0;
2322
+ clips.push({
2323
+ el,
2324
+ track: Number.parseInt(el.getAttribute("data-track-index") || "0", 10) || 0,
2325
+ start,
2326
+ nominal: duration - hold,
2327
+ hold: 0
2328
+ });
2329
+ }
2330
+ if (!clips.length)
2331
+ return;
2332
+ for (const clip of clips) {
2333
+ const transitionEl = clip.el;
2334
+ const preset = transitionEl.getAttribute("data-transition");
2335
+ if (!preset)
2336
+ continue;
2337
+ if (!TRANSITION_PRESETS.includes(preset)) {
2338
+ // Unknown preset would render as a permanently-paused animation with no
2339
+ // keyframes — harmless — but drop it so agents get clean state back.
2340
+ transitionEl.removeAttribute("data-transition");
2341
+ transitionEl.removeAttribute("data-transition-duration");
2342
+ continue;
2343
+ }
2344
+ const tagName = transitionEl.tagName?.toLowerCase?.() ?? "";
2345
+ if (tagName === "audio") {
2346
+ transitionEl.removeAttribute("data-transition");
2347
+ transitionEl.removeAttribute("data-transition-duration");
2348
+ continue;
2349
+ }
2350
+ // The animation cannot outlast its own clip.
2351
+ const duration = Math.min(clampTransitionDuration(transitionEl.getAttribute("data-transition-duration")), clip.nominal);
2352
+ const durationValue = Number(duration.toFixed(3));
2353
+ transitionEl.setAttribute("data-transition-duration", String(durationValue));
2354
+ // Keep the inline var (which drives animation-duration) in lockstep, same
2355
+ // as the --vf-kb-dur re-derivation above.
2356
+ const styleValue = transitionEl.getAttribute("style") || "";
2357
+ const durDecl = `--vf-tr-dur:${durationValue}s`;
2358
+ const nextStyle = /(^|;)\s*--vf-tr-dur\s*:[^;]*/i.test(styleValue)
2359
+ ? styleValue.replace(/(^|;)\s*--vf-tr-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
2360
+ : styleValue.length === 0 || styleValue.endsWith(";")
2361
+ ? `${styleValue}${durDecl}`
2362
+ : `${styleValue};${durDecl}`;
2363
+ if (nextStyle !== styleValue)
2364
+ transitionEl.setAttribute("style", nextStyle);
2365
+ // Grant the hold to the clip this one cuts away from: same track, nominal
2366
+ // end at (within epsilon of) this clip's start.
2367
+ for (const candidate of clips) {
2368
+ if (candidate === clip || candidate.track !== clip.track)
2369
+ continue;
2370
+ if (Math.abs(candidate.start + candidate.nominal - clip.start) > TRANSITION_ADJACENCY_EPSILON)
2371
+ continue;
2372
+ const candidateTag = candidate.el.tagName?.toLowerCase?.() ?? "";
2373
+ if (candidateTag === "audio")
2374
+ continue;
2375
+ candidate.hold = Math.max(candidate.hold, duration);
2376
+ }
2377
+ }
2378
+ for (const clip of clips) {
2379
+ const nextDuration = Number((clip.nominal + clip.hold).toFixed(3));
2380
+ const currentDuration = Number.parseFloat(clip.el.getAttribute("data-duration") || "");
2381
+ if (currentDuration !== nextDuration) {
2382
+ clip.el.setAttribute("data-duration", String(nextDuration));
2383
+ }
2384
+ if (clip.el.hasAttribute("data-end")) {
2385
+ clip.el.setAttribute("data-end", String(Number((clip.start + nextDuration).toFixed(3))));
2386
+ }
2387
+ if (clip.hold > 0) {
2388
+ clip.el.setAttribute("data-transition-hold", String(Number(clip.hold.toFixed(3))));
2389
+ }
2390
+ else {
2391
+ clip.el.removeAttribute("data-transition-hold");
2392
+ }
2393
+ }
2394
+ }
2299
2395
  export function normalizePublishHtml(html) {
2300
2396
  const { document } = parseHTML(html);
2301
2397
  // Coverage gaps: any interval of the timeline with no active canvas-filling
@@ -2412,6 +2508,22 @@ export function normalizePublishHtml(html) {
2412
2508
  styleEl.textContent = KEN_BURNS_CSS;
2413
2509
  (document.head ?? document.documentElement)?.appendChild(styleEl);
2414
2510
  }
2511
+ // Animated captions: word windows are stored relative to the layer start
2512
+ // (inline animation-delay/duration on each [data-cap-word]), so retimes need
2513
+ // no re-derivation — but the shared vf-cap-* keyframes stylesheet must
2514
+ // travel with compositions stored before the builder embedded it, or every
2515
+ // word renders in its resting state.
2516
+ if (document.querySelector("[data-caption-animation]") && !html.includes(CAPTION_STYLE_MARKER)) {
2517
+ const styleEl = document.createElement("style");
2518
+ styleEl.textContent = CAPTION_ANIM_CSS;
2519
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2520
+ }
2521
+ materializeTransitionOverlaps(document);
2522
+ if (document.querySelector("[data-transition]") && !html.includes(TRANSITION_STYLE_MARKER)) {
2523
+ const styleEl = document.createElement("style");
2524
+ styleEl.textContent = TRANSITION_CSS;
2525
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2526
+ }
2415
2527
  // Editor sessions occasionally leave inline style attributes with double
2416
2528
  // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
2417
2529
  // an HTML attribute, those quotes become &quot; entities. The render lambda
Binary file
@@ -0,0 +1,77 @@
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
+ /** A TTS voice preset that belongs to a different provider family — user input error, never retryable. */
11
+ export class SpeechVoiceMismatchError extends Error {
12
+ }
13
+ export class ProviderQuotaExceededError extends Error {
14
+ }
15
+ export class ProviderWaitExceededError extends Error {
16
+ }
17
+ export class ProviderLeaseTimeoutError extends Error {
18
+ retryAfterSeconds;
19
+ constructor(message, retryAfterSeconds = 5) {
20
+ super(message);
21
+ this.retryAfterSeconds = retryAfterSeconds;
22
+ }
23
+ }
24
+ export class ProviderRateLimitError extends Error {
25
+ retryAfterSeconds;
26
+ constructor(message, retryAfterSeconds = 60) {
27
+ super(message);
28
+ this.retryAfterSeconds = retryAfterSeconds;
29
+ }
30
+ }
31
+ export class ProviderTransientError extends Error {
32
+ retryAfterSeconds;
33
+ constructor(message, retryAfterSeconds = 5) {
34
+ super(message);
35
+ this.retryAfterSeconds = retryAfterSeconds;
36
+ }
37
+ }
38
+ export function parseRetryAfterSeconds(value) {
39
+ if (!value) {
40
+ return 60;
41
+ }
42
+ const numeric = Number(value);
43
+ if (Number.isFinite(numeric) && numeric > 0) {
44
+ return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
45
+ }
46
+ const parsedDate = Date.parse(value);
47
+ if (Number.isFinite(parsedDate)) {
48
+ return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
49
+ }
50
+ return 60;
51
+ }
52
+ export function isHardQuotaError(details) {
53
+ const normalized = details.toLowerCase();
54
+ return [
55
+ "insufficient_quota",
56
+ "quota_exceeded",
57
+ "exceeded your current quota",
58
+ "billing",
59
+ "insufficient credits",
60
+ "not enough credits",
61
+ "credit balance",
62
+ "prepaid credits",
63
+ "resource_exhausted"
64
+ ].some((pattern) => normalized.includes(pattern));
65
+ }
66
+ export function conciseProviderDetails(details) {
67
+ const compact = details.replace(/\s+/g, " ").trim();
68
+ return compact ? `: ${compact.slice(0, 500)}` : "";
69
+ }
70
+ export async function rateLimitOrQuotaError(provider, response) {
71
+ const details = await response.text();
72
+ if (isHardQuotaError(details)) {
73
+ return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
74
+ }
75
+ return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
76
+ }
77
+ //# sourceMappingURL=provider-errors.js.map