@mevdragon/vidfarm-devcli 0.12.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.
package/dist/src/cli.js CHANGED
@@ -81,6 +81,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
81
81
  local MP4/MOV/WebM file — as a private (files: presign + PUT via
82
82
  template. --title (uploads; defaults to the /discover/templates/upload)
83
83
  template id), --tagline, --notes optional.
84
+ --raw (uploads) marks it as your own raw
85
+ project footage — the editor won't auto-
86
+ prompt Decompose on it.
84
87
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
85
88
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
86
89
 
@@ -621,6 +624,11 @@ async function runServeCommand(argv) {
621
624
  // local-render by definition — cloud rendering is the EXPLICIT
622
625
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
623
626
  process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
627
+ // Same trap for clip hunts: a dev-shell .env carrying the staging clip-scan
628
+ // ARN would route this box's /clips/scan to the cloud Step Functions. serve
629
+ // hunts are LOCAL-FIRST (local agent CLI / BYOK keys); the upstream cloud is
630
+ // the explicit backup inside the scan route itself.
631
+ process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
624
632
  // Same trap as the state machine ARN: a dev-shell .env can carry the staging
625
633
  // media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
626
634
  // Lambda that cannot fetch this box's localhost storage URLs ("fetch
@@ -1265,7 +1273,7 @@ async function runVideosCommand(argv) {
1265
1273
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1266
1274
  }
1267
1275
  async function runInspirationAddCommand(argv) {
1268
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
1276
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" }, raw: { type: "boolean", default: false } } });
1269
1277
  const source = parsed.positionals[0];
1270
1278
  if (!source)
1271
1279
  throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
@@ -1285,7 +1293,10 @@ async function runInspirationAddCommand(argv) {
1285
1293
  fileName: path.basename(localPath),
1286
1294
  title: parsed.values.title,
1287
1295
  tagline: parsed.values.tagline,
1288
- notes: parsed.values.notes
1296
+ notes: parsed.values.notes,
1297
+ // --raw: this is the user's own footage starting a new project, not an
1298
+ // inspiration to study — the editor skips the auto Decompose prompt.
1299
+ origin: parsed.values.raw ? "raw" : undefined
1289
1300
  });
1290
1301
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1291
1302
  return;
@@ -1380,7 +1391,7 @@ async function ingestUploadedVideo(ctx, input) {
1380
1391
  host: ctx.host,
1381
1392
  path: "/discover/templates",
1382
1393
  auth: ctx.auth,
1383
- body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
1394
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes, origin: input.origin }
1384
1395
  });
1385
1396
  assertApiOk(result, "ingest");
1386
1397
  return result;
@@ -1555,7 +1566,10 @@ async function runCreateCommand(argv) {
1555
1566
  throw new Error(`create: could not fetch the generated video (${dl.status}).`);
1556
1567
  const buffer = Buffer.from(await dl.arrayBuffer());
1557
1568
  const title = parsed.values.title ?? prompt.slice(0, 80);
1558
- const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title });
1569
+ // origin:"raw" a prompt-generated video is the user's own new project, not
1570
+ // an imported inspiration; the editor must not auto-prompt Decompose (matters
1571
+ // especially with --no-decompose).
1572
+ const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title, origin: "raw" });
1559
1573
  const inspirationId = add.json?.inspiration_id;
1560
1574
  if (!inspirationId)
1561
1575
  throw new Error("create: ingest did not return an inspiration id.");
@@ -229,16 +229,65 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
229
229
  // Burns there is nothing to normalize — the scrub is simply the absolute
230
230
  // offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
231
231
  // render producer's CSS adapter exactly.
