@mevdragon/vidfarm-devcli 0.9.0 → 0.11.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
@@ -10,6 +10,7 @@ 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
12
  import { runClipsCommand } from "./devcli/clips.js";
13
+ import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
13
14
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
14
15
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
15
16
  // `serve` command boots the FULL editor locally (single origin, disk-backed
@@ -144,16 +145,44 @@ Generate AI media and drop it on the timeline (for local coding agents):
144
145
  original vs decomposed (caption-free),
145
146
  non-billing (alias: ghostcut)
146
147
 
147
- Clip curation (the third library — mine long-form video into a reusable clip store):
148
- clips scan <video-path> Detect scenes tag + transcribe (Gemini) LOCAL: ffmpeg + BYOK Gemini
149
- → embed → persist to ~/.vidfarm/clips.db. (cloud: POST /clips/scan)
150
- --tier flash|flash-lite Gemini tier (default flash-lite, cheapest)
148
+ Clip hunting (the third library — mine long-form video into a reusable clip store):
149
+ clips scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
150
+ Detect tag/transcribe → embed → persist your local claude/codex CLI
151
+ to ~/.vidfarm/clips.db. (keys are fallback)
152
+ --range "MM:SS-MM:SS" Only hunt these source windows (repeatable; big cost saver)
153
+ --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
154
+ --aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
155
+ --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
156
+ --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /clips/scan (+ poll)
157
+ (uploads to your temp folder — 30-day TTL — bills AWS compute only)
151
158
  --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
152
159
  clips list List the local clip library → GET /clips
153
160
  clips search "<text>" Hybrid structured + semantic search → POST /clips/search
154
161
  clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
155
162
  clips export <ids...> --to <dir> Copy clip mp4s + metadata out
156
163
  (run 'vidfarm clips' for the full clips help)
164
+
165
+ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
166
+ tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
167
+ The VOICE STYLE is promptable via --style. OPENROUTER_API_KEY, no cloud job
168
+ --style "<direction>" Voice-style prompt: tone/pacing/accent/emotion/persona
169
+ (e.g. "calm, warm bedtime narrator", "excited sports announcer")
170
+ --voice <name> Provider voice preset (openai: alloy/ash/coral…; gemini: Kore/Puck…)
171
+ --out <file> Output audio path (default tts-<id>.mp3|wav)
172
+ --provider <p> --model <m> --format mp3|wav
173
+ --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/speech (+ poll)
174
+ stt <file|url> Video or audio → transcript (alias: transcribe). LOCAL: local ffmpeg demux +
175
+ Returns BOTH formats: the simple subtitle your provider key (gemini labels
176
+ version (plain text + timed SRT cues) and speakers; openai/openrouter give
177
+ the advanced multi-speaker version a plain transcript)
178
+ (speaker-labeled timed segments).
179
+ --out <base|.json|.srt|.txt> Write transcript files — bare base writes all three
180
+ --no-diarize Skip speaker attribution (plain transcript only)
181
+ --language <code> Expected language hint (e.g. en, es)
182
+ --prompt "<context>" Domain/vocabulary hint for the transcriber
183
+ --provider <p> --model <m>
184
+ --cloud BACKUP: saved-key platform job → POST /api/v1/primitives/audio/transcribe (+ poll)
185
+ (a local file uploads to your temp folder first — 30-day TTL)
157
186
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
158
187
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
159
188
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -246,6 +275,15 @@ Publish-mode options:
246
275
  --message <text> Message attached to the published version snapshot
247
276
  --no-snapshot Only update the working copy; skip the version snapshot
