@mevdragon/vidfarm-devcli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/app.js CHANGED
@@ -21,7 +21,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
21
21
  import { renderHelpPage } from "./help-page.js";
22
22
  import { renderHomepage } from "./homepage.js";
23
23
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
24
- import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER } from "./hyperframes/composition.js";
24
+ import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER } from "./hyperframes/composition.js";
25
25
  import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
26
26
  import { applyMarkupUsd } from "./services/billing-pricing.js";
27
27
  import { primitiveRegistry } from "./primitive-registry.js";
@@ -3089,7 +3089,7 @@ function buildPrimitiveEditorDocsRoutes() {
3089
3089
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/probe`, summary: "Primitive audio probe job. Body must be { tracer, payload: { source_audio_url }, webhook_url? }. Returns durable JSON metadata." },
3090
3090
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/concat`, summary: "Primitive audio concat job. Body must be { tracer, payload: { source_audio_urls, output_format?, audio_bitrate_kbps? }, webhook_url? }." },
3091
3091
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/normalize`, summary: "Primitive audio normalization job. Body must be { tracer, payload: { source_audio_url, output_format?, audio_bitrate_kbps?, sample_rate_hz? }, webhook_url? }." },
3092
- { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/speech`, summary: "Primitive text-to-speech (TTS) job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? }, webhook_url? }. instructions is a free-form voice-style prompt (tone, pacing, accent, emotion, persona — e.g. \"calm, warm bedtime narrator with a slight British accent\"). Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
3092
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/speech`, summary: "Primitive text-to-speech (TTS) job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? }, webhook_url? }. instructions is a free-form voice-style prompt (tone, pacing, accent, emotion, persona — e.g. \"calm, warm bedtime narrator with a slight British accent\"). voice is a provider preset (OpenAI: alloy/ash/coral/…; Gemini: Kore/Puck/Charon/…); provider is inferred from the voice when omitted, and a voice paired with the wrong provider is rejected with a 400. Returns a durable platform audio URL (mp3 or wav). Alias: POST /api/v1/primitives/tts." },
3093
3093
  { method: "POST", path: `${PRIMITIVES_PREFIX}/audio/transcribe`, summary: "Primitive speech-to-text (STT) job using saved provider keys. Accepts VIDEO or AUDIO sources; video audio is demuxed automatically. Body must be { tracer, payload: { source_url (aliases: source_video_url, source_audio_url, url), diarize?, language?, prompt?, provider?, model? }, webhook_url? }. Returns TWO formats in one job: a simple subtitle transcript (plain text + timed SRT cues, no speakers) and an advanced multi-speaker version (speaker-attributed timed segments for multi-narration), plus transcript.json/.srt/.txt artifacts. diarize defaults to true; speaker attribution needs a Gemini key (openai/openrouter degrade to a plain single-speaker transcript). Sources over ~40 minutes of speech must be trimmed first. Alias: POST /api/v1/primitives/stt." },
3094
3094
  { method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/coldstart`, summary: "Primitive cold-start strategy questionnaire. Body should usually be { tracer, payload: { user_message }, webhook_url? }. user_message can be rough, like \"I don't know where to start\" or a messy description. count is optional only when the user explicitly wants more or fewer questions. offer_description remains accepted as a backward-compatible alias." },
3095
3095
  { method: "POST", path: `${PRIMITIVES_PREFIX}/brainstorm/awareness_stages`, summary: "Primitive awareness-stage recommendation. Body must be { tracer, payload: { offer_description }, webhook_url? }. Use this when the customer is unsure what type of ads to produce." },