232
+ //
233
+ // Stability: Chrome PERMANENTLY drops a paused fill:none CSS animation the
234
+ // moment its currentTime is pushed past the effect end, and never re-creates
235
+ // it while the computed animation-name is unchanged — so one forward scrub
236
+ // across a word's window would leave that word stuck in its resting state
237
+ // (invisible for word-pop/stack-up) on every later frame. Past the end, a
238
+ // parked fill:none animation looks identical to the resting styles, so park
239
+ // at 0 instead of overshooting. fill:forwards presets (karaoke-fill,
240
+ // cumulative-reveal) must overshoot to hold their final keyframe — finished
241
+ // + forwards animations keep applying their effect and survive. If a word's
242
+ // animation is missing anyway (dropped before this guard, or an innerHTML
243
+ // morph replaced the spans mid-scrub), restart it declaratively by cycling
244
+ // animation-name through none.
245
+ function captionWordAnimationFor(word) {
246
+ let anims = [];
247
+ try { anims = word.getAnimations(); } catch { return null; }
248
+ for (const anim of anims) {
249
+ if (typeof anim.animationName === "string" && anim.animationName.indexOf("vf-cap-") === 0) return anim;
250
+ }
251
+ return null;
252
+ }
253
+
232
254
  function syncCaptionClip(clip, timing) {
233
255
  if (typeof clip.getAnimations !== "function") return;
234
256
  const offsetMs = Math.max(0, (time - timing.start)) * 1000;
235
- let anims = [];
236
- try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
237
- for (const anim of anims) {
238
- if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-cap-") !== 0) continue;
257
+ let words = [];
258
+ try { words = Array.from(clip.querySelectorAll('[data-cap-word="true"]')); } catch { return; }
259
+ for (const word of words) {
260
+ if (typeof word.getAnimations !== "function") continue;
261
+ let anim = captionWordAnimationFor(word);
262
+ if (!anim) {
263
+ const inlineEndMs =
264
+ ((parseFloat(word.style.animationDelay) || 0) + (parseFloat(word.style.animationDuration) || 0)) * 1000;
265
+ let needed = offsetMs <= inlineEndMs;
266
+ if (!needed) {
267
+ try { needed = getComputedStyle(word).animationFillMode.indexOf("forward") !== -1 || getComputedStyle(word).animationFillMode.indexOf("both") !== -1; } catch {}
268
+ }
269
+ if (!needed) continue;
270
+ try {
271
+ word.style.animationName = "none";
272
+ void word.offsetWidth;
273
+ word.style.removeProperty("animation-name");
274
+ } catch { continue; }
275
+ anim = captionWordAnimationFor(word);
276
+ if (!anim) continue;
277
+ }
278
+ let target = offsetMs;
279
+ try {
280
+ const computed = anim.effect && typeof anim.effect.getComputedTiming === "function"
281
+ ? anim.effect.getComputedTiming()
282
+ : null;
283
+ if (computed && typeof computed.endTime === "number" && offsetMs >= computed.endTime
284
+ && computed.fill !== "forwards" && computed.fill !== "both") {
285
+ target = 0;
286
+ }
287
+ } catch {}
239
288
  try {
240
289
  anim.pause();
241
- anim.currentTime = offsetMs;
290
+ anim.currentTime = target;
242
291
  } catch {}
243
292
  }
244
293
  }
@@ -369,7 +418,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
369
418
  return playbackRate;
370
419
  },
371
420
  setMuted,
372
- refreshClips
421
+ refreshClips,
422
+ // Re-scrub caption word animations at the current playhead without a full
423
+ // apply(). The editor calls this after live-patching a caption layer's
424
+ // DOM (style/text edits rebuild the word spans, whose fresh CSS-paused
425
+ // animations sit at currentTime 0 — resting/invisible — until scrubbed).
426
+ syncCaptions() {
427
+ for (const clip of Array.from(document.querySelectorAll("[data-caption-animation]"))) {
428
+ if (clip instanceof HTMLElement) syncCaptionClip(clip, readTiming(clip));
429
+ }
430
+ }
373
431
  };
374
432
 
375
433
  refreshClips();
