@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
package/dist/src/cli.js
CHANGED
|
@@ -9,7 +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";
|
|
14
|
+
import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
|
|
15
|
+
import { inferSpeechProviderForVoice } from "./services/speech.js";
|
|
13
16
|
import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
|
|
14
17
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
15
18
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
@@ -140,6 +143,12 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
140
143
|
zoom-out|pan-left|pan-right|pan-up|pan-down|
|
|
141
144
|
zoom-in-left|zoom-in-right
|
|
142
145
|
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
146
|
+
--transition <preset> Scene transition INTO this clip (the previous
|
|
147
|
+
butt-cut clip is held beneath it automatically):
|
|
148
|
+
crossfade|fade-black|slide-left|slide-right|
|
|
149
|
+
slide-up|slide-down|wipe-left|wipe-right|wipe-up|
|
|
150
|
+
wipe-down|zoom-in|zoom-out|circle-open
|
|
151
|
+
--transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
|
|
143
152
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
144
153
|
original vs decomposed (caption-free),
|
|
145
154
|
non-billing (alias: ghostcut)
|
|
@@ -160,6 +169,41 @@ Clip hunting (the third library — mine long-form video into a reusable clip st
|
|
|
160
169
|
clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
|
|
161
170
|
clips export <ids...> --to <dir> Copy clip mp4s + metadata out
|
|
162
171
|
(run 'vidfarm clips' for the full clips help)
|
|
172
|
+
|
|
173
|
+
Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
|
|
174
|
+
tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
|
|
175
|
+
The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
|
|
176
|
+
--style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
|
|
177
|
+
(e.g. "calm, warm bedtime narrator", "excited sports announcer")
|
|
178
|
+
--voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…).
|
|
179
|
+
The provider is inferred from the voice when --provider is omitted;
|
|
180
|
+
a voice from the wrong provider family is rejected with a clear error.
|
|
181
|
+
--out <file> Output audio path (default tts-<id>.mp3|wav)
|
|
182
|
+
--provider <p> --model <m> --format mp3|wav
|
|
183
|
+
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
|
|
184
|
+
stt <file|url> Video or audio → transcript (alias: transcribe). LOCAL: local ffmpeg demux +
|
|
185
|
+
Returns BOTH formats: the simple subtitle your provider key (gemini labels
|
|
186
|
+
version (plain text + timed SRT cues) and speakers; openai/openrouter give
|
|
187
|
+
the advanced multi-speaker version a plain transcript)
|
|
188
|
+
(speaker-labeled timed segments).
|
|
189
|
+
--out <base|.json|.srt|.txt> Write transcript files — bare base writes all three
|
|
190
|
+
--no-diarize Skip speaker attribution (plain transcript only)
|
|
191
|
+
--language <code> Expected language hint (e.g. en, es)
|
|
192
|
+
--prompt "<context>" Domain/vocabulary hint for the transcriber
|
|
193
|
+
--provider <p> --model <m>
|
|
194
|
+
--cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
|
|
195
|
+
(a local file uploads to your temp folder first — 30-day TTL)
|
|
196
|
+
|
|
197
|
+
Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composition):
|
|
198
|
+
captions generate <dir> Transcribe the narration & lay down animated LOCAL: local ffmpeg demux +
|
|
199
|
+
caption cue layers (openai = real word your provider key; writes
|
|
200
|
+
timestamps; gemini/openrouter estimated). composition.html on disk
|
|
201
|
+
--style <preset> spotlight|karaoke|word-pop|stack-up|neon|bounce (default spotlight)
|
|
202
|
+
--audio <file|url> | --srt <file> | --text "<script>" Pick the transcript source
|
|
203
|
+
--max-words <n> --active-color <css> --uppercase … (run 'vidfarm captions' for all flags)
|
|
204
|
+
captions style <dir> Restyle existing cues (same text/timings)
|
|
205
|
+
captions list <dir> Show the current animated caption cues [--json]
|
|
206
|
+
captions clear <dir> Remove all animated caption layers
|
|
163
207
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
164
208
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
165
209
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -252,6 +296,15 @@ Publish-mode options:
|
|
|
252
296
|
--message <text> Message attached to the published version snapshot
|
|
253
297
|
--no-snapshot Only update the working copy; skip the version snapshot
|
|
254
298
|
`;
|
|
299
|
+
// ANSI palette — declared BEFORE main() is invoked because command handlers may
|
|
300
|
+
// reference these synchronously (before their first await) while the rest of
|
|
301
|
+
// this module is still evaluating.
|
|
302
|
+
const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
|
|
303
|
+
const BOLD = "\x1b[1m";
|
|
304
|
+
const DIM = "\x1b[2m";
|
|
305
|
+
const GREEN = "\x1b[32m";
|
|
306
|
+
const RED = "\x1b[31m";
|
|
307
|
+
const RESET = "\x1b[0m";
|
|
255
308
|
void main().catch(async (error) => {
|
|
256
309
|
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
257
310
|
// Report only unexpected crashes (bugs), never the deliberate user-facing
|
|
@@ -314,6 +367,13 @@ async function main() {
|
|
|
314
367
|
case "generate":
|
|
315
368
|
await runGenerateCommand(rest);
|
|
316
369
|
return;
|
|
370
|
+
case "tts":
|
|
371
|
+
await runTtsCommand(rest);
|
|
372
|
+
return;
|
|
373
|
+
case "stt":
|
|
374
|
+
case "transcribe":
|
|
375
|
+
await runSttCommand(rest);
|
|
376
|
+
return;
|
|
317
377
|
case "place":
|
|
318
378
|
await runPlaceCommand(rest);
|
|
319
379
|
return;
|
|
@@ -398,6 +458,9 @@ async function main() {
|
|
|
398
458
|
case "clips":
|
|
399
459
|
await runClipsCommand(rest);
|
|
400
460
|
return;
|
|
461
|
+
case "captions":
|
|
462
|
+
await runCaptionsCommand(rest);
|
|
463
|
+
return;
|
|
401
464
|
default:
|
|
402
465
|
if (!command.startsWith("-")) {
|
|
403
466
|
// Positional template id → default to booting the local editor.
|
|
@@ -407,16 +470,6 @@ async function main() {
|
|
|
407
470
|
throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
|
|
408
471
|
}
|
|
409
472
|
}
|
|
410
|
-
// ── REST plumbing shared by every wrapper command ───────────────────────────
|
|
411
|
-
// One code path for auth + host + query + JSON. This is what makes the devcli a
|
|
412
|
-
// thin shell over the REST API rather than a second implementation: the named
|
|
413
|
-
// commands below just assemble a method/path/body and hand it here.
|
|
414
|
-
const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
|
|
415
|
-
const BOLD = "\x1b[1m";
|
|
416
|
-
const DIM = "\x1b[2m";
|
|
417
|
-
const GREEN = "\x1b[32m";
|
|
418
|
-
const RED = "\x1b[31m";
|
|
419
|
-
const RESET = "\x1b[0m";
|
|
420
473
|
async function apiRequest(input) {
|
|
421
474
|
const url = new URL(input.path, input.host);
|
|
422
475
|
for (const [key, value] of Object.entries(input.query ?? {})) {
|
|
@@ -1957,6 +2010,8 @@ function placeMediaOnDisk(input) {
|
|
|
1957
2010
|
objectFit: input.objectFit,
|
|
1958
2011
|
kenBurns: input.kenBurns,
|
|
1959
2012
|
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
2013
|
+
transition: input.transition,
|
|
2014
|
+
transitionDuration: input.transitionDuration,
|
|
1960
2015
|
slug: input.slug
|
|
1961
2016
|
};
|
|
1962
2017
|
const result = input.replace
|
|
@@ -1988,6 +2043,8 @@ async function runGenerateCommand(argv) {
|
|
|
1988
2043
|
track: { type: "string" },
|
|
1989
2044
|
"ken-burns": { type: "string" },
|
|
1990
2045
|
"ken-burns-intensity": { type: "string" },
|
|
2046
|
+
transition: { type: "string" },
|
|
2047
|
+
"transition-duration": { type: "string" },
|
|
1991
2048
|
"layer-key": { type: "string" }
|
|
1992
2049
|
}
|
|
1993
2050
|
});
|
|
@@ -2061,6 +2118,8 @@ async function runGenerateCommand(argv) {
|
|
|
2061
2118
|
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
2062
2119
|
kenBurns: parsed.values["ken-burns"],
|
|
2063
2120
|
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
2121
|
+
transition: parsed.values.transition,
|
|
2122
|
+
transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
|
|
2064
2123
|
layerKey: parsed.values["layer-key"]
|
|
2065
2124
|
});
|
|
2066
2125
|
if (!ctx.json)
|
|
@@ -2075,6 +2134,357 @@ async function runGenerateCommand(argv) {
|
|
|
2075
2134
|
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
|
|
2076
2135
|
}
|
|
2077
2136
|
}
|
|
2137
|
+
// ── Speech: tts / stt ────────────────────────────────────────────────────────
|
|
2138
|
+
// LOCAL-FIRST like `clips scan`: both commands default to running on the
|
|
2139
|
+
// user's OWN AI key (GEMINI_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY or
|
|
2140
|
+
// the --*-key flags) via src/services/speech.ts — local ffmpeg demux, direct
|
|
2141
|
+
// provider call, no cloud job, no wallet. The REST primitive routes
|
|
2142
|
+
// (/api/v1/primitives/audio/speech|transcribe) are the explicit `--cloud`
|
|
2143
|
+
// backup. Speech cannot ride on a claude/codex CLI agent (no audio I/O), so
|
|
2144
|
+
// "local" means a raw provider key here, not the agent CLI.
|
|
2145
|
+
function speechKeyHint(kind) {
|
|
2146
|
+
const preference = kind === "stt"
|
|
2147
|
+
? "GEMINI_API_KEY (best — it can label speakers), OPENAI_API_KEY, or OPENROUTER_API_KEY"
|
|
2148
|
+
: "OPENAI_API_KEY, GEMINI_API_KEY, or OPENROUTER_API_KEY";
|
|
2149
|
+
return `No local AI key found. Set ${preference} (or pass --gemini-key/--openai-key/--openrouter-key), or run with --cloud to use your saved Vidfarm provider keys.`;
|
|
2150
|
+
}
|
|
2151
|
+
async function downloadUrlToFile(url, dest) {
|
|
2152
|
+
const res = await fetch(url);
|
|
2153
|
+
if (!res.ok || !res.body)
|
|
2154
|
+
throw new Error(`download failed (${res.status}) for ${url}`);
|
|
2155
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
2156
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
2157
|
+
}
|
|
2158
|
+
async function runTtsCommand(argv) {
|
|
2159
|
+
const parsed = parseArgs({
|
|
2160
|
+
args: argv,
|
|
2161
|
+
allowPositionals: true,
|
|
2162
|
+
options: {
|
|
2163
|
+
...commonOptions(),
|
|
2164
|
+
text: { type: "string" },
|
|
2165
|
+
style: { type: "string" },
|
|
2166
|
+
instructions: { type: "string" },
|
|
2167
|
+
voice: { type: "string" },
|
|
2168
|
+
provider: { type: "string" },
|
|
2169
|
+
model: { type: "string" },
|
|
2170
|
+
format: { type: "string" },
|
|
2171
|
+
out: { type: "string" },
|
|
2172
|
+
cloud: { type: "boolean", default: false },
|
|
2173
|
+
"gemini-key": { type: "string" },
|
|
2174
|
+
"openai-key": { type: "string" },
|
|
2175
|
+
"openrouter-key": { type: "string" },
|
|
2176
|
+
"no-wait": { type: "boolean", default: false },
|
|
2177
|
+
tracer: { type: "string" }
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
const text = (parsed.values.text ?? parsed.positionals.join(" ")).trim();
|
|
2181
|
+
if (!text)
|
|
2182
|
+
throw new Error('tts requires text: vidfarm tts "Welcome back to the channel" --style "calm, warm narrator".');
|
|
2183
|
+
const style = (parsed.values.instructions ?? parsed.values.style)?.trim() || undefined;
|
|
2184
|
+
const rawFormat = String(parsed.values.format ?? "mp3").toLowerCase();
|
|
2185
|
+
if (rawFormat !== "mp3" && rawFormat !== "wav")
|
|
2186
|
+
throw new Error("tts --format must be mp3 or wav.");
|
|
2187
|
+
const format = rawFormat;
|
|
2188
|
+
const voice = parsed.values.voice;
|
|
2189
|
+
const json = Boolean(parsed.values.json);
|
|
2190
|
+
if (!parsed.values.cloud) {
|
|
2191
|
+
// A Gemini voice preset ("Kore", "Puck", …) should find a Gemini-capable
|
|
2192
|
+
// key first — openrouter's default TTS model is Gemini-family too.
|
|
2193
|
+
const keyPreference = inferSpeechProviderForVoice(voice) === "gemini" ? ["gemini", "openrouter", "openai"] : ["openai", "gemini", "openrouter"];
|
|
2194
|
+
const auth = resolveLocalSpeechAuth(parsed.values, keyPreference, parsed.values.provider);
|
|
2195
|
+
if (!auth)
|
|
2196
|
+
throw new Error(speechKeyHint("tts"));
|
|
2197
|
+
if (!json)
|
|
2198
|
+
console.log(`${DIM}Generating speech locally on your ${auth.provider} key…${RESET}`);
|
|
2199
|
+
const result = await localGenerateSpeech({
|
|
2200
|
+
auth,
|
|
2201
|
+
text,
|
|
2202
|
+
voice,
|
|
2203
|
+
instructions: style,
|
|
2204
|
+
model: parsed.values.model,
|
|
2205
|
+
responseFormat: format
|
|
2206
|
+
});
|
|
2207
|
+
const extension = result.contentType.includes("wav") ? "wav" : "mp3";
|
|
2208
|
+
const outPath = path.resolve(process.cwd(), parsed.values.out ?? `tts-${Date.now().toString(36)}.${extension}`);
|
|
2209
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
2210
|
+
writeFileSync(outPath, result.bytes);
|
|
2211
|
+
if (json) {
|
|
2212
|
+
printJson({ ok: true, mode: "local", provider: result.provider, model: result.model, voice: voice ?? null, style: style ?? null, content_type: result.contentType, bytes: result.bytes.byteLength, out: outPath });
|
|
2213
|
+
}
|
|
2214
|
+
else {
|
|
2215
|
+
console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${result.provider}/${result.model}, ${Math.round(result.bytes.byteLength / 1024)}KB)${RESET}`);
|
|
2216
|
+
console.log(`${DIM}Get a shareable URL with: vidfarm upload "${outPath}" — then add it to a composition as an audio layer.${RESET}`);
|
|
2217
|
+
}
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
// BACKUP: the platform primitive route (saved provider keys, async job).
|
|
2221
|
+
const ctx = commonContext(parsed.values);
|
|
2222
|
+
const payload = { text, output_format: format };
|
|
2223
|
+
if (voice)
|
|
2224
|
+
payload.voice = voice;
|
|
2225
|
+
if (style)
|
|
2226
|
+
payload.instructions = style;
|
|
2227
|
+
if (parsed.values.provider)
|
|
2228
|
+
payload.provider = parsed.values.provider;
|
|
2229
|
+
if (parsed.values.model)
|
|
2230
|
+
payload.model = parsed.values.model;
|
|
2231
|
+
const tracer = parsed.values.tracer ?? `devcli-tts-${Date.now().toString(36)}`;
|
|
2232
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/speech", auth: ctx.auth, body: { tracer, payload } });
|
|
2233
|
+
assertApiOk(submit, "tts");
|
|
2234
|
+
const jobId = submit.json?.job_id;
|
|
2235
|
+
if (parsed.values["no-wait"] || !jobId) {
|
|
2236
|
+
if (!ctx.json && jobId)
|
|
2237
|
+
console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
|
|
2238
|
+
emitResult(submit, ctx.json);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (!ctx.json)
|
|
2242
|
+
console.log(`${DIM}Generating speech (${jobId})… polling every 5s.${RESET}`);
|
|
2243
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
2244
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
2245
|
+
if (!mediaUrl) {
|
|
2246
|
+
if (ctx.json) {
|
|
2247
|
+
printJson({ ok: false, job_id: jobId, status: String(job?.status ?? ""), job });
|
|
2248
|
+
}
|
|
2249
|
+
else {
|
|
2250
|
+
console.log(`${RED}TTS ${String(job?.status ?? "did not finish")} — no audio URL.${RESET}`);
|
|
2251
|
+
printJson(job);
|
|
2252
|
+
}
|
|
2253
|
+
process.exitCode = 1;
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const extension = /\.wav(\?|#|$)/i.test(mediaUrl) ? "wav" : "mp3";
|
|
2257
|
+
const outPath = path.resolve(process.cwd(), parsed.values.out ?? `tts-${jobId}.${extension}`);
|
|
2258
|
+
await downloadUrlToFile(mediaUrl, outPath);
|
|
2259
|
+
if (ctx.json) {
|
|
2260
|
+
printJson({ ok: true, mode: "cloud", job_id: jobId, tracer, audio_url: mediaUrl, out: outPath });
|
|
2261
|
+
}
|
|
2262
|
+
else {
|
|
2263
|
+
console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${mediaUrl})${RESET}`);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
function formatSpeechClock(totalSeconds) {
|
|
2267
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2268
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
2269
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
2270
|
+
}
|
|
2271
|
+
// Print/persist BOTH transcript formats: the simple subtitle version (plain
|
|
2272
|
+
// text + SRT cues) and the advanced multi-speaker version (speaker-attributed
|
|
2273
|
+
// segments → .json). --out picks one via extension or writes all three.
|
|
2274
|
+
function emitSttResult(input) {
|
|
2275
|
+
const written = [];
|
|
2276
|
+
if (input.out) {
|
|
2277
|
+
const explicitExtension = /\.(json|srt|txt)$/i.exec(input.out)?.[1]?.toLowerCase() ?? null;
|
|
2278
|
+
const base = explicitExtension ? input.out.slice(0, -(explicitExtension.length + 1)) : input.out;
|
|
2279
|
+
const targets = explicitExtension ? [explicitExtension] : ["json", "srt", "txt"];
|
|
2280
|
+
for (const kind of targets) {
|
|
2281
|
+
const dest = path.resolve(process.cwd(), `${base}.${kind}`);
|
|
2282
|
+
if (kind === "json") {
|
|
2283
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
2284
|
+
writeFileSync(dest, `${JSON.stringify({
|
|
2285
|
+
source: input.source,
|
|
2286
|
+
mode: input.mode,
|
|
2287
|
+
provider: input.provider,
|
|
2288
|
+
model: input.model,
|
|
2289
|
+
language: input.result.language,
|
|
2290
|
+
diarization: input.result.diarization,
|
|
2291
|
+
text: input.result.text,
|
|
2292
|
+
speakers: [...new Set((input.result.segments ?? []).map((segment) => segment.speaker))],
|
|
2293
|
+
segments: input.result.segments ?? []
|
|
2294
|
+
}, null, 2)}\n`);
|
|
2295
|
+
written.push(dest);
|
|
2296
|
+
}
|
|
2297
|
+
else if (kind === "srt") {
|
|
2298
|
+
if (input.result.srt) {
|
|
2299
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
2300
|
+
writeFileSync(dest, input.result.srt);
|
|
2301
|
+
written.push(dest);
|
|
2302
|
+
}
|
|
2303
|
+
else if (explicitExtension === "srt") {
|
|
2304
|
+
console.log(`${RED}No timed segments — cannot write ${dest}. The plain transcript is still available (--out <base>.txt).${RESET}`);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
else {
|
|
2308
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
2309
|
+
writeFileSync(dest, `${input.result.text ?? ""}\n`);
|
|
2310
|
+
written.push(dest);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
if (input.json) {
|
|
2315
|
+
printJson({
|
|
2316
|
+
ok: true,
|
|
2317
|
+
mode: input.mode,
|
|
2318
|
+
provider: input.provider,
|
|
2319
|
+
model: input.model,
|
|
2320
|
+
...(input.jobId ? { job_id: input.jobId } : {}),
|
|
2321
|
+
source: input.source,
|
|
2322
|
+
language: input.result.language,
|
|
2323
|
+
diarization: input.result.diarization,
|
|
2324
|
+
text: input.result.text,
|
|
2325
|
+
segments: input.result.segments ?? [],
|
|
2326
|
+
srt: input.result.srt,
|
|
2327
|
+
written
|
|
2328
|
+
});
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
if (input.result.segments?.length && input.result.diarization === "diarized") {
|
|
2332
|
+
for (const segment of input.result.segments) {
|
|
2333
|
+
const at = typeof segment.start_sec === "number" ? `${DIM}[${formatSpeechClock(segment.start_sec)}]${RESET} ` : "";
|
|
2334
|
+
console.log(`${at}${segment.speaker}: ${segment.text}`);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
else {
|
|
2338
|
+
console.log(input.result.text || "(no speech detected)");
|
|
2339
|
+
}
|
|
2340
|
+
for (const dest of written) {
|
|
2341
|
+
console.log(`${GREEN}Wrote ${dest}${RESET}`);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
async function runSttCommand(argv) {
|
|
2345
|
+
const parsed = parseArgs({
|
|
2346
|
+
args: argv,
|
|
2347
|
+
allowPositionals: true,
|
|
2348
|
+
options: {
|
|
2349
|
+
...commonOptions(),
|
|
2350
|
+
"no-diarize": { type: "boolean", default: false },
|
|
2351
|
+
language: { type: "string" },
|
|
2352
|
+
prompt: { type: "string" },
|
|
2353
|
+
provider: { type: "string" },
|
|
2354
|
+
model: { type: "string" },
|
|
2355
|
+
out: { type: "string" },
|
|
2356
|
+
cloud: { type: "boolean", default: false },
|
|
2357
|
+
"gemini-key": { type: "string" },
|
|
2358
|
+
"openai-key": { type: "string" },
|
|
2359
|
+
"openrouter-key": { type: "string" },
|
|
2360
|
+
"no-wait": { type: "boolean", default: false },
|
|
2361
|
+
tracer: { type: "string" },
|
|
2362
|
+
folder: { type: "string" }
|
|
2363
|
+
}
|
|
2364
|
+
});
|
|
2365
|
+
const target = parsed.positionals[0];
|
|
2366
|
+
if (!target)
|
|
2367
|
+
throw new Error("stt requires a source: `vidfarm stt <video-or-audio file|url>`.");
|
|
2368
|
+
const diarize = !parsed.values["no-diarize"];
|
|
2369
|
+
const json = Boolean(parsed.values.json);
|
|
2370
|
+
const language = parsed.values.language?.trim() || undefined;
|
|
2371
|
+
const prompt = parsed.values.prompt?.trim() || undefined;
|
|
2372
|
+
if (!parsed.values.cloud) {
|
|
2373
|
+
const auth = resolveLocalSpeechAuth(parsed.values, ["gemini", "openai", "openrouter"], parsed.values.provider);
|
|
2374
|
+
if (!auth)
|
|
2375
|
+
throw new Error(speechKeyHint("stt"));
|
|
2376
|
+
if (diarize && auth.provider !== "gemini" && !json) {
|
|
2377
|
+
console.log(`${DIM}Note: speaker labels need a Gemini key; ${auth.provider} returns a plain single-speaker transcript.${RESET}`);
|
|
2378
|
+
}
|
|
2379
|
+
if (!json)
|
|
2380
|
+
console.log(`${DIM}Transcribing locally on your ${auth.provider} key (local ffmpeg demux)…${RESET}`);
|
|
2381
|
+
const audio = await loadSpeechAudioLocally(target);
|
|
2382
|
+
if (audio.bytes.byteLength > MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES) {
|
|
2383
|
+
throw new Error(`Audio is too large to transcribe (${Math.round(audio.bytes.byteLength / (1024 * 1024))}MB > 20MB, roughly 40 minutes of speech). Trim the source first.`);
|
|
2384
|
+
}
|
|
2385
|
+
const result = await localTranscribeSpeech({
|
|
2386
|
+
auth,
|
|
2387
|
+
audio: audio.bytes,
|
|
2388
|
+
contentType: audio.contentType,
|
|
2389
|
+
model: parsed.values.model,
|
|
2390
|
+
prompt,
|
|
2391
|
+
language,
|
|
2392
|
+
diarize
|
|
2393
|
+
});
|
|
2394
|
+
emitSttResult({
|
|
2395
|
+
json,
|
|
2396
|
+
out: parsed.values.out,
|
|
2397
|
+
source: target,
|
|
2398
|
+
mode: "local",
|
|
2399
|
+
provider: result.provider,
|
|
2400
|
+
model: result.model,
|
|
2401
|
+
result: {
|
|
2402
|
+
text: result.text,
|
|
2403
|
+
language: result.language,
|
|
2404
|
+
diarization: result.diarization,
|
|
2405
|
+
segments: result.segments,
|
|
2406
|
+
srt: result.srt
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
// BACKUP: the platform primitive route. URLs pass through; local files
|
|
2412
|
+
// upload to the ephemeral temp store first (30-day TTL).
|
|
2413
|
+
const ctx = commonContext(parsed.values);
|
|
2414
|
+
const sourceUrl = /^https?:\/\//i.test(target)
|
|
2415
|
+
? target
|
|
2416
|
+
: await uploadLocalToTempStore(ctx, path.resolve(process.cwd(), target), parsed.values.folder ?? "temp");
|
|
2417
|
+
const payload = { source_url: sourceUrl, diarize };
|
|
2418
|
+
if (language)
|
|
2419
|
+
payload.language = language;
|
|
2420
|
+
if (prompt)
|
|
2421
|
+
payload.prompt = prompt;
|
|
2422
|
+
if (parsed.values.provider)
|
|
2423
|
+
payload.provider = parsed.values.provider;
|
|
2424
|
+
if (parsed.values.model)
|
|
2425
|
+
payload.model = parsed.values.model;
|
|
2426
|
+
const tracer = parsed.values.tracer ?? `devcli-stt-${Date.now().toString(36)}`;
|
|
2427
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/transcribe", auth: ctx.auth, body: { tracer, payload } });
|
|
2428
|
+
assertApiOk(submit, "stt");
|
|
2429
|
+
const jobId = submit.json?.job_id;
|
|
2430
|
+
if (parsed.values["no-wait"] || !jobId) {
|
|
2431
|
+
if (!ctx.json && jobId)
|
|
2432
|
+
console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
|
|
2433
|
+
emitResult(submit, ctx.json);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (!ctx.json)
|
|
2437
|
+
console.log(`${DIM}Transcribing (${jobId})… polling every 5s.${RESET}`);
|
|
2438
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
2439
|
+
const status = String(job?.status ?? "");
|
|
2440
|
+
const rawResult = (job?.result && typeof job.result === "object") ? job.result : {};
|
|
2441
|
+
const output = (rawResult.output && typeof rawResult.output === "object") ? rawResult.output : rawResult;
|
|
2442
|
+
if (status !== "succeeded" && typeof output.text !== "string") {
|
|
2443
|
+
if (ctx.json) {
|
|
2444
|
+
printJson({ ok: false, job_id: jobId, status, job });
|
|
2445
|
+
}
|
|
2446
|
+
else {
|
|
2447
|
+
console.log(`${RED}Transcription ${status || "did not finish"}.${RESET}`);
|
|
2448
|
+
printJson(job);
|
|
2449
|
+
}
|
|
2450
|
+
process.exitCode = 1;
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
// Long transcripts stay artifact-only; hydrate the full version from
|
|
2454
|
+
// transcript.json / transcript.srt when the inline copy was truncated.
|
|
2455
|
+
let text = typeof output.text === "string" ? output.text : "";
|
|
2456
|
+
let segments = Array.isArray(output.segments) ? output.segments : null;
|
|
2457
|
+
let srt = typeof output.subtitles?.srt === "string" ? output.subtitles.srt : null;
|
|
2458
|
+
const jsonUrl = typeof output.transcript?.json_url === "string" ? output.transcript.json_url : null;
|
|
2459
|
+
if ((output.text_truncated === true || !segments) && jsonUrl) {
|
|
2460
|
+
const hydrated = await fetch(jsonUrl).then((res) => (res.ok ? res.json() : null)).catch(() => null);
|
|
2461
|
+
if (hydrated && typeof hydrated === "object") {
|
|
2462
|
+
if (typeof hydrated.text === "string")
|
|
2463
|
+
text = hydrated.text;
|
|
2464
|
+
if (Array.isArray(hydrated.segments))
|
|
2465
|
+
segments = hydrated.segments;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
if (!srt && typeof output.subtitles?.srt_url === "string") {
|
|
2469
|
+
srt = await fetch(output.subtitles.srt_url).then((res) => (res.ok ? res.text() : null)).catch(() => null);
|
|
2470
|
+
}
|
|
2471
|
+
emitSttResult({
|
|
2472
|
+
json: ctx.json,
|
|
2473
|
+
out: parsed.values.out,
|
|
2474
|
+
source: target,
|
|
2475
|
+
mode: "cloud",
|
|
2476
|
+
provider: typeof output.transcript?.provider === "string" ? output.transcript.provider : null,
|
|
2477
|
+
model: typeof output.transcript?.model === "string" ? output.transcript.model : null,
|
|
2478
|
+
jobId,
|
|
2479
|
+
result: {
|
|
2480
|
+
text,
|
|
2481
|
+
language: typeof output.language === "string" ? output.language : null,
|
|
2482
|
+
diarization: typeof output.diarization === "string" ? output.diarization : "none",
|
|
2483
|
+
segments,
|
|
2484
|
+
srt
|
|
2485
|
+
}
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2078
2488
|
async function runPlaceCommand(argv) {
|
|
2079
2489
|
const parsed = parseArgs({
|
|
2080
2490
|
args: argv,
|
|
@@ -2094,6 +2504,8 @@ async function runPlaceCommand(argv) {
|
|
|
2094
2504
|
"object-fit": { type: "string" },
|
|
2095
2505
|
"ken-burns": { type: "string" },
|
|
2096
2506
|
"ken-burns-intensity": { type: "string" },
|
|
2507
|
+
transition: { type: "string" },
|
|
2508
|
+
"transition-duration": { type: "string" },
|
|
2097
2509
|
"layer-key": { type: "string" },
|
|
2098
2510
|
slug: { type: "string" },
|
|
2099
2511
|
// Local file support: where a serve box serves /storage from, and which
|
|
@@ -2136,6 +2548,8 @@ async function runPlaceCommand(argv) {
|
|
|
2136
2548
|
objectFit: parsed.values["object-fit"],
|
|
2137
2549
|
kenBurns: parsed.values["ken-burns"],
|
|
2138
2550
|
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
2551
|
+
transition: parsed.values.transition,
|
|
2552
|
+
transitionDuration: num(parsed.values["transition-duration"]),
|
|
2139
2553
|
layerKey: parsed.values["layer-key"],
|
|
2140
2554
|
slug: parsed.values.slug
|
|
2141
2555
|
});
|
|
@@ -171,14 +171,88 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Scene transitions: a [data-transition] clip animates in (vf-tr-*
|
|
175
|
+
// keyframes) over its first data-transition-duration seconds, ON TOP of the
|
|
176
|
+
// previous clip on its track — so that previous clip must stay active for
|
|
177
|
+
// the transition window. Publish materializes this overlap into
|
|
178
|
+
// data-duration (normalizePublishHtml); the preview grants it live so
|
|
179
|
+
// butt-cut drafts preview exactly like the render.
|
|
180
|
+
function collectTransitionWindows() {
|
|
181
|
+
const windows = [];
|
|
182
|
+
for (const clip of clips) {
|
|
183
|
+
if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) continue;
|
|
184
|
+
if (!clip.hasAttribute("data-transition")) continue;
|
|
185
|
+
const timing = readTiming(clip);
|
|
186
|
+
let transitionDuration = Number(clip.getAttribute("data-transition-duration"));
|
|
187
|
+
if (!Number.isFinite(transitionDuration) || transitionDuration <= 0) transitionDuration = 0.5;
|
|
188
|
+
windows.push({
|
|
189
|
+
track: Number(clip.getAttribute("data-track-index")) || 0,
|
|
190
|
+
start: timing.start,
|
|
191
|
+
duration: Math.min(transitionDuration, timing.duration)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return windows;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function transitionHoldFor(clip, timing, windows) {
|
|
198
|
+
if (!windows.length) return 0;
|
|
199
|
+
if (!(clip instanceof HTMLElement) || clip instanceof HTMLAudioElement) return 0;
|
|
200
|
+
const track = Number(clip.getAttribute("data-track-index")) || 0;
|
|
201
|
+
let hold = 0;
|
|
202
|
+
for (const window of windows) {
|
|
203
|
+
if (window.track !== track) continue;
|
|
204
|
+
// Nominal-end adjacency: already-materialized holds (published HTML)
|
|
205
|
+
// shift this clip's end past the next start, fail the check, and are
|
|
206
|
+
// simply not re-granted — no double extension either way.
|
|
207
|
+
if (Math.abs(window.start - timing.end) > 0.05) continue;
|
|
208
|
+
if (window.duration > hold) hold = window.duration;
|
|
209
|
+
}
|
|
210
|
+
return hold;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function syncTransitionClip(clip, timing) {
|
|
214
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
215
|
+
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
216
|
+
let anims = [];
|
|
217
|
+
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
218
|
+
for (const anim of anims) {
|
|
219
|
+
if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-tr-") !== 0) continue;
|
|
220
|
+
try {
|
|
221
|
+
anim.pause();
|
|
222
|
+
anim.currentTime = offsetMs;
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Animated caption words carry vf-cap-* keyframes whose inline delay/
|
|
228
|
+
// duration ARE the word windows relative to the layer start, so unlike Ken
|
|
229
|
+
// Burns there is nothing to normalize — the scrub is simply the absolute
|
|
230
|
+
// offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
|
|
231
|
+
// render producer's CSS adapter exactly.
|
|
232
|
+
function syncCaptionClip(clip, timing) {
|
|
233
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
234
|
+
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
235
|
+
let anims = [];
|
|
236
|
+
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
237
|
+
for (const anim of anims) {
|
|
238
|
+
if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-cap-") !== 0) continue;
|
|
239
|
+
try {
|
|
240
|
+
anim.pause();
|
|
241
|
+
anim.currentTime = offsetMs;
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
174
246
|
function apply(nextTime, options = {}) {
|
|
175
247
|
const forceSeek = Boolean(options.forceSeek);
|
|
176
248
|
const fromClock = Boolean(options.fromClock);
|
|
177
249
|
time = clampTime(nextTime);
|
|
178
250
|
let activeVideoProxy = null;
|
|
251
|
+
const transitionWindows = collectTransitionWindows();
|
|
179
252
|
for (const clip of clips) {
|
|
180
253
|
const timing = readTiming(clip);
|
|
181
|
-
const
|
|
254
|
+
const hold = transitionHoldFor(clip, timing, transitionWindows);
|
|
255
|
+
const active = time >= timing.start && time < timing.end + hold;
|
|
182
256
|
if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
|
|
183
257
|
if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
|
|
184
258
|
if (active) activeVideoProxy = clip;
|
|
@@ -186,6 +260,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
186
260
|
setVisibility(clip, active);
|
|
187
261
|
}
|
|
188
262
|
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
263
|
+
if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
|
|
264
|
+
if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
|
|
189
265
|
}
|
|
190
266
|
if (clip instanceof HTMLMediaElement) {
|
|
191
267
|
applyMediaState(clip);
|