@@ -4187,8 +4187,9 @@ function createEditorActionTool() {
4187
4187
  "ungroup_layers",
4188
4188
  "replace_composition_html",
4189
4189
  "export_composition",
4190
- "generate_layer"
4191
- ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url."),
4190
+ "generate_layer",
4191
+ "set_captions"
4192
+ ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer."),
4192
4193
  layer_key: z.string().optional().describe("Existing layer key from editor_context (required for remove/set/duplicate/split). MAY be either the element_id (data-hf-id) or the slug (data-hf-slug). For add_layer, optionally provide a stable id (starts with a letter, [A-Za-z0-9_-], <=64 chars) to reuse in later tool calls this turn."),
4193
4194
  slug: z.string().optional().describe("snake_case slug for the layer. On add_layer, seeds data-hf-slug. On set_layer_identity, sets it. Slugs give viral DNA a stable handle for the element."),
4194
4195
  note: z.string().optional().describe("Human-facing note explaining the layer's role, referenced by viral DNA. On add_layer seeds data-hf-note. On set_layer_identity, sets it. Empty string removes."),
@@ -4221,6 +4222,23 @@ function createEditorActionTool() {
4221
4222
  object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
4222
4223
  ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
4223
4224
  ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
4225
+ transition: z.enum(["crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. When the user asks for 'transitions between scenes' / 'smooth cuts', apply one to EVERY scene clip except the first, varying or matching presets to the template's energy (crossfade/fade-black = calm, slide/wipe = energetic, zoom/circle-open = punchy)."),
4226
+ transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition."),
4227
+ caption_style: z.enum(["spotlight", "karaoke", "word-pop", "stack-up", "neon", "bounce"]).optional().describe("Animated caption preset look: spotlight (active word gets a highlight pill, bold outline text), karaoke (words fill with color as spoken), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and stay), neon (active word glow pulse), bounce (active word bounces). Applies animation + colors + background treatment in one go; individual caption_*/color/background/background_style fields override specifics."),
4228
+ caption_animation: z.enum(["highlight-pop", "karaoke-fill", "word-pop", "cumulative-reveal", "glow-pulse", "bounce-wave", "none"]).optional().describe("Word-by-word caption animation, if you want to set it directly instead of via caption_style. Works on set_captions, add_layer (kind=text), and set_layer_style. 'none' removes the animation and flattens the layer back to static text."),
4229
+ caption_active_color: z.string().optional().describe("CSS color the active word takes while it is spoken (karaoke fill color, highlight text color, glow color)."),
4230
+ caption_highlight_color: z.string().optional().describe("CSS color of the rounded pill behind the active word (highlight-pop animation only)."),
4231
+ caption_uppercase: z.boolean().optional().describe("Render caption words in uppercase."),
4232
+ captions: z.array(z.object({
4233
+ text: z.string().describe("Cue text — one page of ~3-5 words shown together."),
4234
+ start: z.number().describe("Cue start in seconds on the composition timeline."),
4235
+ duration: z.number().describe("Cue length in seconds (cues must not overlap)."),
4236
+ words: z.array(z.object({
4237
+ text: z.string(),
4238
+ start: z.number().describe("Word start in seconds RELATIVE to the cue start."),
4239
+ end: z.number().describe("Word end in seconds RELATIVE to the cue start.")
4240
+ })).optional().describe("Word-level timings relative to the cue start (e.g. from a transcript). Omit to auto-estimate across the cue.")
4241
+ })).optional().describe("set_captions: the full animated-caption cue list, one caption layer per cue on a shared free track. Replaces ALL existing animated caption layers. Derive cue timing from the video's transcript/segments when available. Alternatively omit this and pass text (+ start/duration window) to auto-page plain text."),
4224
4242
  volume: z.number().optional().describe("Volume 0..1 for audio/video."),
4225
4243
  muted: z.boolean().optional().describe("Toggle mute on audio/video."),
4226
4244
  group_label: z.string().optional().describe("Optional label for group_layers."),
@@ -4615,6 +4633,34 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4615
4633
  rewritten = `${rewritten}\n${kenBurnsTag}`;
4616
4634
  }
4617
4635
  }
4636
+ // Same deal for the animated-caption keyframes: applying a caption style in
4637
+ // the editor must animate immediately on older stored compositions.
4638
+ if (!rewritten.includes(CAPTION_STYLE_MARKER)) {
4639
+ const captionTag = `<style data-vf-captions="true">${CAPTION_ANIM_CSS}</style>`;
4640
+ if (/<\/head>/i.test(rewritten)) {
4641
+ rewritten = rewritten.replace(/<\/head>/i, `${captionTag}\n</head>`);
4642
+ }
4643
+ else if (/<\/body>/i.test(rewritten)) {
4644
+ rewritten = rewritten.replace(/<\/body>/i, `${captionTag}\n</body>`);
4645
+ }
4646
+ else {
4647
+ rewritten = `${rewritten}\n${captionTag}`;
4648
+ }
4649
+ }
4650
+ // And the scene-transition keyframes: toggling data-transition on a clip
4651
+ // must animate immediately on older stored compositions.
4652
+ if (!rewritten.includes(TRANSITION_STYLE_MARKER)) {
4653
+ const transitionTag = `<style data-vf-transitions="true">${TRANSITION_CSS}</style>`;
4654
+ if (/<\/head>/i.test(rewritten)) {
4655
+ rewritten = rewritten.replace(/<\/head>/i, `${transitionTag}\n</head>`);
4656
+ }
4657
+ else if (/<\/body>/i.test(rewritten)) {
4658
+ rewritten = rewritten.replace(/<\/body>/i, `${transitionTag}\n</body>`);
4659
+ }
4660
+ else {
4661
+ rewritten = `${rewritten}\n${transitionTag}`;
4662
+ }
4663
+ }
4618
4664
  return rewritten;
4619
4665
  }
