@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/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 active = time >= timing.start && time < timing.end;
254
+ const hold = transitionHoldFor(clip, timing, transitionWindows);
255
+ const active = time >= timing.start && time < timing.end + hold;
182
256
  if (clip instanceof HTMLElement && !(clip instanceof HTMLAudioElement)) {
183
257
  if (clip.getAttribute("data-vf-timeline-proxy") === "video") {
184
258
  if (active) activeVideoProxy = clip;
@@ -186,6 +260,8 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
186
260
  setVisibility(clip, active);
187
261
  }
188
262
  if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
263
+ if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
264
+ if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
189
265
  }
190
266
  if (clip instanceof HTMLMediaElement) {
191
267
  applyMediaState(clip);