248
277
  `;
278
+ // ANSI palette — declared BEFORE main() is invoked because command handlers may
279
+ // reference these synchronously (before their first await) while the rest of
280
+ // this module is still evaluating.
281
+ const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
282
+ const BOLD = "\x1b[1m";
283
+ const DIM = "\x1b[2m";
284
+ const GREEN = "\x1b[32m";
285
+ const RED = "\x1b[31m";
286
+ const RESET = "\x1b[0m";
249
287
  void main().catch(async (error) => {
250
288
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
251
289
  // Report only unexpected crashes (bugs), never the deliberate user-facing
@@ -308,6 +346,13 @@ async function main() {
308
346
  case "generate":
309
347
  await runGenerateCommand(rest);
310
348
  return;
349
+ case "tts":
350
+ await runTtsCommand(rest);
351
+ return;
352
+ case "stt":
353
+ case "transcribe":
354
+ await runSttCommand(rest);
355
+ return;
311
356
  case "place":
312
357
  await runPlaceCommand(rest);
313
358
  return;
@@ -401,16 +446,6 @@ async function main() {
401
446
  throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
402
447
  }
403
448
  }
404
- // ── REST plumbing shared by every wrapper command ───────────────────────────
405
- // One code path for auth + host + query + JSON. This is what makes the devcli a
406
- // thin shell over the REST API rather than a second implementation: the named
407
- // commands below just assemble a method/path/body and hand it here.
408
- const FRONTEND = "\x1b[1m\x1b[36m"; // bold cyan — reserved for openable URLs
409
- const BOLD = "\x1b[1m";
410
- const DIM = "\x1b[2m";
411
- const GREEN = "\x1b[32m";
412
- const RED = "\x1b[31m";
413
- const RESET = "\x1b[0m";
414
449
  async function apiRequest(input) {
415
450
  const url = new URL(input.path, input.host);
416
451
  for (const [key, value] of Object.entries(input.query ?? {})) {
@@ -2069,6 +2104,354 @@ async function runGenerateCommand(argv) {
2069
2104
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
2070
2105
  }
2071
2106
  }
2107
+ // ── Speech: tts / stt ────────────────────────────────────────────────────────
2108
+ // LOCAL-FIRST like `clips scan`: both commands default to running on the
2109
+ // user's OWN AI key (GEMINI_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY or
2110
+ // the --*-key flags) via src/services/speech.ts — local ffmpeg demux, direct
2111
+ // provider call, no cloud job, no wallet. The REST primitive routes
2112
+ // (/api/v1/primitives/audio/speech|transcribe) are the explicit `--cloud`
2113
+ // backup. Speech cannot ride on a claude/codex CLI agent (no audio I/O), so
2114
+ // "local" means a raw provider key here, not the agent CLI.
2115
+ function speechKeyHint(kind) {
2116
+ const preference = kind === "stt"
2117
+ ? "GEMINI_API_KEY (best — it can label speakers), OPENAI_API_KEY, or OPENROUTER_API_KEY"
2118
+ : "OPENAI_API_KEY, GEMINI_API_KEY, or OPENROUTER_API_KEY";
2119
+ 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.`;
2120
+ }
2121
+ async function downloadUrlToFile(url, dest) {
2122
+ const res = await fetch(url);
2123
+ if (!res.ok || !res.body)
2124
+ throw new Error(`download failed (${res.status}) for ${url}`);
2125
+ mkdirSync(path.dirname(dest), { recursive: true });
2126
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
2127
+ }
2128
+ async function runTtsCommand(argv) {
2129
+ const parsed = parseArgs({
2130
+ args: argv,
2131
+ allowPositionals: true,
2132
+ options: {
2133
+ ...commonOptions(),
2134
+ text: { type: "string" },
2135
+ style: { type: "string" },
2136
+ instructions: { type: "string" },
2137
+ voice: { type: "string" },
2138
+ provider: { type: "string" },
2139
+ model: { type: "string" },
2140
+ format: { type: "string" },
2141
+ out: { type: "string" },
2142
+ cloud: { type: "boolean", default: false },
2143
+ "gemini-key": { type: "string" },
2144
+ "openai-key": { type: "string" },
2145
+ "openrouter-key": { type: "string" },
2146
+ "no-wait": { type: "boolean", default: false },
2147
+ tracer: { type: "string" }
2148
+ }
2149
+ });
2150
+ const text = (parsed.values.text ?? parsed.positionals.join(" ")).trim();
2151
+ if (!text)
2152
+ throw new Error('tts requires text: vidfarm tts "Welcome back to the channel" --style "calm, warm narrator".');
2153
+ const style = (parsed.values.instructions ?? parsed.values.style)?.trim() || undefined;
2154
+ const rawFormat = String(parsed.values.format ?? "mp3").toLowerCase();
2155
+ if (rawFormat !== "mp3" && rawFormat !== "wav")
2156
+ throw new Error("tts --format must be mp3 or wav.");
2157
+ const format = rawFormat;
2158
+ const voice = parsed.values.voice;
2159
+ const json = Boolean(parsed.values.json);
2160
+ if (!parsed.values.cloud) {
2161
+ const auth = resolveLocalSpeechAuth(parsed.values, ["openai", "gemini", "openrouter"], parsed.values.provider);
2162
+ if (!auth)
2163
+ throw new Error(speechKeyHint("tts"));
2164
+ if (!json)
2165
+ console.log(`${DIM}Generating speech locally on your ${auth.provider} key…${RESET}`);
2166
+ const result = await localGenerateSpeech({
2167
+ auth,
2168
+ text,
2169
+ voice,
2170
+ instructions: style,
2171
+ model: parsed.values.model,
2172
+ responseFormat: format
2173
+ });
2174
+ const extension = result.contentType.includes("wav") ? "wav" : "mp3";
2175
+ const outPath = path.resolve(process.cwd(), parsed.values.out ?? `tts-${Date.now().toString(36)}.${extension}`);
2176
+ mkdirSync(path.dirname(outPath), { recursive: true });
2177
+ writeFileSync(outPath, result.bytes);
2178
+ if (json) {
2179
+ 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 });
2180
+ }
2181
+ else {
2182
+ console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${result.provider}/${result.model}, ${Math.round(result.bytes.byteLength / 1024)}KB)${RESET}`);
2183
+ console.log(`${DIM}Get a shareable URL with: vidfarm upload "${outPath}" — then add it to a composition as an audio layer.${RESET}`);
2184
+ }
2185
+ return;
2186
+ }
2187
+ // BACKUP: the platform primitive route (saved provider keys, async job).
2188
+ const ctx = commonContext(parsed.values);
2189
+ const payload = { text, output_format: format };
2190
+ if (voice)
2191
+ payload.voice = voice;
2192
+ if (style)
2193
+ payload.instructions = style;
2194
+ if (parsed.values.provider)
2195
+ payload.provider = parsed.values.provider;
2196
+ if (parsed.values.model)
2197
+ payload.model = parsed.values.model;
2198
+ const tracer = parsed.values.tracer ?? `devcli-tts-${Date.now().toString(36)}`;
2199
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/speech", auth: ctx.auth, body: { tracer, payload } });
2200
+ assertApiOk(submit, "tts");
2201
+ const jobId = submit.json?.job_id;
2202
+ if (parsed.values["no-wait"] || !jobId) {
2203
+ if (!ctx.json && jobId)
2204
+ 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}`);
2205
+ emitResult(submit, ctx.json);
2206
+ return;
2207
+ }
2208
+ if (!ctx.json)
2209
+ console.log(`${DIM}Generating speech (${jobId})… polling every 5s.${RESET}`);
2210
+ const job = await pollPrimitiveJob(ctx, jobId);
2211
+ const mediaUrl = resolveJobMediaUrl(job);
2212
+ if (!mediaUrl) {
2213
+ if (ctx.json) {
2214
+ printJson({ ok: false, job_id: jobId, status: String(job?.status ?? ""), job });
2215
+ }
2216
+ else {
2217
+ console.log(`${RED}TTS ${String(job?.status ?? "did not finish")} — no audio URL.${RESET}`);
2218
+ printJson(job);
2219
+ }
2220
+ process.exitCode = 1;
2221
+ return;
2222
+ }
2223
+ const extension = /\.wav(\?|#|$)/i.test(mediaUrl) ? "wav" : "mp3";
2224
+ const outPath = path.resolve(process.cwd(), parsed.values.out ?? `tts-${jobId}.${extension}`);
2225
+ await downloadUrlToFile(mediaUrl, outPath);
2226
+ if (ctx.json) {
2227
+ printJson({ ok: true, mode: "cloud", job_id: jobId, tracer, audio_url: mediaUrl, out: outPath });
2228
+ }
2229
+ else {
2230
+ console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${mediaUrl})${RESET}`);
2231
+ }
2232
+ }
2233
+ function formatSpeechClock(totalSeconds) {
2234
+ const minutes = Math.floor(totalSeconds / 60);
2235
+ const seconds = Math.floor(totalSeconds % 60);
2236
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
2237
+ }
2238
+ // Print/persist BOTH transcript formats: the simple subtitle version (plain
2239
+ // text + SRT cues) and the advanced multi-speaker version (speaker-attributed
2240
+ // segments → .json). --out picks one via extension or writes all three.
2241
+ function emitSttResult(input) {
2242
+ const written = [];
2243
+ if (input.out) {
2244
+ const explicitExtension = /\.(json|srt|txt)$/i.exec(input.out)?.[1]?.toLowerCase() ?? null;
2245
+ const base = explicitExtension ? input.out.slice(0, -(explicitExtension.length + 1)) : input.out;
2246
+ const targets = explicitExtension ? [explicitExtension] : ["json", "srt", "txt"];
2247
+ for (const kind of targets) {
2248
+ const dest = path.resolve(process.cwd(), `${base}.${kind}`);
2249
+ if (kind === "json") {
2250
+ mkdirSync(path.dirname(dest), { recursive: true });
2251
+ writeFileSync(dest, `${JSON.stringify({
2252
+ source: input.source,
2253
+ mode: input.mode,
2254
+ provider: input.provider,
2255
+ model: input.model,
2256
+ language: input.result.language,
2257
+ diarization: input.result.diarization,
2258
+ text: input.result.text,
2259
+ speakers: [...new Set((input.result.segments ?? []).map((segment) => segment.speaker))],
2260
+ segments: input.result.segments ?? []
2261
+ }, null, 2)}\n`);
2262
+ written.push(dest);
2263
+ }
2264
+ else if (kind === "srt") {
2265
+ if (input.result.srt) {
2266
+ mkdirSync(path.dirname(dest), { recursive: true });
2267
+ writeFileSync(dest, input.result.srt);
2268
+ written.push(dest);
2269
+ }
2270
+ else if (explicitExtension === "srt") {
2271
+ console.log(`${RED}No timed segments — cannot write ${dest}. The plain transcript is still available (--out <base>.txt).${RESET}`);
2272
+ }
2273
+ }
2274
+ else {
2275
+ mkdirSync(path.dirname(dest), { recursive: true });
2276
+ writeFileSync(dest, `${input.result.text ?? ""}\n`);
2277
+ written.push(dest);
2278
+ }
2279
+ }
2280
+ }
2281
+ if (input.json) {
2282
+ printJson({
2283
+ ok: true,
2284
+ mode: input.mode,
2285
+ provider: input.provider,
2286
+ model: input.model,
2287
+ ...(input.jobId ? { job_id: input.jobId } : {}),
2288
+ source: input.source,
2289
+ language: input.result.language,
2290
+ diarization: input.result.diarization,
2291
+ text: input.result.text,
2292
+ segments: input.result.segments ?? [],
2293
+ srt: input.result.srt,
2294
+ written
2295
+ });
2296
+ return;
2297
+ }
2298
+ if (input.result.segments?.length && input.result.diarization === "diarized") {
2299
+ for (const segment of input.result.segments) {
2300
+ const at = typeof segment.start_sec === "number" ? `${DIM}[${formatSpeechClock(segment.start_sec)}]${RESET} ` : "";
2301
+ console.log(`${at}${segment.speaker}: ${segment.text}`);
2302
+ }
2303
+ }
2304
+ else {
2305
+ console.log(input.result.text || "(no speech detected)");
2306
+ }
2307
+ for (const dest of written) {
2308
+ console.log(`${GREEN}Wrote ${dest}${RESET}`);
2309
+ }
2310
+ }
2311
+ async function runSttCommand(argv) {
2312
+ const parsed = parseArgs({
2313
+ args: argv,
2314
+ allowPositionals: true,
2315
+ options: {
2316
+ ...commonOptions(),
2317
+ "no-diarize": { type: "boolean", default: false },
2318
+ language: { type: "string" },
2319
+ prompt: { type: "string" },
2320
+ provider: { type: "string" },
2321
+ model: { type: "string" },
2322
+ out: { type: "string" },
2323
+ cloud: { type: "boolean", default: false },
2324
+ "gemini-key": { type: "string" },
2325
+ "openai-key": { type: "string" },
2326
+ "openrouter-key": { type: "string" },
2327
+ "no-wait": { type: "boolean", default: false },
2328
+ tracer: { type: "string" },
2329
+ folder: { type: "string" }
2330
+ }
2331
+ });
2332
+ const target = parsed.positionals[0];
2333
+ if (!target)
2334
+ throw new Error("stt requires a source: `vidfarm stt <video-or-audio file|url>`.");
2335
+ const diarize = !parsed.values["no-diarize"];
2336
+ const json = Boolean(parsed.values.json);
2337
+ const language = parsed.values.language?.trim() || undefined;
2338
+ const prompt = parsed.values.prompt?.trim() || undefined;
2339
+ if (!parsed.values.cloud) {
2340
+ const auth = resolveLocalSpeechAuth(parsed.values, ["gemini", "openai", "openrouter"], parsed.values.provider);
2341
+ if (!auth)
2342
+ throw new Error(speechKeyHint("stt"));
2343
+ if (diarize && auth.provider !== "gemini" && !json) {
2344
+ console.log(`${DIM}Note: speaker labels need a Gemini key; ${auth.provider} returns a plain single-speaker transcript.${RESET}`);
2345
+ }
2346
+ if (!json)
2347
+ console.log(`${DIM}Transcribing locally on your ${auth.provider} key (local ffmpeg demux)…${RESET}`);
2348
+ const audio = await loadSpeechAudioLocally(target);
2349
+ if (audio.bytes.byteLength > MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES) {
2350
+ 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.`);
2351
+ }
2352
+ const result = await localTranscribeSpeech({
2353
+ auth,
2354
+ audio: audio.bytes,
2355
+ contentType: audio.contentType,
2356
+ model: parsed.values.model,
2357
+ prompt,
2358
+ language,
2359
+ diarize
2360
+ });
2361
+ emitSttResult({
2362
+ json,
2363
+ out: parsed.values.out,
2364
+ source: target,
2365
+ mode: "local",
2366
+ provider: result.provider,
2367
+ model: result.model,
2368
+ result: {
2369
+ text: result.text,
2370
+ language: result.language,
2371
+ diarization: result.diarization,
2372
+ segments: result.segments,
2373
+ srt: result.srt
2374
+ }
2375
+ });
2376
+ return;
2377
+ }
2378
+ // BACKUP: the platform primitive route. URLs pass through; local files
2379
+ // upload to the ephemeral temp store first (30-day TTL).
2380
+ const ctx = commonContext(parsed.values);
2381
+ const sourceUrl = /^https?:\/\//i.test(target)
2382
+ ? target
2383
+ : await uploadLocalToTempStore(ctx, path.resolve(process.cwd(), target), parsed.values.folder ?? "temp");
2384
+ const payload = { source_url: sourceUrl, diarize };
2385
+ if (language)
2386
+ payload.language = language;
2387
+ if (prompt)
2388
+ payload.prompt = prompt;
2389
+ if (parsed.values.provider)
2390
+ payload.provider = parsed.values.provider;
2391
+ if (parsed.values.model)
2392
+ payload.model = parsed.values.model;
2393
+ const tracer = parsed.values.tracer ?? `devcli-stt-${Date.now().toString(36)}`;
2394
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/transcribe", auth: ctx.auth, body: { tracer, payload } });
2395
+ assertApiOk(submit, "stt");
2396
+ const jobId = submit.json?.job_id;
2397
+ if (parsed.values["no-wait"] || !jobId) {
2398
+ if (!ctx.json && jobId)
2399
+ 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}`);
2400
+ emitResult(submit, ctx.json);
2401
+ return;
2402
+ }
2403
+ if (!ctx.json)
2404
+ console.log(`${DIM}Transcribing (${jobId})… polling every 5s.${RESET}`);
2405
+ const job = await pollPrimitiveJob(ctx, jobId);
2406
+ const status = String(job?.status ?? "");
2407
+ const rawResult = (job?.result && typeof job.result === "object") ? job.result : {};
2408
+ const output = (rawResult.output && typeof rawResult.output === "object") ? rawResult.output : rawResult;
2409
+ if (status !== "succeeded" && typeof output.text !== "string") {
2410
+ if (ctx.json) {
2411
+ printJson({ ok: false, job_id: jobId, status, job });
2412
+ }
2413
+ else {
2414
+ console.log(`${RED}Transcription ${status || "did not finish"}.${RESET}`);
2415
+ printJson(job);
2416
+ }
2417
+ process.exitCode = 1;
2418
+ return;
2419
+ }
2420
+ // Long transcripts stay artifact-only; hydrate the full version from
2421
+ // transcript.json / transcript.srt when the inline copy was truncated.
2422
+ let text = typeof output.text === "string" ? output.text : "";
2423
+ let segments = Array.isArray(output.segments) ? output.segments : null;
2424
+ let srt = typeof output.subtitles?.srt === "string" ? output.subtitles.srt : null;
2425
+ const jsonUrl = typeof output.transcript?.json_url === "string" ? output.transcript.json_url : null;
2426
+ if ((output.text_truncated === true || !segments) && jsonUrl) {
2427
+ const hydrated = await fetch(jsonUrl).then((res) => (res.ok ? res.json() : null)).catch(() => null);
2428
+ if (hydrated && typeof hydrated === "object") {
2429
+ if (typeof hydrated.text === "string")
2430
+ text = hydrated.text;
2431
+ if (Array.isArray(hydrated.segments))
2432
+ segments = hydrated.segments;
2433
+ }
2434
+ }
2435
+ if (!srt && typeof output.subtitles?.srt_url === "string") {
2436
+ srt = await fetch(output.subtitles.srt_url).then((res) => (res.ok ? res.text() : null)).catch(() => null);
2437
+ }
2438
+ emitSttResult({
2439
+ json: ctx.json,
2440
+ out: parsed.values.out,
2441
+ source: target,
2442
+ mode: "cloud",
2443
+ provider: typeof output.transcript?.provider === "string" ? output.transcript.provider : null,
2444
+ model: typeof output.transcript?.model === "string" ? output.transcript.model : null,
2445
+ jobId,
2446
+ result: {
2447
+ text,
2448
+ language: typeof output.language === "string" ? output.language : null,
2449
+ diarization: typeof output.diarization === "string" ? output.diarization : "none",
2450
+ segments,
2451
+ srt
2452
+ }
2453
+ });
2454
+ }
2072
2455
  async function runPlaceCommand(argv) {
2073
2456
  const parsed = parseArgs({
2074
2457
  args: argv,
@@ -68,6 +68,11 @@ const schema = z.object({
68
68
  GHOSTCUT_KEY: z.string().optional(),
69
69
  GHOSTCUT_SECRET: z.string().optional(),
70
70
  GHOSTCUT_USD_PER_30S: z.coerce.number().min(0).default(0.10),
71
+ // GhostCut caption removal is for SHORT clips, never long-form sources —
72
+ // clip hunting must select text-free scenes instead of laundering a whole
73
+ // long video through GhostCut. 15 min default ceiling (per-30s pricing makes
74
+ // long-form runs absurd anyway).
75
+ GHOSTCUT_MAX_DURATION_SEC: z.coerce.number().min(0).default(900),
71
76
  // Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
72
77
  // fully local but lists the upstream (prod) catalog in /discover and
73
78
  // /library, seeds forks on demand, and can hand a render off to the cloud.