@mevdragon/vidfarm-devcli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -79,6 +81,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
79
81
  local MP4/MOV/WebM file — as a private (files: presign + PUT via
80
82
  template. --title (uploads; defaults to the /discover/templates/upload)
81
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.
82
87
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
83
88
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
84
89
 
@@ -141,6 +146,12 @@ Generate AI media and drop it on the timeline (for local coding agents):
141
146
  zoom-out|pan-left|pan-right|pan-up|pan-down|
142
147
  zoom-in-left|zoom-in-right
143
148
  --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
149
+ --transition <preset> Scene transition INTO this clip (the previous
150
+ butt-cut clip is held beneath it automatically):
151
+ crossfade|fade-black|slide-left|slide-right|
152
+ slide-up|slide-down|wipe-left|wipe-right|wipe-up|
153
+ wipe-down|zoom-in|zoom-out|circle-open
154
+ --transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
144
155
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
145
156
  original vs decomposed (caption-free),
146
157
  non-billing (alias: ghostcut)
@@ -167,7 +178,9 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
167
178
  The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
168
179
  --style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
169
180
  (e.g. "calm, warm bedtime narrator", "excited sports announcer")
170
- --voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…)
181
+ --voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…).
182
+ The provider is inferred from the voice when --provider is omitted;
183
+ a voice from the wrong provider family is rejected with a clear error.
171
184
  --out <file> Output audio path (default tts-<id>.mp3|wav)
172
185
  --provider <p> --model <m> --format mp3|wav
173
186
  --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
@@ -183,6 +196,17 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
183
196
  --provider <p> --model <m>
184
197
  --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
185
198
  (a local file uploads to your temp folder first — 30-day TTL)
199
+
200
+ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
201
+ captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
202
+ caption cue layers (openai = real word your provider key; writes
203
+ timestamps; gemini/openrouter estimated). composition.html on disk
204
+ --style <preset> spotlight|karaoke|word-pop|stack-up|neon|bounce (default spotlight)
205
+ --audio <file|url> | --srt <file> | --text "<script>" Pick the transcript source
206
+ --max-words <n> --active-color <css> --uppercase … (run 'vidfarm captions' for all flags)
207
+ captions style <dir> Restyle existing cues (same text/timings)
208
+ captions list <dir> Show the current animated caption cues [--json]
209
+ captions clear <dir> Remove all animated caption layers
186
210
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
187
211
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
188
212
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -437,6 +461,9 @@ async function main() {
437
461
  case "clips":
438
462
  await runClipsCommand(rest);
439
463
  return;
464
+ case "captions":
465
+ await runCaptionsCommand(rest);
466
+ return;
440
467
  default:
441
468
  if (!command.startsWith("-")) {
442
469
  // Positional template id → default to booting the local editor.
@@ -597,6 +624,11 @@ async function runServeCommand(argv) {
597
624
  // local-render by definition — cloud rendering is the EXPLICIT
598
625
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
599
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 = "";
600
632
  // Same trap as the state machine ARN: a dev-shell .env can carry the staging
601
633
  // media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
602
634
  // Lambda that cannot fetch this box's localhost storage URLs ("fetch
@@ -1241,7 +1273,7 @@ async function runVideosCommand(argv) {
1241
1273
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1242
1274
  }
1243
1275
  async function runInspirationAddCommand(argv) {
1244
- 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 } } });
1245
1277
  const source = parsed.positionals[0];
1246
1278
  if (!source)
1247
1279
  throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
@@ -1261,7 +1293,10 @@ async function runInspirationAddCommand(argv) {
1261
1293
  fileName: path.basename(localPath),
1262
1294
  title: parsed.values.title,
1263
1295
  tagline: parsed.values.tagline,
1264
- 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
1265
1300
  });
1266
1301
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1267
1302
  return;
@@ -1356,7 +1391,7 @@ async function ingestUploadedVideo(ctx, input) {
1356
1391
  host: ctx.host,
1357
1392
  path: "/discover/templates",
1358
1393
  auth: ctx.auth,
1359
- 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 }
1360
1395
  });
1361
1396
  assertApiOk(result, "ingest");
1362
1397
  return result;
