@mevdragon/vidfarm-devcli 0.10.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.
@@ -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
@@ -6,9 +6,14 @@
6
6
  // pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
7
7
  // ffmpeg, no network.
8
8
  import { parseHTML } from "linkedom";
9
+ import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById, estimateCaptionWords, renderHyperframeLayerHtml } from "../hyperframes/composition.js";
9
10
  const KEN_BURNS_PRESETS = new Set([
10
11
  "zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
11
12
  ]);
13
+ const TRANSITION_PRESET_SET = new Set([
14
+ "crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down",
15
+ "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open"
16
+ ]);
12
17
  const round3 = (v) => Number(v.toFixed(3));
13
18
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
14
19
  function serialize(document) {
@@ -221,6 +226,18 @@ function buildMediaClip(document, opts, id, track, geom) {
221
226
  styles.push(`--vf-kb-amount:${Math.max(0.04, Math.min(0.5, opts.kenBurnsIntensity))}`);
222
227
  }
223
228
  }
229
+ if (opts.transition && TRANSITION_PRESET_SET.has(opts.transition)) {
230
+ // Scene transition (see src/hyperframes/composition.ts): data-transition
231
+ // picks the keyframes; data-transition-duration + --vf-tr-dur set the span.
232
+ // The overlap with the previous clip is granted by the runtime/publish
233
+ // normalizer — the stored timeline stays butt-cut.
234
+ const transitionDuration = Number.isFinite(opts.transitionDuration) && opts.transitionDuration > 0
235
+ ? Math.max(0.1, Math.min(2.5, opts.transitionDuration))
236
+ : 0.5;
237
+ node.setAttribute("data-transition", opts.transition);
238
+ node.setAttribute("data-transition-duration", String(round3(transitionDuration)));
239
+ styles.push(`--vf-tr-dur:${round3(transitionDuration)}s`);
240
+ }
224
241
  node.setAttribute("style", `${styles.join(";")};`);
225
242
  return node;
226
243
  }
@@ -254,6 +271,131 @@ export function insertMediaLayer(html, opts) {
254
271
  root.append(node);
255
272
  return { html: serialize(document), layerKey: id };
256
273
  }
