@mevdragon/vidfarm-devcli 0.10.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
@@ -160,6 +161,28 @@ Clip hunting (the third library — mine long-form video into a reusable clip st
160
161
  clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
161
162
  clips export <ids...> --to <dir> Copy clip mp4s + metadata out
162
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)
163
186
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
164
187
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
165
188
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -252,6 +275,15 @@ Publish-mode options:
252
275
  --message <text> Message attached to the published version snapshot
253
276
  --no-snapshot Only update the working copy; skip the version snapshot
254
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";
255
287
  void main().catch(async (error) => {
256
288
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
257
289
  // Report only unexpected crashes (bugs), never the deliberate user-facing
@@ -314,6 +346,13 @@ async function main() {
314
346
  case "generate":
315
347
  await runGenerateCommand(rest);
316
348
  return;
349
+ case "tts":
350
+ await runTtsCommand(rest);
351
+ return;
352
+ case "stt":
353
+ case "transcribe":
354
+ await runSttCommand(rest);
355
+ return;
317
356
  case "place":
318
357
  await runPlaceCommand(rest);
319
358
  return;
@@ -407,16 +446,6 @@ async function main() {
407
446
  throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
408
447
  }
409
448
  }
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
449
  async function apiRequest(input) {
421
450
  const url = new URL(input.path, input.host);
422
451
  for (const [key, value] of Object.entries(input.query ?? {})) {
@@ -2075,6 +2104,354 @@ async function runGenerateCommand(argv) {
2075
2104
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
2076
2105
  }
2077
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
+ }
2078
2455
  async function runPlaceCommand(argv) {
2079
2456
  const parsed = parseArgs({
2080
2457
  args: argv,
@@ -0,0 +1,175 @@
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
+ export async function localTranscribeSpeech(input) {
84
+ const model = input.model?.trim() || defaultSpeechModelFor("stt", input.auth.provider);
85
+ if (!model) {
86
+ throw new Error(`No STT model available for provider ${input.auth.provider}.`);
87
+ }
88
+ const result = await transcribeSpeechWithKey({
89
+ provider: input.auth.provider,
90
+ model,
91
+ audio: input.audio,
92
+ contentType: input.contentType,
93
+ prompt: input.prompt,
94
+ language: input.language,
95
+ diarize: input.diarize,
96
+ apiKey: input.auth.apiKey
97
+ });
98
+ return { ...result, provider: input.auth.provider, model, srt: buildSrtFromSegments(result.segments) };
99
+ }
100
+ const AUDIO_EXTENSION_CONTENT_TYPES = {
101
+ mp3: "audio/mpeg",
102
+ wav: "audio/wav",
103
+ m4a: "audio/mp4",
104
+ aac: "audio/mp4",
105
+ ogg: "audio/ogg",
106
+ oga: "audio/ogg",
107
+ flac: "audio/flac"
108
+ };
109
+ function audioExtensionOf(target) {
110
+ const match = /\.(mp3|wav|m4a|aac|ogg|oga|flac)(\?|#|$)/i.exec(target);
111
+ return match ? match[1].toLowerCase() : null;
112
+ }
113
+ /**
114
+ * Turn a local path OR http(s) URL — video or audio — into speech-provider
115
+ * ready audio bytes. Plain audio files pass through untouched; everything else
116
+ * (video, unknown containers) is demuxed to small mono mp3 by LOCAL ffmpeg
117
+ * (bundled ffmpeg-static, or PATH), which reads URLs directly.
118
+ */
119
+ export async function loadSpeechAudioLocally(target) {
120
+ const isUrl = /^https?:\/\//i.test(target);
121
+ if (!isUrl && !existsSync(target)) {
122
+ throw new Error(`No such local file: ${target}`);
123
+ }
124
+ const audioExtension = audioExtensionOf(target);
125
+ if (audioExtension) {
126
+ if (isUrl) {
127
+ const response = await fetch(target);
128
+ if (!response.ok) {
129
+ throw new Error(`Could not fetch source audio (${response.status}).`);
130
+ }
131
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || AUDIO_EXTENSION_CONTENT_TYPES[audioExtension];
132
+ return { bytes: new Uint8Array(await response.arrayBuffer()), contentType, demuxed: false };
133
+ }
134
+ return { bytes: readFileSync(target), contentType: AUDIO_EXTENSION_CONTENT_TYPES[audioExtension], demuxed: false };
135
+ }
136
+ const ffmpeg = await resolveFfmpeg();
137
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "vidfarm-stt-"));
138
+ const outputPath = path.join(tempDir, "audio.mp3");
139
+ try {
140
+ await runFfmpegDemux(ffmpeg, target, outputPath);
141
+ return { bytes: await readFile(outputPath), contentType: "audio/mpeg", demuxed: true };
142
+ }
143
+ finally {
144
+ await rm(tempDir, { recursive: true, force: true });
145
+ }
146
+ }
147
+ function runFfmpegDemux(bin, input, outputPath) {
148
+ return new Promise((resolvePromise, rejectPromise) => {
149
+ const child = spawn(bin, [
150
+ "-hide_banner",
151
+ "-y",
152
+ "-i", input,
153
+ "-vn",
154
+ "-ac", "1",
155
+ "-b:a", "64k",
156
+ outputPath
157
+ ], { stdio: ["ignore", "ignore", "pipe"] });
158
+ let stderr = "";
159
+ child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
160
+ child.on("error", (error) => {
161
+ rejectPromise(error.code === "ENOENT"
162
+ ? new Error("ffmpeg not found. Install ffmpeg (or `npm i ffmpeg-static`), or run with --cloud to demux on the platform.")
163
+ : error);
164
+ });
165
+ child.on("close", (code) => {
166
+ if (code === 0) {
167
+ resolvePromise();
168
+ }
169
+ else {
170
+ rejectPromise(new Error(`ffmpeg demux exited ${code}: ${stderr.slice(-400)}`));
171
+ }
172
+ });
173
+ });
174
+ }
175
+ //# sourceMappingURL=speech.js.map
@@ -34,7 +34,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
34
34
  "Execute REST calls sequentially. Make one API call, inspect the response, then decide the next step.",
35
35
  "A queued async POST job is enough inspection for that request. Do not poll it in the same response; continue launching any remaining requested outputs, then summarize the queued jobs.",
36
36
  "Primitive routes under /api/v1/primitives are first-class allowed routes in this editor, even when the primitive is not specific to the current template.",
37
- "Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
37
+ "Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, text-to-speech, speech transcription, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
38
38
  "All primitive POST routes require the same outer JSON envelope: top-level tracer, top-level payload containing the primitive-specific fields, and optional webhook_url.",
39
39
  "When the user explicitly asks to use primitives or timeline rendering, do not redirect them to old per-template operation routes unless the requested primitive route cannot accept the available inputs.",
40
40
  "If the user asks to create, generate, or make a new image similar to an attached image, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }.",
@@ -42,6 +42,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
42
42
  "If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
43
43
  "If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
44
44
  "If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
45
+ "SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output.",
45
46
  "Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
46
47
  "Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
47
48
  "BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
@@ -181,6 +181,22 @@ export function createPrimitiveJobContext(input) {
181
181
  workerId: input.workerId,
182
182
  ...request
183
183
  });
184
+ },
185
+ async generateSpeech(request) {
186
+ return input.providers.generateSpeech({
187
+ customerId: input.customer.id,
188
+ jobId: input.job.id,
189
+ workerId: input.workerId,
190
+ ...request
191
+ });
192
+ },
193
+ async transcribeSpeech(request) {
194
+ return input.providers.transcribeSpeech({
195
+ customerId: input.customer.id,
196
+ jobId: input.job.id,
197
+ workerId: input.workerId,
198
+ ...request
199
+ });
184
200
  }
185
201
  },
186
202
  hyperframes: {