@@ -1531,7 +1566,10 @@ async function runCreateCommand(argv) {
1531
1566
  throw new Error(`create: could not fetch the generated video (${dl.status}).`);
1532
1567
  const buffer = Buffer.from(await dl.arrayBuffer());
1533
1568
  const title = parsed.values.title ?? prompt.slice(0, 80);
1534
- 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" });
1535
1573
  const inspirationId = add.json?.inspiration_id;
1536
1574
  if (!inspirationId)
1537
1575
  throw new Error("create: ingest did not return an inspiration id.");
@@ -1986,6 +2024,8 @@ function placeMediaOnDisk(input) {
1986
2024
  objectFit: input.objectFit,
1987
2025
  kenBurns: input.kenBurns,
1988
2026
  kenBurnsIntensity: input.kenBurnsIntensity,
2027
+ transition: input.transition,
2028
+ transitionDuration: input.transitionDuration,
1989
2029
  slug: input.slug
1990
2030
  };
1991
2031
  const result = input.replace
@@ -2017,6 +2057,8 @@ async function runGenerateCommand(argv) {
2017
2057
  track: { type: "string" },
2018
2058
  "ken-burns": { type: "string" },
2019
2059
  "ken-burns-intensity": { type: "string" },
2060
+ transition: { type: "string" },
2061
+ "transition-duration": { type: "string" },
2020
2062
  "layer-key": { type: "string" }
2021
2063
  }
2022
2064
  });
@@ -2090,6 +2132,8 @@ async function runGenerateCommand(argv) {
2090
2132
  track: parsed.values.track ? Number(parsed.values.track) : undefined,
2091
2133
  kenBurns: parsed.values["ken-burns"],
2092
2134
  kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2135
+ transition: parsed.values.transition,
2136
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
2093
2137
  layerKey: parsed.values["layer-key"]
2094
2138
  });
2095
2139
  if (!ctx.json)
@@ -2158,7 +2202,10 @@ async function runTtsCommand(argv) {
2158
2202
  const voice = parsed.values.voice;
2159
2203
  const json = Boolean(parsed.values.json);
2160
2204
  if (!parsed.values.cloud) {
2161
- const auth = resolveLocalSpeechAuth(parsed.values, ["openai", "gemini", "openrouter"], parsed.values.provider);
2205
+ // A Gemini voice preset ("Kore", "Puck", ) should find a Gemini-capable
2206
+ // key first — openrouter's default TTS model is Gemini-family too.
2207
+ const keyPreference = inferSpeechProviderForVoice(voice) === "gemini" ? ["gemini", "openrouter", "openai"] : ["openai", "gemini", "openrouter"];
2208
+ const auth = resolveLocalSpeechAuth(parsed.values, keyPreference, parsed.values.provider);
2162
2209
  if (!auth)
2163
2210
  throw new Error(speechKeyHint("tts"));
2164
2211
  if (!json)
@@ -2471,6 +2518,8 @@ async function runPlaceCommand(argv) {
2471
2518
  "object-fit": { type: "string" },
2472
2519
  "ken-burns": { type: "string" },
2473
2520
  "ken-burns-intensity": { type: "string" },
2521
+ transition: { type: "string" },
2522
+ "transition-duration": { type: "string" },
2474
2523
  "layer-key": { type: "string" },
2475
2524
  slug: { type: "string" },
2476
2525
  // Local file support: where a serve box serves /storage from, and which
@@ -2513,6 +2562,8 @@ async function runPlaceCommand(argv) {
2513
2562
  objectFit: parsed.values["object-fit"],
2514
2563
  kenBurns: parsed.values["ken-burns"],
2515
2564
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2565
+ transition: parsed.values.transition,
2566
+ transitionDuration: num(parsed.values["transition-duration"]),
2516
2567
  layerKey: parsed.values["layer-key"],
2517
2568
  slug: parsed.values.slug
2518
2569
  });
@@ -171,14 +171,137 @@ 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
+ //
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
+
254
+ function syncCaptionClip(clip, timing) {
255
+ if (typeof clip.getAnimations !== "function") return;
256
+ const offsetMs = Math.max(0, (time - timing.start)) * 1000;
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 {}
288
+ try {
289
+ anim.pause();
290
+ anim.currentTime = target;
291
+ } catch {}
292
+ }
293
+ }
294
+
174
295
  function apply(nextTime, options = {}) {
175
296
  const forceSeek = Boolean(options.forceSeek);
176
297
  const fromClock = Boolean(options.fromClock);
177
298
  time = clampTime(nextTime);
178
299
  let activeVideoProxy = null;
300
+ const transitionWindows = collectTransitionWindows();
179
301
  for (const clip of clips) {
180
302
  const timing = readTiming(clip);
181
- const active = time >= timing.start && time < timing.end;
303
+ const hold = transitionHoldFor(clip, timing, transitionWindows);
304
+ const active = time >= timing.start && time < timing.end + hold;
182
305
  if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
183
306
  if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
184
307
  if (active) activeVideoProxy = clip;
@@ -186,6 +309,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
186
309
  setVisibility(clip, active);
187
310
  }
188
311
  if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
312
+ if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
313
+ if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
189
314
  }
190
315
  if (clip instanceof HTMLMediaElement) {
191
316
  applyMediaState(clip);
@@ -293,7 +418,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
293
418
  return playbackRate;
294
419
  },
295
420
  setMuted,
296
- 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
+ }
297
431
  };
298
432
 
299
433
  refreshClips();
@@ -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