274
+ const CAPTION_DEFAULT_FRAME = { x: 10, y: 70, width: 80, height: 14 };
275
+ function resolveCaptionLook(opts) {
276
+ const preset = captionStylePresetById(opts.style)
277
+ ?? (opts.animation ? null : CAPTION_STYLE_PRESETS[0]);
278
+ const animation = (opts.animation ?? preset?.animation);
279
+ if (!animation || !CAPTION_ANIMATIONS.includes(animation)) {
280
+ throw new Error(`Unknown caption animation "${opts.animation ?? opts.style}". Use a preset (${CAPTION_STYLE_PRESETS.map((p) => p.id).join(", ")}) or an animation (${CAPTION_ANIMATIONS.join(", ")}).`);
281
+ }
282
+ return {
283
+ animation,
284
+ activeColor: opts.activeColor ?? preset?.activeColor,
285
+ highlightColor: opts.highlightColor ?? preset?.highlightColor,
286
+ uppercase: opts.uppercase ?? preset?.uppercase,
287
+ color: opts.color ?? preset?.color ?? "#ffffff",
288
+ background: opts.background ?? preset?.background ?? "#000000",
289
+ backgroundStyle: (opts.backgroundStyle ?? preset?.textBackgroundStyle ?? "plain"),
290
+ fontFamily: opts.fontFamily ?? "Montserrat",
291
+ fontWeight: opts.fontWeight ?? preset?.fontWeight ?? 800,
292
+ fontSize: opts.fontSize ?? 38,
293
+ presetId: preset?.id ?? animation
294
+ };
295
+ }
296
+ /** Read the animated-caption cues already in a composition (for restyle/list). */
297
+ export function readCaptionCues(html) {
298
+ const { root } = readCompositionDoc(html);
299
+ return Array.from(root.querySelectorAll("[data-caption-animation]")).map((node) => {
300
+ const words = Array.from(node.querySelectorAll("[data-cap-word]"))
301
+ .map((span) => ({
302
+ text: String(span.textContent || "").trim(),
303
+ start: Number.parseFloat(span.getAttribute("data-word-start") ?? "") || 0,
304
+ end: Number.parseFloat(span.getAttribute("data-word-end") ?? "") || 0
305
+ }))
306
+ .filter((word) => word.text);
307
+ return {
308
+ layerKey: node.getAttribute("data-hf-id") || node.id || "",
309
+ track: Number.isFinite(numAttr(node, "data-track-index")) ? numAttr(node, "data-track-index") : 0,
310
+ text: words.length ? words.map((word) => word.text).join(" ") : String(node.textContent || "").trim(),
311
+ start: Number.isFinite(numAttr(node, "data-start")) ? numAttr(node, "data-start") : 0,
312
+ duration: Number.isFinite(numAttr(node, "data-duration")) ? numAttr(node, "data-duration") : 0,
313
+ words: words.length ? words : undefined
314
+ };
315
+ });
316
+ }
317
+ /** Remove every animated-caption layer. Returns the count removed. */
318
+ export function clearCaptionLayers(html) {
319
+ const { document, root } = readCompositionDoc(html);
320
+ const nodes = Array.from(root.querySelectorAll("[data-caption-animation]"));
321
+ for (const node of nodes)
322
+ node.parentNode?.removeChild(node);
323
+ return { html: serialize(document), removed: nodes.length };
324
+ }
325
+ /**
326
+ * Insert an animated-caption cue run: one caption layer per cue on a shared
327
+ * collision-free track. Replaces any existing animated captions (set
328
+ * semantics — matching the editor's set_captions action).
329
+ */
330
+ export function insertCaptionLayers(html, cues, opts = {}) {
331
+ const usable = cues
332
+ .filter((cue) => cue.text?.trim() && Number.isFinite(cue.start) && Number.isFinite(cue.duration) && cue.duration > 0)
333
+ .sort((a, b) => a.start - b.start);
334
+ if (!usable.length)
335
+ throw new Error("insertCaptionLayers needs at least one cue with text, start, and duration.");
336
+ const look = resolveCaptionLook(opts);
337
+ const { document, root } = readCompositionDoc(html);
338
+ const existing = Array.from(root.querySelectorAll("[data-caption-animation]"));
339
+ for (const node of existing)
340
+ node.parentNode?.removeChild(node);
341
+ const clips = collectClips(root);
342
+ const runStart = usable[0].start;
343
+ const runEnd = usable.reduce((max, cue) => Math.max(max, cue.start + cue.duration), runStart);
344
+ const initialTrack = opts.track !== undefined && Number.isFinite(opts.track)
345
+ ? Math.max(0, Math.floor(opts.track))
346
+ : nextAutoTrack(clips);
347
+ const track = findNonCollidingTrack(clips, { start: runStart, duration: Math.max(0.1, runEnd - runStart), track: initialTrack }, "");
348
+ const stamp = Date.now().toString(36);
349
+ const layerKeys = [];
350
+ usable.forEach((cue, index) => {
351
+ const id = `element_cap_${stamp}_${index}`;
352
+ layerKeys.push(id);
353
+ const layer = {
354
+ id,
355
+ kind: "caption",
356
+ label: cue.text.slice(0, 48),
357
+ start: cue.start,
358
+ duration: cue.duration,
359
+ track,
360
+ z: track,
361
+ x: opts.x ?? CAPTION_DEFAULT_FRAME.x,
362
+ y: opts.y ?? CAPTION_DEFAULT_FRAME.y,
363
+ width: opts.width ?? CAPTION_DEFAULT_FRAME.width,
364
+ height: opts.height ?? CAPTION_DEFAULT_FRAME.height,
365
+ text: cue.text,
366
+ color: look.color,
367
+ background: look.background,
368
+ textBackgroundStyle: look.backgroundStyle,
369
+ fontFamily: look.fontFamily,
370
+ fontWeight: look.fontWeight,
371
+ fontSize: look.fontSize,
372
+ captionAnimation: {
373
+ animation: look.animation,
374
+ words: cue.words?.length ? cue.words : estimateCaptionWords(cue.text, cue.duration),
375
+ activeColor: look.activeColor,
376
+ highlightColor: look.highlightColor,
377
+ uppercase: look.uppercase
378
+ }
379
+ };
380
+ const container = document.createElement("div");
381
+ container.innerHTML = renderHyperframeLayerHtml(layer);
382
+ const node = container.firstElementChild;
383
+ if (!node)
384
+ throw new Error(`Failed to render caption cue ${index}.`);
385
+ node.classList?.add?.("clip");
386
+ root.append(node);
387
+ });
388
+ return { html: serialize(document), layerKeys, replaced: existing.length, styleId: look.presetId };
389
+ }
390
+ /** Restyle existing animated captions in place: same cues/timings/track, new look. */
391
+ export function restyleCaptionLayers(html, opts) {
392
+ const cues = readCaptionCues(html);
393
+ if (!cues.length)
394
+ throw new Error("No animated caption layers found. Run `captions generate` (or add captions in the editor) first.");
395
+ const track = opts.track ?? cues[0].track;
396
+ const result = insertCaptionLayers(html, cues, { ...opts, track });
397
+ return { html: result.html, layerKeys: result.layerKeys, styleId: result.styleId };
398
+ }
257
399
  // Replace an existing scene/layer with a generated media clip, taking over its
