@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.
- package/README.md +19 -1
- package/SKILL.director.md +83 -2
- package/SKILL.platform.md +18 -3
- package/demo/dist/app.js +191 -65
- package/dist/src/app.js +69 -5
- package/dist/src/cli.js +424 -10
- package/dist/src/composition-runtime.js +77 -1
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +178 -0
- package/dist/src/editor-chat.js +5 -2
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +243 -0
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +77 -0
- package/dist/src/services/providers.js +38 -431
- package/dist/src/services/speech.js +629 -0
- package/package.json +1 -1
|
@@ -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
|