@@ -166,6 +166,9 @@ async function runScan(argv) {
166
166
  const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
167
167
  // ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
168
168
  if (values.cloud) {
169
+ const apiProvider = values.provider && !["agent", "local", "local-agent"].includes(values.provider)
170
+ ? normalizeProvider(values.provider)
171
+ : undefined; // unset → the cloud auto-picks from the account's saved keys
169
172
  return runScanCloud({
170
173
  videoArg: positionals[0],
171
174
  sourceUrl: values.url?.trim() || undefined,
@@ -173,6 +176,7 @@ async function runScan(argv) {
173
176
  apiKey: values["api-key"],
174
177
  tracer: values.tracer,
175
178
  prompt: guidancePromptRaw,
179
+ provider: apiProvider,
176
180
  windows: rawWindows,
177
181
  durationBand,
178
182
  aspect,
@@ -431,6 +435,7 @@ async function runScanCloud(input) {
431
435
  body: JSON.stringify({
432
436
  ...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
433
437
  prompt: input.prompt ?? "",
438
+ ...(input.provider ? { provider: input.provider } : {}),
434
439
  ...(input.tracer ? { tracer: input.tracer } : {}),
435
440
  hunt_spec: {
436
441
  ...(input.windows.length ? { windows: input.windows } : {}),
@@ -80,7 +80,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
80
80
  "Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
81
81
  "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
82
82
  "SCENE TRANSITIONS: every visual clip supports a first-class transition at its start — the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window, in both the preview and the final render. Apply it with editor_action set_layer_media and the transition field ON THE INCOMING CLIP — the clip AFTER the cut, never the one before it (presets: crossfade, fade-black, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-open; 'none' removes it), optionally transition_duration (0.1-2.5s, default 0.5; ~0.3 snappy, ~1.0 slow cinematic). When the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, apply a transition to EVERY scene clip except the first in one pass (one set_layer_media call per clip). Match the preset to the template's energy: crossfade/fade-black read calm and cinematic, slide/wipe read energetic (vary directions across cuts), zoom-in/zoom-out/circle-open read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition directly on add_layer or generate_layer so a newly placed scene arrives with its entrance. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition.",
83
- "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar: FIRST call video_context to get the transcript's timestamped segments, then call editor_action action_type=set_captions with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions.",
83
+ "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
84
84
  "Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
85
85
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
86
86
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -378,7 +378,7 @@ function TemplateCard(input) {
378
378
  ? _jsx(LazyVideo, { src: template.previewUrl, controls: false })
379
379
  : _jsx("img", { className: "preview", src: template.previewUrl, alt: `${template.title} preview`, loading: "lazy", decoding: "async" })
380
380
  : _jsx("div", { className: "preview preview-empty", children: template.status === "processing" ? "Downloading…" : "No preview" });
381
- return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
381
+ return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.origin === "raw" ? (_jsx("div", { className: "pill-tag origin-pill", title: "Your own project \u2014 created from a prompt or your raw clips, not an imported inspiration", children: "Your project" })) : null, template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
382
382
  }
383
383
  function PreviewModal(input) {
384
384
  const { onClose, template } = input;
@@ -3624,10 +3624,12 @@ export function TemplateEditorChat({ boot }) {
3624
3624
  }
3625
3625
  if (!storageKey)
3626
3626
  throw new Error("upload did not return a storage key.");
3627
+ // origin:"raw" — a prompt-generated video is the user's own new project,
3628
+ // not an imported inspiration; the editor must not auto-prompt Decompose.
3627
3629
  const addRes = await fetch(new URL("/discover/templates", origin), {
3628
3630
  method: "POST",
3629
3631
  headers: jsonHeaders,
3630
- body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title })
3632
+ body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title, origin: "raw" })
3631
3633
  });
3632
3634
  const add = await readJson(addRes);
3633
3635
  if (!addRes.ok)
@@ -366,6 +366,13 @@ export function renderHomepage(input) {
366
366
  font-weight: 800;
367
367
  }
368
368
 
369
+ .origin-pill {
370
+ border-color: rgba(46, 160, 100, 0.35);
371
+ background: rgba(46, 160, 100, 0.1);
372
+ color: #1f7a4d;
373
+ font-weight: 800;
374
+ }
375
+
369
376
  .processing-pill {
370
377
  border-color: rgba(88, 132, 196, 0.4);
371
378
  background: rgba(88, 132, 196, 0.12);
@@ -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],
@@ -1555,6 +1555,7 @@ export class ProviderService {
1555
1555
  prompt: input.prompt,
1556
1556
  language: input.language,
1557
1557
  diarize: input.diarize,
1558
+ wordTimestamps: input.wordTimestamps,
1558
1559
  apiKey: lease.secret
1559
1560
  })));
1560
1561
  await this.serverlessProviderKeys.recordProviderKeyUsage({