4620
4666
  async function renderHyperframesStudioEditorPage(input) {
package/dist/src/cli.js CHANGED
@@ -9,8 +9,10 @@ import { spawnSync } from "node:child_process";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
11
11
  import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
12
+ import { runCaptionsCommand } from "./devcli/captions.js";
12
13
  import { runClipsCommand } from "./devcli/clips.js";
13
14
  import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
15
+ import { inferSpeechProviderForVoice } from "./services/speech.js";
14
16
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
15
17
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
16
18
  // `serve` command boots the FULL editor locally (single origin, disk-backed
@@ -141,6 +143,12 @@ Generate AI media and drop it on the timeline (for local coding agents):
141
143
  zoom-out|pan-left|pan-right|pan-up|pan-down|
142
144
  zoom-in-left|zoom-in-right
143
145
  --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
146
+ --transition <preset> Scene transition INTO this clip (the previous
147
+ butt-cut clip is held beneath it automatically):
148
+ crossfade|fade-black|slide-left|slide-right|
149
+ slide-up|slide-down|wipe-left|wipe-right|wipe-up|
150
+ wipe-down|zoom-in|zoom-out|circle-open
151
+ --transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
144
152
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
145
153
  original vs decomposed (caption-free),
146
154
  non-billing (alias: ghostcut)
@@ -167,7 +175,9 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
167
175
  The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
168
176
  --style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
169
177
  (e.g. "calm, warm bedtime narrator", "excited sports announcer")
170
- --voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…)
178
+ --voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…).
179
+ The provider is inferred from the voice when --provider is omitted;
180
+ a voice from the wrong provider family is rejected with a clear error.
171
181
  --out <file> Output audio path (default tts-<id>.mp3|wav)
172
182
  --provider <p> --model <m> --format mp3|wav
173
183
  --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
@@ -183,6 +193,17 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
183
193
  --provider <p> --model <m>
184
194
  --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
185
195
  (a local file uploads to your temp folder first — 30-day TTL)
196
+
197
+ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
198
+ captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
199
+ caption cue layers (openai = real word your provider key; writes
200
+ timestamps; gemini/openrouter estimated). composition.html on disk
201
+ --style <preset> spotlight|karaoke|word-pop|stack-up|neon|bounce (default spotlight)
202
+ --audio <file|url> | --srt <file> | --text "<script>" Pick the transcript source
203
+ --max-words <n> --active-color <css> --uppercase … (run 'vidfarm captions' for all flags)
204
+ captions style <dir> Restyle existing cues (same text/timings)
205
+ captions list <dir> Show the current animated caption cues [--json]
206
+ captions clear <dir> Remove all animated caption layers
186
207
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
187
208
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
188
209
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -437,6 +458,9 @@ async function main() {
437
458
  case "clips":
438
459
  await runClipsCommand(rest);
439
460
  return;
461
+ case "captions":
462
+ await runCaptionsCommand(rest);
463
+ return;
440
464
  default:
441
465
  if (!command.startsWith("-")) {
442
466
  // Positional template id → default to booting the local editor.
@@ -1986,6 +2010,8 @@ function placeMediaOnDisk(input) {
1986
2010
  objectFit: input.objectFit,
1987
2011
  kenBurns: input.kenBurns,
1988
2012
  kenBurnsIntensity: input.kenBurnsIntensity,
2013
+ transition: input.transition,
2014
+ transitionDuration: input.transitionDuration,
1989
2015
  slug: input.slug
1990
2016
  };
1991
2017
  const result = input.replace
@@ -2017,6 +2043,8 @@ async function runGenerateCommand(argv) {
2017
2043
  track: { type: "string" },
2018
2044
  "ken-burns": { type: "string" },
2019
2045
  "ken-burns-intensity": { type: "string" },
2046
+ transition: { type: "string" },
2047
+ "transition-duration": { type: "string" },
2020
2048
  "layer-key": { type: "string" }
2021
2049
  }
2022
2050
  });
@@ -2090,6 +2118,8 @@ async function runGenerateCommand(argv) {
2090
2118
  track: parsed.values.track ? Number(parsed.values.track) : undefined,
2091
2119
  kenBurns: parsed.values["ken-burns"],
2092
2120
  kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2121
+ transition: parsed.values.transition,
2122
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
2093
2123
  layerKey: parsed.values["layer-key"]
2094
2124
  });
2095
2125
  if (!ctx.json)
