@mevdragon/vidfarm-devcli 0.12.0 → 0.14.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.
@@ -98,10 +98,16 @@ export function normalizeKenBurns(value) {
98
98
  export const TRANSITION_PRESETS = [
99
99
  "crossfade",
100
100
  "fade-black",
101
+ "fade-white",
102
+ "flash",
103
+ "smoke",
104
+ "blur",
101
105
  "slide-left",
102
106
  "slide-right",
103
107
  "slide-up",
104
108
  "slide-down",
109
+ "whip-left",
110
+ "whip-right",
105
111
  "wipe-left",
106
112
  "wipe-right",
107
113
  "wipe-up",
@@ -110,14 +116,49 @@ export const TRANSITION_PRESETS = [
110
116
  "zoom-out",
111
117
  "circle-open"
112
118
  ];
119
+ // Exit transitions: the clip animates AWAY over its last data-transition-out-
120
+ // duration seconds. Same declarative contract as the entrance presets; the
121
+ // keyframes omit the `from` state so the animation departs from the element's
122
+ // own inline styles, and the inline --vf-tr-out-delay var (clip duration minus
123
+ // out duration) positions the animation window at the clip's tail so the
124
+ // time-based scrub in the producer and preview runtimes needs no extra logic.
125
+ export const TRANSITION_OUT_PRESETS = [
126
+ "fade",
127
+ "fade-black",
128
+ "fade-white",
129
+ "flash",
130
+ "smoke",
131
+ "blur",
132
+ "slide-left",
133
+ "slide-right",
134
+ "slide-up",
135
+ "slide-down",
136
+ "wipe-left",
137
+ "wipe-right",
138
+ "wipe-up",
139
+ "wipe-down",
140
+ "zoom-in",
141
+ "zoom-out",
142
+ "circle-close"
143
+ ];
113
144
  export const TRANSITION_DEFAULT_DURATION = 0.5;
114
145
  export const TRANSITION_MIN_DURATION = 0.1;
115
146
  export const TRANSITION_MAX_DURATION = 2.5;
116
147
  // Two butt-cut clips can drift by a few ms through timeline edits; treat ends
117
148
  // within this window as adjacent when granting the outgoing clip's hold.
118
149
  export const TRANSITION_ADJACENCY_EPSILON = 0.05;
119
- export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V1__";
120
- export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
150
+ // V2: entrance AND exit animations compose on one element via cascading custom
151
+ // properties (--vf-tr-in-name / --vf-tr-out-name) feeding a two-slot animation
152
+ // list — per-preset attribute selectors can't merge two animation-names, vars
153
+ // can. The [data-kenburns] combo rules below fix a V1 latent clash where Ken
154
+ // Burns and a transition on the same <img> fought over animation-name (equal
155
+ // specificity, last stylesheet won); the (0,2,0) combo selectors prepend the
156
+ // vf-kb animation to the list instead.
157
+ export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V2__";
158
+ // The exact V1 block previous builds baked into stored compositions. Injectors
159
+ // string-replace this with the current TRANSITION_CSS so old drafts/publishes
160
+ // upgrade in place instead of accumulating duplicate style blocks.
161
+ export const LEGACY_TRANSITION_CSS_V1 = `/*__VF_TRANSITIONS_V1__*/
121
162
  @keyframes vf-tr-crossfade { from { opacity: 0; } }
122
163
  @keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
123
164
  @keyframes vf-tr-slide-left { from { translate: 100% 0; } }
@@ -151,12 +192,119 @@ export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
151
192
  [data-transition="zoom-out"] { animation-name: vf-tr-zoom-out; }
152
193
  [data-transition="circle-open"] { animation-name: vf-tr-circle-open; }
153
194
  `;
195
+ export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
196
+ @keyframes vf-tr-crossfade { from { opacity: 0; } }
197
+ @keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
198
+ @keyframes vf-tr-fade-white { 0% { opacity: 0; filter: saturate(0.55) brightness(3); } 45% { opacity: 1; filter: saturate(0.7) brightness(2.1); } }
199
+ @keyframes vf-tr-flash { 0% { filter: saturate(0.3) brightness(5); } 55% { filter: saturate(0.9) brightness(1.6); } }
200
+ @keyframes vf-tr-smoke { 0% { opacity: 0; filter: blur(26px) saturate(0.65) brightness(1.55); } 55% { opacity: 1; } }
201
+ @keyframes vf-tr-blur { from { opacity: 0; filter: blur(16px); } }
202
+ @keyframes vf-tr-slide-left { from { translate: 100% 0; } }
203
+ @keyframes vf-tr-slide-right { from { translate: -100% 0; } }
204
+ @keyframes vf-tr-slide-up { from { translate: 0 100%; } }
205
+ @keyframes vf-tr-slide-down { from { translate: 0 -100%; } }
206
+ @keyframes vf-tr-whip-left { 0% { translate: 110% 0; filter: blur(14px); } 60% { filter: blur(5px); } }
207
+ @keyframes vf-tr-whip-right { 0% { translate: -110% 0; filter: blur(14px); } 60% { filter: blur(5px); } }
208
+ @keyframes vf-tr-wipe-left { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0 0 0 0); } }
209
+ @keyframes vf-tr-wipe-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
210
+ @keyframes vf-tr-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
211
+ @keyframes vf-tr-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
212
+ @keyframes vf-tr-zoom-in { from { opacity: 0; scale: 1.18; } }
213
+ @keyframes vf-tr-zoom-out { from { opacity: 0; scale: 0.82; } }
214
+ @keyframes vf-tr-circle-open { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } }
215
+ @keyframes vf-tr-out-fade { to { opacity: 0; } }
216
+ @keyframes vf-tr-out-fade-black { to { filter: brightness(0); } }
217
+ @keyframes vf-tr-out-fade-white { to { filter: saturate(0.35) brightness(6); } }
218
+ @keyframes vf-tr-out-flash { 65% { filter: brightness(1); } 100% { filter: saturate(0.3) brightness(5); } }
219
+ @keyframes vf-tr-out-smoke { to { opacity: 0; filter: blur(24px) saturate(0.7) brightness(1.5); } }
220
+ @keyframes vf-tr-out-blur { to { opacity: 0; filter: blur(16px); } }
221
+ @keyframes vf-tr-out-slide-left { to { translate: -100% 0; } }
222
+ @keyframes vf-tr-out-slide-right { to { translate: 100% 0; } }
223
+ @keyframes vf-tr-out-slide-up { to { translate: 0 -100%; } }
224
+ @keyframes vf-tr-out-slide-down { to { translate: 0 100%; } }
225
+ @keyframes vf-tr-out-wipe-left { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 100% 0 0); } }
226
+ @keyframes vf-tr-out-wipe-right { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 0 100%); } }
227
+ @keyframes vf-tr-out-wipe-up { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 100% 0); } }
228
+ @keyframes vf-tr-out-wipe-down { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(100% 0 0 0); } }
229
+ @keyframes vf-tr-out-zoom-in { to { opacity: 0; scale: 1.18; } }
230
+ @keyframes vf-tr-out-zoom-out { to { opacity: 0; scale: 0.82; } }
231
+ @keyframes vf-tr-out-circle-close { from { clip-path: circle(75% at 50% 50%); } to { clip-path: circle(0% at 50% 50%); } }
232
+ [data-transition], [data-transition-out] {
233
+ animation-name: var(--vf-tr-in-name, none), var(--vf-tr-out-name, none);
234
+ animation-duration: var(--vf-tr-dur, 0.5s), var(--vf-tr-out-dur, 0.5s);
235
+ animation-delay: 0s, var(--vf-tr-out-delay, 0s);
236
+ animation-timing-function: var(--vf-tr-ease, ease-in-out), var(--vf-tr-out-ease, ease-in-out);
237
+ animation-fill-mode: both, both;
238
+ animation-play-state: paused, paused;
239
+ }
240
+ [data-kenburns][data-transition], [data-kenburns][data-transition-out] {
241
+ animation-name: var(--vf-kb-name, none), var(--vf-tr-in-name, none), var(--vf-tr-out-name, none);
242
+ animation-duration: var(--vf-kb-dur, 5s), var(--vf-tr-dur, 0.5s), var(--vf-tr-out-dur, 0.5s);
243
+ animation-delay: 0s, 0s, var(--vf-tr-out-delay, 0s);
244
+ animation-timing-function: var(--vf-kb-ease, linear), var(--vf-tr-ease, ease-in-out), var(--vf-tr-out-ease, ease-in-out);
245
+ animation-fill-mode: both, both, both;
246
+ animation-play-state: paused, paused, paused;
247
+ }
248
+ [data-kenburns="zoom-in"] { --vf-kb-name: vf-kb-zoom-in; }
249
+ [data-kenburns="zoom-out"] { --vf-kb-name: vf-kb-zoom-out; }
250
+ [data-kenburns="pan-left"] { --vf-kb-name: vf-kb-pan-left; }
251
+ [data-kenburns="pan-right"] { --vf-kb-name: vf-kb-pan-right; }
252
+ [data-kenburns="pan-up"] { --vf-kb-name: vf-kb-pan-up; }
253
+ [data-kenburns="pan-down"] { --vf-kb-name: vf-kb-pan-down; }
254
+ [data-kenburns="zoom-in-left"] { --vf-kb-name: vf-kb-zoom-in-left; }
255
+ [data-kenburns="zoom-in-right"] { --vf-kb-name: vf-kb-zoom-in-right; }
256
+ [data-transition="crossfade"] { --vf-tr-in-name: vf-tr-crossfade; }
257
+ [data-transition="fade-black"] { --vf-tr-in-name: vf-tr-fade-black; }
258
+ [data-transition="fade-white"] { --vf-tr-in-name: vf-tr-fade-white; }
259
+ [data-transition="flash"] { --vf-tr-in-name: vf-tr-flash; --vf-tr-ease: cubic-bezier(0.3, 0, 0.55, 1); }
260
+ [data-transition="smoke"] { --vf-tr-in-name: vf-tr-smoke; --vf-tr-ease: cubic-bezier(0.25, 0.4, 0.3, 1); }
261
+ [data-transition="blur"] { --vf-tr-in-name: vf-tr-blur; }
262
+ [data-transition="slide-left"] { --vf-tr-in-name: vf-tr-slide-left; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
263
+ [data-transition="slide-right"] { --vf-tr-in-name: vf-tr-slide-right; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
264
+ [data-transition="slide-up"] { --vf-tr-in-name: vf-tr-slide-up; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
265
+ [data-transition="slide-down"] { --vf-tr-in-name: vf-tr-slide-down; --vf-tr-ease: cubic-bezier(0.22, 1, 0.36, 1); }
266
+ [data-transition="whip-left"] { --vf-tr-in-name: vf-tr-whip-left; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
267
+ [data-transition="whip-right"] { --vf-tr-in-name: vf-tr-whip-right; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
268
+ [data-transition="wipe-left"] { --vf-tr-in-name: vf-tr-wipe-left; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
269
+ [data-transition="wipe-right"] { --vf-tr-in-name: vf-tr-wipe-right; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
270
+ [data-transition="wipe-up"] { --vf-tr-in-name: vf-tr-wipe-up; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
271
+ [data-transition="wipe-down"] { --vf-tr-in-name: vf-tr-wipe-down; --vf-tr-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
272
+ [data-transition="zoom-in"] { --vf-tr-in-name: vf-tr-zoom-in; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
273
+ [data-transition="zoom-out"] { --vf-tr-in-name: vf-tr-zoom-out; --vf-tr-ease: cubic-bezier(0.16, 1, 0.3, 1); }
274
+ [data-transition="circle-open"] { --vf-tr-in-name: vf-tr-circle-open; --vf-tr-ease: cubic-bezier(0.3, 0.6, 0.3, 1); }
275
+ [data-transition-out="fade"] { --vf-tr-out-name: vf-tr-out-fade; }
276
+ [data-transition-out="fade-black"] { --vf-tr-out-name: vf-tr-out-fade-black; }
277
+ [data-transition-out="fade-white"] { --vf-tr-out-name: vf-tr-out-fade-white; }
278
+ [data-transition-out="flash"] { --vf-tr-out-name: vf-tr-out-flash; --vf-tr-out-ease: cubic-bezier(0.45, 0, 0.7, 0.4); }
279
+ [data-transition-out="smoke"] { --vf-tr-out-name: vf-tr-out-smoke; --vf-tr-out-ease: cubic-bezier(0.7, 0, 0.75, 0.6); }
280
+ [data-transition-out="blur"] { --vf-tr-out-name: vf-tr-out-blur; }
281
+ [data-transition-out="slide-left"] { --vf-tr-out-name: vf-tr-out-slide-left; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
282
+ [data-transition-out="slide-right"] { --vf-tr-out-name: vf-tr-out-slide-right; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
283
+ [data-transition-out="slide-up"] { --vf-tr-out-name: vf-tr-out-slide-up; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
284
+ [data-transition-out="slide-down"] { --vf-tr-out-name: vf-tr-out-slide-down; --vf-tr-out-ease: cubic-bezier(0.55, 0.06, 0.68, 0.19); }
285
+ [data-transition-out="wipe-left"] { --vf-tr-out-name: vf-tr-out-wipe-left; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
286
+ [data-transition-out="wipe-right"] { --vf-tr-out-name: vf-tr-out-wipe-right; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
287
+ [data-transition-out="wipe-up"] { --vf-tr-out-name: vf-tr-out-wipe-up; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
288
+ [data-transition-out="wipe-down"] { --vf-tr-out-name: vf-tr-out-wipe-down; --vf-tr-out-ease: cubic-bezier(0.45, 0.05, 0.35, 0.95); }
289
+ [data-transition-out="zoom-in"] { --vf-tr-out-name: vf-tr-out-zoom-in; --vf-tr-out-ease: cubic-bezier(0.5, 0, 0.75, 0.4); }
290
+ [data-transition-out="zoom-out"] { --vf-tr-out-name: vf-tr-out-zoom-out; --vf-tr-out-ease: cubic-bezier(0.5, 0, 0.75, 0.4); }
291
+ [data-transition-out="circle-close"] { --vf-tr-out-name: vf-tr-out-circle-close; --vf-tr-out-ease: cubic-bezier(0.6, 0, 0.7, 0.4); }
292
+ `;
154
293
  export function clampTransitionDuration(value) {
155
294
  const next = Number(value);
156
295
  if (!Number.isFinite(next) || next <= 0)
157
296
  return TRANSITION_DEFAULT_DURATION;
158
297
  return Math.max(TRANSITION_MIN_DURATION, Math.min(TRANSITION_MAX_DURATION, next));
159
298
  }
299
+ // Seconds into the clip at which its exit animation must start so it finishes
300
+ // exactly at the clip's end. Written as the inline --vf-tr-out-delay var.
301
+ export function transitionOutDelaySeconds(layerDurationSeconds, outDurationSeconds) {
302
+ const layerDuration = Number(layerDurationSeconds);
303
+ const outDuration = Number(outDurationSeconds);
304
+ if (!Number.isFinite(layerDuration) || !Number.isFinite(outDuration))
305
+ return 0;
306
+ return Math.max(0, Number((layerDuration - outDuration).toFixed(3)));
307
+ }
160
308
  export function normalizeTransition(value) {
161
309
  if (!value)
162
310
  return null;
@@ -168,6 +316,28 @@ export function normalizeTransition(value) {
168
316
  duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
169
317
  };
170
318
  }
319
+ export function normalizeTransitionOut(value) {
320
+ if (!value)
321
+ return null;
322
+ const raw = typeof value === "string" ? { preset: value } : value;
323
+ if (!TRANSITION_OUT_PRESETS.includes(raw.preset))
324
+ return null;
325
+ return {
326
+ preset: raw.preset,
327
+ duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
328
+ };
329
+ }
330
+ // Upgrades stored composition HTML that still carries the V1 transition
331
+ // stylesheet: exact-replaces the legacy block with the current one. If the V1
332
+ // marker is present but the block has drifted, callers' normal marker-guarded
333
+ // injection appends the V2 block AFTER it, which also wins (equal-specificity
334
+ // rules, later source order) — this replace just avoids duplicate blocks in
335
+ // the common case.
336
+ export function upgradeLegacyTransitionCss(html) {
337
+ if (!html || html.includes(TRANSITION_STYLE_MARKER) || !html.includes("__VF_TRANSITIONS_V1__"))
338
+ return html;
339
+ return html.split(LEGACY_TRANSITION_CSS_V1).join(TRANSITION_CSS);
340
+ }
171
341
  // ---------------------------------------------------------------------------
172
342
  // Animated captions. Same declarative contract as Ken Burns: the caption layer
173
343
  // <div> (the element that owns data-start/data-duration) carries
@@ -476,6 +646,10 @@ function timedAttrs(layer) {
476
646
  if (transition) {
477
647
  attrs.push(`data-transition="${escapeAttr(transition.preset)}"`, `data-transition-duration="${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
478
648
  }
649
+ const transitionOut = normalizeTransitionOut(layer.transitionOut);
650
+ if (transitionOut) {
651
+ attrs.push(`data-transition-out="${escapeAttr(transitionOut.preset)}"`, `data-transition-out-duration="${num(clampTransitionDuration(transitionOut.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
652
+ }
479
653
  }
480
654
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
481
655
  attrs.push(`data-text-background-style="${escapeAttr(layer.textBackgroundStyle || "plain")}"`, `data-text-background-color="${escapeAttr(layer.background || "#000000")}"`, `data-font-family="${escapeAttr(layer.fontFamily || "TikTok Sans")}"`);
@@ -504,6 +678,13 @@ function layerStyle(layer) {
504
678
  if (transition) {
505
679
  styles.push(`--vf-tr-dur:${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}s`);
506
680
  }
681
+ // Exit transitions additionally need the delay var so the animation window
682
+ // sits at the clip's tail (duration − out duration).
683
+ const transitionOut = normalizeTransitionOut(layer.transitionOut);
684
+ if (transitionOut) {
685
+ const outDur = clampTransitionDuration(transitionOut.duration ?? TRANSITION_DEFAULT_DURATION);
686
+ styles.push(`--vf-tr-out-dur:${num(outDur)}s`, `--vf-tr-out-delay:${num(transitionOutDelaySeconds(positiveNumber(layer.duration, 0), outDur))}s`);
687
+ }
507
688
  }
508
689
  if (["caption", "text", "shape", "html"].includes(layer.kind)) {
509
690
  const fontFamily = layer.fontFamily || "TikTok Sans";
@@ -8,6 +8,8 @@ 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
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"]);
@@ -406,9 +408,58 @@ const sttPayloadSchema = z.preprocess((raw) => {
406
408
  source_url: mediaSourceUrlSchema,
407
409
  media_type: z.enum(["auto", "video", "audio"]).default("auto"),
408
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),
409
415
  language: z.string().min(2).max(16).optional(),
410
416
  prompt: z.string().min(1).max(2000).optional()
411
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
+ }));
412
463
  const probeVideoPayloadSchema = z.object({
413
464
  source_video_url: mediaSourceUrlSchema
414
465
  });
@@ -561,6 +612,7 @@ const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_
561
612
  const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
562
613
  const PRIMITIVE_TTS_ID = "primitive:tts";
563
614
  const PRIMITIVE_STT_ID = "primitive:stt";
615
+ const PRIMITIVE_CAPTIONS_ID = "primitive:captions";
564
616
  // Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
565
617
  // that is roughly 40 minutes of speech.
566
618
  const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
@@ -1686,6 +1738,55 @@ const ttsPrimitive = definePrimitive({
1686
1738
  }
1687
1739
  }
1688
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
+ }
1689
1790
  const sttPrimitive = definePrimitive({
1690
1791
  id: PRIMITIVE_STT_ID,
1691
1792
  kind: "stt",
@@ -1699,53 +1800,9 @@ const sttPrimitive = definePrimitive({
1699
1800
  jobs: {
1700
1801
  async run(ctx, input) {
1701
1802
  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
- }
1803
+ const { audioBytes, audioContentType, extractionMetadata } = await loadSpeechAudioForPrimitive(ctx, payload);
1747
1804
  ctx.logger.progress(0.45, "Transcribing speech");
1748
- const provider = payload.provider ?? "gemini";
1805
+ const provider = payload.provider ?? (payload.word_timestamps ? "openai" : "gemini");
1749
1806
  const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
1750
1807
  const transcription = await ctx.providers.transcribeSpeech({
1751
1808
  provider,
@@ -1754,7 +1811,8 @@ const sttPrimitive = definePrimitive({
1754
1811
  contentType: audioContentType,
1755
1812
  prompt: payload.prompt,
1756
1813
  language: payload.language,
1757
- diarize: payload.diarize
1814
+ diarize: payload.diarize,
1815
+ wordTimestamps: payload.word_timestamps
1758
1816
  });
1759
1817
  ctx.logger.progress(0.85, "Storing transcript artifacts");
1760
1818
  const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
@@ -1802,6 +1860,137 @@ const sttPrimitive = definePrimitive({
1802
1860
  }
1803
1861
  }
1804
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
+ });
1805
1994
  const videoProbePrimitive = definePrimitive({
1806
1995
  id: PRIMITIVE_VIDEO_PROBE_ID,
1807
1996
  kind: "video_probe",
@@ -2380,6 +2569,7 @@ class PrimitiveRegistry {
2380
2569
  [audioNormalizePrimitive.id, audioNormalizePrimitive],
2381
2570
  [ttsPrimitive.id, ttsPrimitive],
2382
2571
  [sttPrimitive.id, sttPrimitive],
2572
+ [captionsPrimitive.id, captionsPrimitive],
2383
2573
  [hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
2384
2574
  [brainstormHooksPrimitive.id, brainstormHooksPrimitive],
2385
2575
  [brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],