@mevdragon/vidfarm-devcli 0.11.0 → 0.13.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,7 +7,9 @@ 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
+ import { buildSrtFromSegments, defaultSpeechModelFor, inferSpeechProviderForVoice, speechVoiceMismatch } from "./services/speech.js";
11
+ import { buildCaptionCues, cuesFromText } from "./services/captions.js";
12
+ import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById } from "./hyperframes/composition.js";
11
13
  import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
12
14
  const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
13
15
  const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
@@ -376,6 +378,15 @@ const ttsPayloadSchema = z.preprocess((raw) => {
376
378
  voice: z.string().min(1).max(120).optional(),
377
379
  instructions: z.string().min(1).max(4000).optional(),
378
380
  output_format: z.enum(["mp3", "wav"]).default("mp3")
381
+ }).superRefine((value, ctx) => {
382
+ // Reject an explicit provider/voice contradiction at enqueue (400) instead
383
+ // of queueing a job that can only fail at the provider.
384
+ if (!value.provider || !value.voice)
385
+ return;
386
+ const mismatch = speechVoiceMismatch({ provider: value.provider, model: value.model, voice: value.voice });
387
+ if (mismatch) {
388
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["voice"], message: mismatch });
389
+ }
379
390
  }));
380
391
  const sttPayloadSchema = z.preprocess((raw) => {
381
392
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -397,9 +408,58 @@ const sttPayloadSchema = z.preprocess((raw) => {
397
408
  source_url: mediaSourceUrlSchema,
398
409
  media_type: z.enum(["auto", "video", "audio"]).default("auto"),
399
410
  diarize: z.boolean().default(true),
411
+ // Word-level timings on each segment (animated captions). Real timestamps
412
+ // come from the openai path (whisper-1); other providers keep segment-level
413
+ // output and downstream callers estimate word windows.
414
+ word_timestamps: z.boolean().default(false),
400
415
  language: z.string().min(2).max(16).optional(),
401
416
  prompt: z.string().min(1).max(2000).optional()
402
417
  }));
418
+ // Animated captions from speech: STT with word-level timings, paged into
419
+ // ready-to-apply cue layers. Either transcribe a source_url or page plain text.
420
+ const captionsPayloadSchema = z.preprocess((raw) => {
421
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
422
+ return raw;
423
+ const payload = raw;
424
+ return {
425
+ ...payload,
426
+ source_url: payload.source_url ??
427
+ payload.source_video_url ??
428
+ payload.source_audio_url ??
429
+ payload.source_media_url ??
430
+ payload.video_url ??
431
+ payload.audio_url ??
432
+ payload.url
433
+ };
434
+ }, z.object({
435
+ provider: speechProviderSchema.optional(),
436
+ model: z.string().min(1).optional(),
437
+ source_url: mediaSourceUrlSchema.optional(),
438
+ media_type: z.enum(["auto", "video", "audio"]).default("auto"),
439
+ /** Skip STT entirely: page this script across the window instead. */
440
+ text: z.string().min(1).max(20_000).optional(),
441
+ language: z.string().min(2).max(16).optional(),
442
+ prompt: z.string().min(1).max(2000).optional(),
443
+ /** Named look: spotlight | karaoke | word-pop | stack-up | neon | bounce. */
444
+ style: z.string().min(1).max(32).optional(),
445
+ animation: z.enum(CAPTION_ANIMATIONS).optional(),
446
+ active_color: z.string().min(1).max(64).optional(),
447
+ highlight_color: z.string().min(1).max(64).optional(),
448
+ uppercase: z.boolean().optional(),
449
+ color: z.string().min(1).max(64).optional(),
450
+ background: z.string().min(1).max(64).optional(),
451
+ background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional(),
452
+ font_family: z.string().min(1).max(64).optional(),
453
+ font_size: z.number().int().min(8).max(200).optional(),
454
+ max_words_per_cue: z.number().int().min(1).max(8).default(4),
455
+ /** Shift every cue by this many seconds (where the narration layer starts). */
456
+ offset_sec: z.number().min(0).max(3600).default(0),
457
+ /** Text mode window: where the paged script starts and how long it spans. */
458
+ window_start: z.number().min(0).max(3600).default(0),
459
+ window_duration: z.number().positive().max(3600).optional()
460
+ }).refine((payload) => Boolean(payload.source_url || payload.text), {
461
+ message: "Provide source_url (audio/video to transcribe) or text (script to page)."
462
+ }));
403
463
  const probeVideoPayloadSchema = z.object({
404
464
  source_video_url: mediaSourceUrlSchema
405
465
  });
@@ -552,6 +612,7 @@ const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_
552
612
  const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
553
613
  const PRIMITIVE_TTS_ID = "primitive:tts";
554
614
  const PRIMITIVE_STT_ID = "primitive:stt";
615
+ const PRIMITIVE_CAPTIONS_ID = "primitive:captions";
555
616
  // Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
556
617
  // that is roughly 40 minutes of speech.
557
618
  const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
@@ -1630,7 +1691,9 @@ const ttsPrimitive = definePrimitive({
1630
1691
  async run(ctx, input) {
1631
1692
  const payload = ttsPayloadSchema.parse(input);
1632
1693
  ctx.logger.progress(0.12, "Generating speech audio");
1633
- const provider = payload.provider ?? "openai";
1694
+ // A bare voice preset implies its provider ("Puck" → gemini) — blindly
1695
+ // defaulting to openai would guarantee a cross-family voice failure.
1696
+ const provider = payload.provider ?? inferSpeechProviderForVoice(payload.voice) ?? "openai";
1634
1697
  const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
1635
1698
  const generated = await ctx.providers.generateSpeech({
1636
1699
  provider,
@@ -1675,6 +1738,55 @@ const ttsPrimitive = definePrimitive({
1675
1738
  }
1676
1739
  }
1677
1740
  });
1741
+ // Shared source→speech-ready-audio loader for the STT-family primitives.
1742
+ // Plain audio URLs pass through; videos (and unknown containers) go through
1743
+ // the ffmpeg seam, which demuxes and normalizes into a small mono mp3 the
1744
+ // speech providers accept.
1745
+ async function loadSpeechAudioForPrimitive(ctx, payload) {
1746
+ const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
1747
+ let audioBytes;
1748
+ let audioContentType;
1749
+ let extractionMetadata = null;
1750
+ if (sourceKind === "audio") {
1751
+ ctx.logger.progress(0.1, "Fetching source audio");
1752
+ const response = await fetch(payload.source_url);
1753
+ if (!response.ok) {
1754
+ throw new Error(`Could not fetch source audio (${response.status}).`);
1755
+ }
1756
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1757
+ audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
1758
+ }
1759
+ else {
1760
+ ctx.logger.progress(0.1, "Extracting audio track from source", {
1761
+ executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
1762
+ });
1763
+ const extracted = await executePrimitiveMediaOperation(ctx, {
1764
+ operation: "video_extract_audio",
1765
+ payload: {
1766
+ source_video_url: payload.source_url,
1767
+ output_format: "mp3",
1768
+ audio_bitrate_kbps: 64
1769
+ },
1770
+ outputKey: "source-audio.mp3"
1771
+ });
1772
+ if (!extracted.publicUrl) {
1773
+ throw new Error("Audio extraction produced no readable audio URL.");
1774
+ }
1775
+ const response = await fetch(extracted.publicUrl);
1776
+ if (!response.ok) {
1777
+ throw new Error(`Could not read extracted audio (${response.status}).`);
1778
+ }
1779
+ audioBytes = new Uint8Array(await response.arrayBuffer());
1780
+ audioContentType = extracted.contentType || "audio/mpeg";
1781
+ extractionMetadata = extracted.metadata ?? null;
1782
+ }
1783
+ if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
1784
+ const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
1785
+ const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
1786
+ 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.`);
1787
+ }
1788
+ return { audioBytes, audioContentType, extractionMetadata };
1789
+ }
1678
1790
  const sttPrimitive = definePrimitive({
1679
1791
  id: PRIMITIVE_STT_ID,
1680
1792
  kind: "stt",
@@ -1688,53 +1800,9 @@ const sttPrimitive = definePrimitive({
1688
1800
  jobs: {
1689
1801
  async run(ctx, input) {
1690
1802
  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
- }
1803
+ const { audioBytes, audioContentType, extractionMetadata } = await loadSpeechAudioForPrimitive(ctx, payload);
1736
1804
  ctx.logger.progress(0.45, "Transcribing speech");
1737
- const provider = payload.provider ?? "gemini";
1805
+ const provider = payload.provider ?? (payload.word_timestamps ? "openai" : "gemini");
1738
1806
  const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
1739
1807
  const transcription = await ctx.providers.transcribeSpeech({
1740
1808
  provider,
@@ -1743,7 +1811,8 @@ const sttPrimitive = definePrimitive({
1743
1811
  contentType: audioContentType,
1744
1812
  prompt: payload.prompt,
1745
1813
  language: payload.language,
1746
- diarize: payload.diarize
1814
+ diarize: payload.diarize,
1815
+ wordTimestamps: payload.word_timestamps
1747
1816
  });
1748
1817
  ctx.logger.progress(0.85, "Storing transcript artifacts");
1749
1818
  const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
@@ -1791,6 +1860,137 @@ const sttPrimitive = definePrimitive({
1791
1860
  }
1792
1861
  }
1793
1862
  });
1863
+ // Speech → animated captions convenience primitive. One job: transcribe the
1864
+ // narration WITH word-level timings (openai/whisper-1 real timestamps, other
1865
+ // providers estimated), page it into CapCut-style cues, and return the cue
1866
+ // list shaped EXACTLY for the editor's `editor_action set_captions` — the
1867
+ // output even carries a ready `set_captions_action` object so an agent can
1868
+ // pipe it through without any cue math. Composition-agnostic: it never
1869
+ // mutates a fork; POST /api/v1/compositions/:forkId/captions is the one-step
1870
+ // variant that applies server-side.
1871
+ const captionsPrimitive = definePrimitive({
1872
+ id: PRIMITIVE_CAPTIONS_ID,
1873
+ kind: "captions",
1874
+ operations: {
1875
+ run: {
1876
+ description: "Transcribe narration (or page a script) into animated word-by-word caption cues ready for the editor's set_captions action.",
1877
+ inputSchema: captionsPayloadSchema,
1878
+ workflow: "run"
1879
+ }
1880
+ },
1881
+ jobs: {
1882
+ async run(ctx, input) {
1883
+ const payload = captionsPayloadSchema.parse(input);
1884
+ const preset = payload.style
1885
+ ? captionStylePresetById(payload.style)
1886
+ : payload.animation
1887
+ ? null
1888
+ : CAPTION_STYLE_PRESETS[0];
1889
+ if (payload.style && !preset) {
1890
+ throw new Error(`Unknown caption style "${payload.style}". Use one of: ${CAPTION_STYLE_PRESETS.map((entry) => entry.id).join(", ")} — or pass animation directly.`);
1891
+ }
1892
+ const animation = payload.animation ?? preset?.animation ?? "highlight-pop";
1893
+ let cues;
1894
+ let wordTimestampSource = "estimated";
1895
+ let transcriptText = null;
1896
+ let transcriptArtifacts = {};
1897
+ if (payload.text?.trim()) {
1898
+ const tokens = payload.text.trim().split(/\s+/).filter(Boolean);
1899
+ const windowDuration = payload.window_duration ?? Math.max(0.4, tokens.length * 0.32);
1900
+ cues = cuesFromText(payload.text, payload.window_start + payload.offset_sec, windowDuration, {
1901
+ maxWordsPerCue: payload.max_words_per_cue
1902
+ });
1903
+ }
1904
+ else {
1905
+ const { audioBytes, audioContentType } = await loadSpeechAudioForPrimitive(ctx, {
1906
+ source_url: payload.source_url,
1907
+ media_type: payload.media_type
1908
+ });
1909
+ ctx.logger.progress(0.45, "Transcribing speech with word timings");
1910
+ // openai first: whisper-1 is the only provider with REAL word-level
1911
+ // timestamps; providerAttempts falls back to the caller's other keys.
1912
+ const provider = payload.provider ?? "openai";
1913
+ const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
1914
+ const transcription = await ctx.providers.transcribeSpeech({
1915
+ provider,
1916
+ model,
1917
+ audio: audioBytes,
1918
+ contentType: audioContentType,
1919
+ prompt: payload.prompt,
1920
+ language: payload.language,
1921
+ diarize: false,
1922
+ wordTimestamps: true
1923
+ });
1924
+ if (!transcription.segments.length) {
1925
+ throw new Error("Transcription returned no speech segments — the source may be silent.");
1926
+ }
1927
+ transcriptText = transcription.text || null;
1928
+ wordTimestampSource = transcription.segments.some((segment) => segment.words?.length) ? "provider" : "estimated";
1929
+ ctx.logger.progress(0.7, "Paging transcript into caption cues");
1930
+ cues = buildCaptionCues(transcription.segments, {
1931
+ maxWordsPerCue: payload.max_words_per_cue,
1932
+ offsetSec: payload.offset_sec
1933
+ });
1934
+ const srt = buildSrtFromSegments(transcription.segments);
1935
+ const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
1936
+ if (storedSrt?.url)
1937
+ transcriptArtifacts.srt_url = storedSrt.url;
1938
+ }
1939
+ if (!cues.length) {
1940
+ throw new Error("No caption cues produced from the source.");
1941
+ }
1942
+ // Style echo in the exact field names editor_action set_captions takes,
1943
+ // so the agent can spread this straight into the action.
1944
+ const styleFields = {
1945
+ ...(preset ? { caption_style: preset.id } : {}),
1946
+ caption_animation: animation,
1947
+ ...(payload.active_color ?? preset?.activeColor ? { caption_active_color: payload.active_color ?? preset?.activeColor } : {}),
1948
+ ...(payload.highlight_color ?? preset?.highlightColor ? { caption_highlight_color: payload.highlight_color ?? preset?.highlightColor } : {}),
1949
+ ...((payload.uppercase ?? preset?.uppercase) !== undefined ? { caption_uppercase: payload.uppercase ?? preset?.uppercase } : {}),
1950
+ ...(payload.color ?? preset?.color ? { color: payload.color ?? preset?.color } : {}),
1951
+ ...(payload.background ?? preset?.background ? { background: payload.background ?? preset?.background } : {}),
1952
+ ...(payload.background_style ?? preset?.textBackgroundStyle ? { background_style: payload.background_style ?? preset?.textBackgroundStyle } : {}),
1953
+ ...(payload.font_family ? { font_family: payload.font_family } : {}),
1954
+ ...(payload.font_size ? { font_size: payload.font_size } : {})
1955
+ };
1956
+ const setCaptionsAction = {
1957
+ action_type: "set_captions",
1958
+ captions: cues,
1959
+ ...styleFields,
1960
+ explanation: "Apply transcribed animated captions."
1961
+ };
1962
+ ctx.logger.progress(0.85, "Storing caption artifacts");
1963
+ const storedCaptions = await ctx.storage.putJson("captions.json", {
1964
+ source_url: payload.source_url ?? null,
1965
+ word_timestamps: wordTimestampSource,
1966
+ style: styleFields,
1967
+ cue_count: cues.length,
1968
+ cues,
1969
+ set_captions_action: setCaptionsAction,
1970
+ ...(transcriptText ? { transcript_text: transcriptText } : {})
1971
+ });
1972
+ const cuesInline = JSON.stringify(cues).length <= 60_000;
1973
+ ctx.logger.progress(1, "Caption cues ready");
1974
+ return {
1975
+ progress: 1,
1976
+ output: {
1977
+ files: compactUrls([storedCaptions.url, transcriptArtifacts.srt_url]),
1978
+ primary_file_url: storedCaptions.url,
1979
+ cue_count: cues.length,
1980
+ word_timestamps: wordTimestampSource,
1981
+ ...styleFields,
1982
+ // The cue list, ready to pass as editor_action set_captions `captions`.
1983
+ captions: cuesInline ? cues : undefined,
1984
+ captions_truncated: !cuesInline,
1985
+ // Fully-formed editor_action arguments — apply verbatim.
1986
+ set_captions_action: cuesInline ? setCaptionsAction : undefined,
1987
+ captions_json_url: storedCaptions.url,
1988
+ transcript: transcriptArtifacts.srt_url ? { srt_url: transcriptArtifacts.srt_url } : undefined
1989
+ }
1990
+ };
1991
+ }
1992
+ }
1993
+ });
1794
1994
  const videoProbePrimitive = definePrimitive({
1795
1995
  id: PRIMITIVE_VIDEO_PROBE_ID,
1796
1996
  kind: "video_probe",
@@ -2369,6 +2569,7 @@ class PrimitiveRegistry {
2369
2569
  [audioNormalizePrimitive.id, audioNormalizePrimitive],
2370
2570
  [ttsPrimitive.id, ttsPrimitive],
2371
2571
  [sttPrimitive.id, sttPrimitive],
2572
+ [captionsPrimitive.id, captionsPrimitive],
2372
2573
  [hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
2373
2574
  [brainstormHooksPrimitive.id, brainstormHooksPrimitive],
2374
2575
  [brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
@@ -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
@@ -7,6 +7,9 @@ export class ProviderKeyUnavailableError extends Error {
7
7
  }
8
8
  export class ProviderAuthError extends Error {
9
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
+ }
10
13
  export class ProviderQuotaExceededError extends Error {
11
14
  }
12
15
  export class ProviderWaitExceededError extends Error {