@@ -2158,7 +2188,10 @@ async function runTtsCommand(argv) {
2158
2188
  const voice = parsed.values.voice;
2159
2189
  const json = Boolean(parsed.values.json);
2160
2190
  if (!parsed.values.cloud) {
2161
- const auth = resolveLocalSpeechAuth(parsed.values, ["openai", "gemini", "openrouter"], parsed.values.provider);
2191
+ // A Gemini voice preset ("Kore", "Puck", ) should find a Gemini-capable
2192
+ // key first — openrouter's default TTS model is Gemini-family too.
2193
+ const keyPreference = inferSpeechProviderForVoice(voice) === "gemini" ? ["gemini", "openrouter", "openai"] : ["openai", "gemini", "openrouter"];
2194
+ const auth = resolveLocalSpeechAuth(parsed.values, keyPreference, parsed.values.provider);
2162
2195
  if (!auth)
2163
2196
  throw new Error(speechKeyHint("tts"));
2164
2197
  if (!json)
@@ -2471,6 +2504,8 @@ async function runPlaceCommand(argv) {
2471
2504
  "object-fit": { type: "string" },
2472
2505
  "ken-burns": { type: "string" },
2473
2506
  "ken-burns-intensity": { type: "string" },
2507
+ transition: { type: "string" },
2508
+ "transition-duration": { type: "string" },
2474
2509
  "layer-key": { type: "string" },
2475
2510
  slug: { type: "string" },
2476
2511
  // Local file support: where a serve box serves /storage from, and which
@@ -2513,6 +2548,8 @@ async function runPlaceCommand(argv) {
2513
2548
  objectFit: parsed.values["object-fit"],
2514
2549
  kenBurns: parsed.values["ken-burns"],
2515
2550
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2551
+ transition: parsed.values.transition,
2552
+ transitionDuration: num(parsed.values["transition-duration"]),
2516
2553
  layerKey: parsed.values["layer-key"],
2517
2554
  slug: parsed.values.slug
2518
2555
  });
@@ -171,14 +171,88 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
171
171
  }
172
172
  }
173
173
 
174
+ // Scene transitions: a [data-transition] clip animates in (vf-tr-*
175
+ // keyframes) over its first data-transition-duration seconds, ON TOP of the
176
+ // previous clip on its track — so that previous clip must stay active for
177
+ // the transition window. Publish materializes this overlap into
178
+ // data-duration (normalizePublishHtml); the preview grants it live so
179
+ // butt-cut drafts preview exactly like the render.
180
+ function collectTransitionWindows() {
181
+ const windows = [];
182
+ for (const clip of clips) {
183
+ if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) continue;
184
+ if (!clip.hasAttribute("data-transition")) continue;
185
+ const timing = readTiming(clip);
186
+ let transitionDuration = Number(clip.getAttribute("data-transition-duration"));
187
+ if (!Number.isFinite(transitionDuration) || transitionDuration <= 0) transitionDuration = 0.5;
188
+ windows.push({
189
+ track: Number(clip.getAttribute("data-track-index")) || 0,
190
+ start: timing.start,
191
+ duration: Math.min(transitionDuration, timing.duration)
192
+ });
193
+ }
194
+ return windows;
195
+ }
196
+
197
+ function transitionHoldFor(clip, timing, windows) {
198
+ if (!windows.length) return 0;
199
+ if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) return 0;
200
+ const track = Number(clip.getAttribute("data-track-index")) || 0;
201
+ let hold = 0;
202
+ for (const window of windows) {
203
+ if (window.track !== track) continue;
204
+ // Nominal-end adjacency: already-materialized holds (published HTML)
205
+ // shift this clip's end past the next start, fail the check, and are
206
+ // simply not re-granted — no double extension either way.
207
+ if (Math.abs(window.start - timing.end) > 0.05) continue;
208
+ if (window.duration > hold) hold = window.duration;
209
+ }
210
+ return hold;
211
+ }
212
+
213
+ function syncTransitionClip(clip, timing) {
214
+ if (typeof clip.getAnimations !== "function") return;
215
+ const offsetMs = Math.max(0, (time - timing.start)) * 1000;
216
+ let anims = [];
217
+ try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
218
+ for (const anim of anims) {
219
+ if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-tr-") !== 0) continue;
220
+ try {
221
+ anim.pause();
222
+ anim.currentTime = offsetMs;
223
+ } catch {}
224
+ }
225
+ }
226
+
227
+ // Animated caption words carry vf-cap-* keyframes whose inline delay/
228
+ // duration ARE the word windows relative to the layer start, so unlike Ken
229
+ // Burns there is nothing to normalize — the scrub is simply the absolute
230
+ // offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
231
+ // render producer's CSS adapter exactly.
232
+ function syncCaptionClip(clip, timing) {
233
+ if (typeof clip.getAnimations !== "function") return;
234
+ 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;
239
+ try {
240
+ anim.pause();
241
+ anim.currentTime = offsetMs;
242
+ } catch {}
243
+ }
244
+ }
245
+
174
246
  function apply(nextTime, options = {}) {
175
247
  const forceSeek = Boolean(options.forceSeek);
176
248
  const fromClock = Boolean(options.fromClock);
177
249
  time = clampTime(nextTime);
178
250
  let activeVideoProxy = null;
251
+ const transitionWindows = collectTransitionWindows();
179
252
  for (const clip of clips) {
180
253
  const timing = readTiming(clip);
181
- const active = time >= timing.start && time < timing.end;
254
+ const hold = transitionHoldFor(clip, timing, transitionWindows);
255
+ const active = time >= timing.start && time < timing.end + hold;
182
256
  if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
183
257
  if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
184
258
  if (active) activeVideoProxy = clip;
@@ -186,6 +260,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
186
260
  setVisibility(clip, active);
187
261
  }
188
262
  if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
263
+ if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
264
+ if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
189
265
  }