258
400
  // timing + geometry (full-canvas when it was a proxy / full-canvas fill).
259
401
  export function replaceLayerWithMedia(html, targetKey, opts) {
@@ -0,0 +1,178 @@
1
+ // Local speech engine — devcli-only. `vidfarm tts` / `vidfarm stt` run
2
+ // LOCAL-FIRST on the user's own AI key (GEMINI_API_KEY / OPENAI_API_KEY /
3
+ // OPENROUTER_API_KEY, or the --gemini-key/--openai-key/--openrouter-key flags)
4
+ // by calling the shared speech provider layer (src/services/speech.ts)
5
+ // directly: local ffmpeg for the video→audio demux, the user's key for the
6
+ // model call — no cloud job, no wallet, no REST route. The REST primitive
7
+ // routes (/api/v1/primitives/audio/speech|transcribe) are the explicit
8
+ // `--cloud` BACKUP, mirroring `vidfarm clips scan`.
9
+ //
10
+ // Unlike clip tagging, speech can NOT ride on a claude/codex CLI agent — the
11
+ // agent CLIs have no audio I/O (the same reason embeddings need a real key in
12
+ // local-agent.ts) — so "local" here means a raw provider key, not the agent.
13
+ import { spawn } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { buildSrtFromSegments, defaultSpeechModelFor, generateSpeechWithKey, transcribeSpeechWithKey } from "../services/speech.js";
19
+ import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
20
+ // Mirrors the env-var fallbacks used by `vidfarm clips` so one exported key
21
+ // powers every local-first devcli surface.
22
+ const PROVIDER_ENV = {
23
+ gemini: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
24
+ openai: ["OPENAI_API_KEY"],
25
+ openrouter: ["OPENROUTER_API_KEY"]
26
+ };
27
+ const PROVIDER_FLAG = {
28
+ gemini: "gemini-key",
29
+ openai: "openai-key",
30
+ openrouter: "openrouter-key"
31
+ };
32
+ // Same inline-audio ceiling as the cloud stt primitive (~40min of 64kbps mono).
33
+ export const MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
34
+ /**
35
+ * Find a usable local provider key: an explicit --provider narrows the search,
36
+ * otherwise the first provider (in `preference` order) with a flag/env key
37
+ * wins. Returns null when nothing local is configured (callers point the user
38
+ * at env keys or --cloud).
39
+ */
40
+ export function resolveLocalSpeechAuth(values, preference, requestedProvider) {
41
+ const providers = requestedProvider
42
+ ? [normalizeSpeechProvider(requestedProvider)]
43
+ : preference;
44
+ for (const provider of providers) {
45
+ const flagged = values[PROVIDER_FLAG[provider]];
46
+ if (typeof flagged === "string" && flagged.trim()) {
47
+ return { provider, apiKey: flagged.trim() };
48
+ }
49
+ for (const envName of PROVIDER_ENV[provider]) {
50
+ const fromEnv = process.env[envName]?.trim();
51
+ if (fromEnv) {
52
+ return { provider, apiKey: fromEnv };
53
+ }
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ export function normalizeSpeechProvider(value) {
59
+ const normalized = value.trim().toLowerCase();
60
+ if (normalized === "gemini" || normalized === "openai" || normalized === "openrouter") {
61
+ return normalized;
62
+ }
63
+ throw new Error(`Unsupported speech provider "${value}". Use gemini, openai, or openrouter.`);
64
+ }
65
+ /** Local TTS: text → audio bytes on the user's own key. */
66
+ export async function localGenerateSpeech(input) {
67
+ const model = input.model?.trim() || defaultSpeechModelFor("tts", input.auth.provider);
68
+ if (!model) {
69
+ throw new Error(`No TTS model available for provider ${input.auth.provider}.`);
70
+ }
71
+ const result = await generateSpeechWithKey({
72
+ provider: input.auth.provider,
73
+ model,
74
+ text: input.text,
75
+ voice: input.voice,
76
+ instructions: input.instructions,
77
+ responseFormat: input.responseFormat,
78
+ apiKey: input.auth.apiKey
79
+ });
80
+ return { ...result, provider: input.auth.provider, model };
81
+ }
82
+ /** Local STT: audio bytes → transcript (diarized on gemini) on the user's own key.
83
+ * wordTimestamps requests word-level timings (real on openai/whisper-1; other
84
+ * providers stay segment-level and downstream callers estimate word windows). */
85
+ export async function localTranscribeSpeech(input) {
86
+ const model = input.model?.trim() || defaultSpeechModelFor("stt", input.auth.provider);
87
+ if (!model) {
88
+ throw new Error(`No STT model available for provider ${input.auth.provider}.`);
89
+ }
90
+ const result = await transcribeSpeechWithKey({
91
+ provider: input.auth.provider,
92
+ model,
93
+ audio: input.audio,
94
+ contentType: input.contentType,
95
+ prompt: input.prompt,
96
+ language: input.language,
97
+ diarize: input.diarize,
98
+ wordTimestamps: input.wordTimestamps,
99
+ apiKey: input.auth.apiKey
100
+ });
101
+ return { ...result, provider: input.auth.provider, model, srt: buildSrtFromSegments(result.segments) };
102
+ }
103
+ const AUDIO_EXTENSION_CONTENT_TYPES = {
104
+ mp3: "audio/mpeg",
105
+ wav: "audio/wav",
106
+ m4a: "audio/mp4",
107
+ aac: "audio/mp4",
108
+ ogg: "audio/ogg",
109
+ oga: "audio/ogg",
110
+ flac: "audio/flac"
111
+ };
112
+ function audioExtensionOf(target) {
113
+ const match = /\.(mp3|wav|m4a|aac|ogg|oga|flac)(\?|#|$)/i.exec(target);
114
+ return match ? match[1].toLowerCase() : null;
115
+ }
116
+ /**
117
+ * Turn a local path OR http(s) URL — video or audio — into speech-provider
118
+ * ready audio bytes. Plain audio files pass through untouched; everything else
119
+ * (video, unknown containers) is demuxed to small mono mp3 by LOCAL ffmpeg
120
+ * (bundled ffmpeg-static, or PATH), which reads URLs directly.
121
+ */
122
+ export async function loadSpeechAudioLocally(target) {
123
+ const isUrl = /^https?:\/\//i.test(target);
124
+ if (!isUrl && !existsSync(target)) {
125
+ throw new Error(`No such local file: ${target}`);
126
+ }
127
+ const audioExtension = audioExtensionOf(target);
128
+ if (audioExtension) {
129
+ if (isUrl) {
130
+ const response = await fetch(target);
131
+ if (!response.ok) {
132
+ throw new Error(`Could not fetch source audio (${response.status}).`);
133
+ }
134
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || AUDIO_EXTENSION_CONTENT_TYPES[audioExtension];
135
+ return { bytes: new Uint8Array(await response.arrayBuffer()), contentType, demuxed: false };
136
+ }
137
+ return { bytes: readFileSync(target), contentType: AUDIO_EXTENSION_CONTENT_TYPES[audioExtension], demuxed: false };
138
+ }
139
+ const ffmpeg = await resolveFfmpeg();
140
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "vidfarm-stt-"));
141
+ const outputPath = path.join(tempDir, "audio.mp3");
142
+ try {
143
+ await runFfmpegDemux(ffmpeg, target, outputPath);
144
+ return { bytes: await readFile(outputPath), contentType: "audio/mpeg", demuxed: true };
145
+ }
146
+ finally {
147
+ await rm(tempDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+ function runFfmpegDemux(bin, input, outputPath) {
151
+ return new Promise((resolvePromise, rejectPromise) => {
152
+ const child = spawn(bin, [
153
+ "-hide_banner",
154
+ "-y",
155
+ "-i", input,
156
+ "-vn",
157
+ "-ac", "1",
158
+ "-b:a", "64k",
159
+ outputPath
160
+ ], { stdio: ["ignore", "ignore", "pipe"] });
161
+ let stderr = "";
162
+ child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
163
+ child.on("error", (error) => {
164
+ rejectPromise(error.code === "ENOENT"
165
+ ? new Error("ffmpeg not found. Install ffmpeg (or `npm i ffmpeg-static`), or run with --cloud to demux on the platform.")
166
+ : error);
167
+ });
168
+ child.on("close", (code) => {
169
+ if (code === 0) {
170
+ resolvePromise();
171
+ }
172
+ else {
173
+ rejectPromise(new Error(`ffmpeg demux exited ${code}: ${stderr.slice(-400)}`));
174
+ }
175
+ });
176
+ });
177
+ }
178
+ //# sourceMappingURL=speech.js.map