@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/README.md +37 -1
- package/SKILL.director.md +123 -0
- package/SKILL.platform.md +15 -2
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +550 -184
- package/dist/src/cli.js +397 -14
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +239 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/speech.js +467 -0
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
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
|
|
148
|
-
clips scan <video-path>
|
|
149
|
-
→ embed → persist
|
|
150
|
-
|
|
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,
|
package/dist/src/config.js
CHANGED
|
@@ -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.
|