190
266
  if (clip instanceof HTMLMediaElement) {
191
267
  applyMediaState(clip);
@@ -0,0 +1,310 @@
1
+ // devcli `vidfarm captions …` — animated word-by-word captions (TikTok/CapCut
2
+ // style) for a composition.html on disk. LOCAL-FIRST like `place`/`stt`: the
3
+ // transcript comes from the user's own provider key (openai gives REAL
4
+ // word-level timings via whisper-1; gemini/openrouter fall back to estimated
5
+ // word windows), the DOM edit is a local file write, and a running
6
+ // `vidfarm serve` live-morphs the change into open editor tabs. No cloud job,
7
+ // no wallet. Feed it an --srt file (e.g. from `vidfarm stt --cloud`) to skip
8
+ // STT entirely, or --text to page a script without any transcript.
9
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { clearCaptionLayers, computeCompositionGaps, inspectComposition, insertCaptionLayers, readCaptionCues, restyleCaptionLayers } from "./composition-edit.js";
13
+ import { loadSpeechAudioLocally, localTranscribeSpeech, resolveLocalSpeechAuth, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./speech.js";
14
+ import { buildCaptionCues, cuesFromText } from "../services/captions.js";
15
+ import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS } from "../hyperframes/composition.js";
16
+ export const CAPTIONS_HELP = `vidfarm captions — animated word-by-word captions (local STT + local file write)
17
+
18
+ captions generate <dir|composition.html> Transcribe narration & lay down animated caption cues
19
+ --audio <file|url> Audio/video to transcribe. Default: the composition's first
20
+ audio layer src, else its first video layer src.
21
+ --srt <file> Skip STT; build cues from this SRT subtitle file.
22
+ --text "<script>" Skip STT; page this text across [--start, --start+--duration].
23
+ --style <preset> ${CAPTION_STYLE_PRESETS.map((p) => p.id).join(" | ")} (default: spotlight)
24
+ --animation <anim> ${CAPTION_ANIMATIONS.join(" | ")}
25
+ --active-color <css> Color the active word takes while spoken
26
+ --highlight-color <css> Pill behind the active word (highlight-pop)
27
+ --color / --background / --background-style plain|outline|highlight-solid|highlight-translucent
28
+ --font <family> / --font-size <px> / --uppercase
29
+ --max-words <n> Words per cue page (default 4)
30
+ --track <n> / --x / --y / --width / --height (%)
31
+ --provider <p> openai | gemini | openrouter (default: openai first — it has
32
+ real word timestamps; gemini/openrouter estimate word windows)
33
+ --language <code> Expected audio language
34
+ --offset <sec> Shift every cue (e.g. narration layer's start)
35
+ --start <sec> --duration <sec> Window for --text paging
36
+ --json
37
+
38
+ captions style <dir|composition.html> --style <preset> Restyle existing cues (same text/timing)
39
+ captions list <dir|composition.html> [--json] Show current animated caption cues
40
+ captions clear <dir|composition.html> Remove all animated caption layers
41
+
42
+ Word timings ride inside each cue layer (per-word animation-delay/duration), so preview,
43
+ serve live-morph, local render, and cloud render all animate identically. \`publish\` pushes
44
+ the edited composition to the cloud as usual.
45
+ `;
46
+ const STYLE_FLAG_OPTIONS = {
47
+ style: { type: "string" },
48
+ animation: { type: "string" },
49
+ "active-color": { type: "string" },
50
+ "highlight-color": { type: "string" },
51
+ color: { type: "string" },
52
+ background: { type: "string" },
53
+ "background-style": { type: "string" },
54
+ font: { type: "string" },
55
+ "font-size": { type: "string" },
56
+ uppercase: { type: "boolean" },
57
+ track: { type: "string" },
58
+ x: { type: "string" },
59
+ y: { type: "string" },
60
+ width: { type: "string" },
61
+ height: { type: "string" }
62
+ };
63
+ export async function runCaptionsCommand(argv) {
64
+ const sub = argv[0];
65
+ const rest = argv.slice(1);
66
+ switch (sub) {
67
+ case "generate":
68
+ return runGenerate(rest);
69
+ case "style":
70
+ return runStyle(rest);
71
+ case "list":
72
+ return runListCues(rest);
73
+ case "clear":
74
+ return runClear(rest);
75
+ case undefined:
76
+ case "help":
77
+ case "--help":
78
+ case "-h":
79
+ console.log(CAPTIONS_HELP);
80
+ return;
81
+ default:
82
+ console.error(`Unknown captions subcommand: ${sub}\n`);
83
+ console.log(CAPTIONS_HELP);
84
+ process.exitCode = 1;
85
+ }
86
+ }
87
+ // Same dir-or-file resolution as `vidfarm place` (cli.ts resolveCompositionHtmlPath).
88
+ function resolveCompositionHtml(target) {
89
+ if (!target)
90
+ throw new Error("Provide the composition dir or composition.html path.");
91
+ const abs = path.resolve(process.cwd(), target);
92
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
93
+ const inside = path.join(abs, "composition.html");
94
+ if (!existsSync(inside))
95
+ throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
96
+ return inside;
97
+ }
98
+ if (!existsSync(abs))
99
+ throw new Error(`No such composition file or dir: ${abs}.`);
100
+ return abs;
101
+ }
102
+ function styleOptionsFromFlags(values) {
103
+ const num = (key) => {
104
+ const raw = values[key];
105
+ if (typeof raw !== "string" || !raw.trim())
106
+ return undefined;
107
+ const parsed = Number(raw);
108
+ if (!Number.isFinite(parsed))
109
+ throw new Error(`--${key} must be a number.`);
110
+ return parsed;
111
+ };
112
+ return {
113
+ style: typeof values.style === "string" ? values.style : undefined,
114
+ animation: typeof values.animation === "string" ? values.animation : undefined,
115
+ activeColor: typeof values["active-color"] === "string" ? values["active-color"] : undefined,
116
+ highlightColor: typeof values["highlight-color"] === "string" ? values["highlight-color"] : undefined,
117
+ uppercase: values.uppercase === true ? true : undefined,
118
+ color: typeof values.color === "string" ? values.color : undefined,
119
+ background: typeof values.background === "string" ? values.background : undefined,
120
+ backgroundStyle: typeof values["background-style"] === "string" ? values["background-style"] : undefined,
121
+ fontFamily: typeof values.font === "string" ? values.font : undefined,
122
+ fontSize: num("font-size"),
123
+ track: num("track"),
124
+ x: num("x"),
125
+ y: num("y"),
126
+ width: num("width"),
127
+ height: num("height")
128
+ };
129
+ }
130
+ // Minimal SRT reader: index / "start --> end" / text lines. Produces the same
131
+ // segment shape STT returns so cue building is one code path.
132
+ export function parseSrtToSegments(srt) {
133
+ const toSeconds = (stamp) => {
134
+ const match = /(\d+):(\d+):(\d+)[,.](\d+)/.exec(stamp.trim());
135
+ if (!match)
136
+ return null;
137
+ return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4].padEnd(3, "0").slice(0, 3)) / 1000;
138
+ };
139
+ const segments = [];
140
+ for (const block of srt.split(/\r?\n\r?\n+/)) {
141
+ const lines = block.split(/\r?\n/).filter((line) => line.trim());
142
+ const timingIndex = lines.findIndex((line) => line.includes("-->"));
143
+ if (timingIndex === -1)
144
+ continue;
145
+ const [startRaw, endRaw] = lines[timingIndex].split("-->");
146
+ const start = toSeconds(startRaw ?? "");
147
+ const end = toSeconds(endRaw ?? "");
148
+ const text = lines.slice(timingIndex + 1).join(" ").replace(/<[^>]+>/g, "").trim();
149
+ if (start === null || !text)
150
+ continue;
151
+ segments.push({ speaker: "speaker_1", start_sec: start, end_sec: end, text });
152
+ }
153
+ return segments;
154
+ }
155
+ // Default transcription source: the composition's own narration. Prefer a
156
+ // dedicated audio layer; fall back to the first video layer with a src.
157
+ function defaultAudioSource(htmlPath, html) {
158
+ const info = inspectComposition(html);
159
+ const candidate = info.layers.find((layer) => layer.kind === "audio" && layer.src)
160
+ ?? info.layers.find((layer) => layer.kind === "video" && layer.src);
161
+ if (!candidate?.src)
162
+ return null;
163
+ const src = candidate.src;
164
+ if (/^https?:\/\//i.test(src))
165
+ return src;
166
+ // Relative srcs resolve against the composition dir (pull/serve layouts).
167
+ return path.resolve(path.dirname(htmlPath), src.replace(/^\.\//, ""));
168
+ }
169
+ async function runGenerate(argv) {
170
+ const { values, positionals } = parseArgs({
171
+ args: argv,
172
+ allowPositionals: true,
173
+ options: {
174
+ ...STYLE_FLAG_OPTIONS,
175
+ audio: { type: "string" },
176
+ srt: { type: "string" },
177
+ text: { type: "string" },
178
+ "max-words": { type: "string" },
179
+ provider: { type: "string" },
180
+ language: { type: "string" },
181
+ offset: { type: "string" },
182
+ start: { type: "string" },
183
+ duration: { type: "string" },
184
+ "gemini-key": { type: "string" },
185
+ "openai-key": { type: "string" },
186
+ "openrouter-key": { type: "string" },
187
+ json: { type: "boolean" }
188
+ }
189
+ });
190
+ const htmlPath = resolveCompositionHtml(positionals[0]);
191
+ const html = readFileSync(htmlPath, "utf8");
192
+ const info = inspectComposition(html);
193
+ const maxWords = values["max-words"] ? Math.max(1, Math.round(Number(values["max-words"]))) : 4;
194
+ const offsetSec = values.offset ? Number(values.offset) : 0;
195
+ if (!Number.isFinite(offsetSec))
196
+ throw new Error("--offset must be a number of seconds.");
197
+ let cues;
198
+ let sourceNote;
199
+ if (typeof values.text === "string" && values.text.trim()) {
200
+ const start = values.start ? Number(values.start) : 0;
201
+ const duration = values.duration ? Number(values.duration) : Math.max(0.4, info.duration_seconds - start);
202
+ cues = cuesFromText(values.text, start + offsetSec, duration, { maxWordsPerCue: maxWords });
203
+ sourceNote = "provided text (estimated word timings)";
204
+ }
205
+ else if (typeof values.srt === "string" && values.srt.trim()) {
206
+ const srtPath = path.resolve(process.cwd(), values.srt);
207
+ if (!existsSync(srtPath))
208
+ throw new Error(`No such SRT file: ${srtPath}`);
209
+ const segments = parseSrtToSegments(readFileSync(srtPath, "utf8"));
210
+ if (!segments.length)
211
+ throw new Error(`No cues found in ${srtPath}.`);
212
+ cues = buildCaptionCues(segments, { maxWordsPerCue: maxWords, offsetSec, fallbackDurationSec: info.duration_seconds });
213
+ sourceNote = `${path.basename(srtPath)} (${segments.length} SRT cues, estimated word timings)`;
214
+ }
215
+ else {
216
+ // openai first: whisper-1 verbose_json is the only path with REAL word
217
+ // timestamps, which is what makes the word-by-word animation land on beat.
218
+ const auth = resolveLocalSpeechAuth(values, ["openai", "gemini", "openrouter"], typeof values.provider === "string" ? values.provider : undefined);
219
+ if (!auth) {
220
+ throw new Error("No local provider key found. Export OPENAI_API_KEY (best: real word timings), GEMINI_API_KEY, or OPENROUTER_API_KEY — or pass --srt/--text instead.");
221
+ }
222
+ const audioTarget = typeof values.audio === "string" && values.audio.trim()
223
+ ? values.audio.trim()
224
+ : defaultAudioSource(htmlPath, html);
225
+ if (!audioTarget) {
226
+ throw new Error("Could not find narration to transcribe (no audio/video layer with a src). Pass --audio <file|url>, --srt, or --text.");
227
+ }
228
+ console.log(`Transcribing ${audioTarget} (${auth.provider}, local key)…`);
229
+ const audio = await loadSpeechAudioLocally(audioTarget);
230
+ if (audio.bytes.byteLength > MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES) {
231
+ throw new Error(`Audio is ${(audio.bytes.byteLength / (1024 * 1024)).toFixed(1)}MB (limit ${(MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024)).toFixed(0)}MB). Trim it or transcribe externally and pass --srt.`);
232
+ }
233
+ const transcript = await localTranscribeSpeech({
234
+ auth,
235
+ audio: audio.bytes,
236
+ contentType: audio.contentType,
237
+ language: typeof values.language === "string" ? values.language : undefined,
238
+ diarize: auth.provider === "gemini",
239
+ wordTimestamps: true
240
+ });
241
+ if (!transcript.segments.length)
242
+ throw new Error("Transcription returned no speech segments.");
243
+ cues = buildCaptionCues(transcript.segments, { maxWordsPerCue: maxWords, offsetSec, fallbackDurationSec: info.duration_seconds });
244
+ const hasRealWords = transcript.segments.some((segment) => segment.words?.length);
245
+ sourceNote = `${transcript.provider}/${transcript.model} (${hasRealWords ? "word-level timestamps" : "estimated word timings"})`;
246
+ }
247
+ if (!cues.length)
248
+ throw new Error("No caption cues produced.");
249
+ // Keep cues inside the composition's duration.
250
+ cues = cues.filter((cue) => cue.start < info.duration_seconds)
251
+ .map((cue) => ({ ...cue, duration: Math.min(cue.duration, Math.max(0.1, info.duration_seconds - cue.start)) }));
252
+ const result = insertCaptionLayers(html, cues, styleOptionsFromFlags(values));
253
+ writeFileSync(htmlPath, result.html);
254
+ if (values.json) {
255
+ console.log(JSON.stringify({ ok: true, style: result.styleId, cues: cues.length, replaced: result.replaced, layer_keys: result.layerKeys, source: sourceNote, gaps: computeCompositionGaps(result.html) }, null, 2));
256
+ return;
257
+ }
258
+ console.log(`Wrote ${cues.length} animated caption cues (style: ${result.styleId}) from ${sourceNote}.`);
259
+ if (result.replaced > 0)
260
+ console.log(`Replaced ${result.replaced} previous caption cues.`);
261
+ console.log(`Updated ${htmlPath} — a running \`vidfarm serve\` live-morphs it; \`vidfarm publish\` pushes to cloud.`);
262
+ }
263
+ async function runStyle(argv) {
264
+ const { values, positionals } = parseArgs({
265
+ args: argv,
266
+ allowPositionals: true,
267
+ options: { ...STYLE_FLAG_OPTIONS, json: { type: "boolean" } }
268
+ });
269
+ const htmlPath = resolveCompositionHtml(positionals[0]);
270
+ const opts = styleOptionsFromFlags(values);
271
+ if (!opts.style && !opts.animation && !opts.activeColor && !opts.highlightColor && opts.uppercase === undefined && !opts.color && !opts.background && !opts.backgroundStyle && !opts.fontFamily && opts.fontSize === undefined) {
272
+ throw new Error("captions style needs at least one style flag (e.g. --style karaoke).");
273
+ }
274
+ const result = restyleCaptionLayers(readFileSync(htmlPath, "utf8"), opts);
275
+ writeFileSync(htmlPath, result.html);
276
+ if (values.json) {
277
+ console.log(JSON.stringify({ ok: true, style: result.styleId, cues: result.layerKeys.length }, null, 2));
278
+ return;
279
+ }
280
+ console.log(`Restyled ${result.layerKeys.length} caption cues to ${result.styleId}.`);
281
+ }
282
+ async function runListCues(argv) {
283
+ const { values, positionals } = parseArgs({
284
+ args: argv,
285
+ allowPositionals: true,
286
+ options: { json: { type: "boolean" } }
287
+ });
288
+ const htmlPath = resolveCompositionHtml(positionals[0]);
289
+ const cues = readCaptionCues(readFileSync(htmlPath, "utf8"));
290
+ if (values.json) {
291
+ console.log(JSON.stringify({ cues }, null, 2));
292
+ return;
293
+ }
294
+ if (!cues.length) {
295
+ console.log("No animated caption layers. Run `vidfarm captions generate <dir>` to add some.");
296
+ return;
297
+ }
298
+ for (const cue of cues) {
299
+ console.log(`${cue.start.toFixed(2).padStart(8)}s +${cue.duration.toFixed(2)}s ${cue.text}`);
300
+ }
301
+ console.log(`${cues.length} cues on track ${cues[0].track}.`);
302
+ }
303
+ async function runClear(argv) {
304
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
305
+ const htmlPath = resolveCompositionHtml(positionals[0]);
306
+ const result = clearCaptionLayers(readFileSync(htmlPath, "utf8"));
307
+ writeFileSync(htmlPath, result.html);
308
+ console.log(`Removed ${result.removed} animated caption layer${result.removed === 1 ? "" : "s"}.`);
309
+ }
310
+ //# sourceMappingURL=captions.js.map