@mevdragon/vidfarm-devcli 0.8.0 → 0.10.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 { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
13
14
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
14
15
  // `serve` command boots the FULL editor locally (single origin, disk-backed
15
16
  // records + storage) so power users edit compositions on disk while a browser
@@ -80,6 +81,19 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
80
81
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
81
82
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
82
83
 
84
+ Make a video in one command (orchestrates ingest → decompose → fork):
85
+ replicate <url> Recreate an existing video end-to-end: fetch → ingest + decompose + fork
86
+ the URL, AI-decompose/recreate its scenes,
87
+ and open an editable fork. --prompt "...and
88
+ make it about X" steers the recreation.
89
+ --title, --no-fork optional.
90
+ create "<prompt>" Spin up a NEW video from a text prompt: AI- → generate + ingest + decompose + fork
91
+ generate a base video, wrap it into an
92
+ editable template, decompose + fork. --duration
93
+ <s>, --aspect-ratio, --resolution, --provider,
94
+ --model, --audio, --ref <url|@file>, --title,
95
+ --no-decompose, --no-fork. Needs a provider key.
96
+
83
97
  Composition lifecycle (fork → decompose → snapshot → render):
84
98
  fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
85
99
  pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
@@ -130,10 +144,16 @@ Generate AI media and drop it on the timeline (for local coding agents):
130
144
  original vs decomposed (caption-free),
131
145
  non-billing (alias: ghostcut)
132
146
 
133
- Clip curation (the third library — mine long-form video into a reusable clip store):
134
- clips scan <video-path> Detect scenes tag + transcribe (Gemini) LOCAL: ffmpeg + BYOK Gemini
135
- → embed → persist to ~/.vidfarm/clips.db. (cloud: POST /clips/scan)
136
- --tier flash|flash-lite Gemini tier (default flash-lite, cheapest)
147
+ Clip hunting (the third library — mine long-form video into a reusable clip store):
148
+ clips scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
149
+ Detect tag/transcribe → embed → persist your local claude/codex CLI
150
+ to ~/.vidfarm/clips.db. (keys are fallback)
151
+ --range "MM:SS-MM:SS" Only hunt these source windows (repeatable; big cost saver)
152
+ --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
153
+ --aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
154
+ --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
155
+ --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /clips/scan (+ poll)
156
+ (uploads to your temp folder — 30-day TTL — bills AWS compute only)
137
157
  --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
138
158
  clips list List the local clip library → GET /clips
139
159
  clips search "<text>" Hybrid structured + semantic search → POST /clips/search
@@ -232,8 +252,11 @@ Publish-mode options:
232
252
  --message <text> Message attached to the published version snapshot
233
253
  --no-snapshot Only update the working copy; skip the version snapshot
234
254
  `;
235
- void main().catch((error) => {
255
+ void main().catch(async (error) => {
236
256
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
257
+ // Report only unexpected crashes (bugs), never the deliberate user-facing
258
+ // `throw new Error(...)` above; no-op unless a devcli DSN is configured.
259
+ await reportCliCrash(error);
237
260
  process.exit(1);
238
261
  });
239
262
  async function main() {
@@ -243,6 +266,9 @@ async function main() {
243
266
  return;
244
267
  }
245
268
  const command = argv[0];
269
+ // Opt-out crash telemetry (also covers the long-lived `serve` runtime, whose
270
+ // in-process job/render captures share this client). Guarded + no-op by default.
271
+ initTelemetry(command);
246
272
  const rest = argv.slice(1);
247
273
  switch (command) {
248
274
  // `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
@@ -273,6 +299,12 @@ async function main() {
273
299
  case "inspiration-decompose":
274
300
  await runInspirationDecomposeCommand(rest);
275
301
  return;
302
+ case "replicate":
303
+ await runReplicateCommand(rest);
304
+ return;
305
+ case "create":
306
+ await runCreateCommand(rest);
307
+ return;
276
308
  case "fork":
277
309
  await runForkCommand(rest);
278
310
  return;
@@ -1194,54 +1226,14 @@ async function runInspirationAddCommand(argv) {
1194
1226
  if (!existsSync(localPath)) {
1195
1227
  throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
1196
1228
  }
1197
- const fileName = path.basename(localPath);
1198
1229
  const buffer = readFileSync(localPath);
1199
- const contentType = guessContentType(fileName);
1200
- const presign = await apiRequest({
1201
- method: "POST",
1202
- host: ctx.host,
1203
- path: "/discover/templates/upload/presign",
1204
- auth: ctx.auth,
1205
- body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1206
- });
1207
- assertApiOk(presign, "inspiration-add (presign)");
1208
- let storageKey = presign.json?.storage_key;
1209
- let uploadedName = presign.json?.file_name || fileName;
1210
- if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1211
- const upload = presign.json.upload;
1212
- const put = await fetch(upload.url, {
1213
- method: upload.method || "PUT",
1214
- headers: upload.headers || {},
1215
- body: new Uint8Array(buffer)
1216
- });
1217
- if (!put.ok)
1218
- throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
1219
- }
1220
- else {
1221
- const form = new FormData();
1222
- form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1223
- const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1224
- method: "POST",
1225
- headers: buildAuthHeaders(ctx.auth),
1226
- body: form
1227
- });
1228
- const json = await res.json().catch(() => null);
1229
- if (!res.ok || !json?.storage_key) {
1230
- throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1231
- }
1232
- storageKey = json.storage_key;
1233
- uploadedName = json.file_name || fileName;
1234
- }
1235
- if (!storageKey)
1236
- throw new Error("inspiration-add upload did not return a storage key.");
1237
- const result = await apiRequest({
1238
- method: "POST",
1239
- host: ctx.host,
1240
- path: "/discover/templates",
1241
- auth: ctx.auth,
1242
- body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
1230
+ const result = await ingestUploadedVideo(ctx, {
1231
+ buffer,
1232
+ fileName: path.basename(localPath),
1233
+ title: parsed.values.title,
1234
+ tagline: parsed.values.tagline,
1235
+ notes: parsed.values.notes
1243
1236
  });
1244
- assertApiOk(result, "inspiration-add");
1245
1237
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1246
1238
  return;
1247
1239
  }
@@ -1287,6 +1279,259 @@ async function runInspirationDecomposeCommand(argv) {
1287
1279
  }
1288
1280
  emitResult(result, ctx.json);
1289
1281
  }
1282
+ // Presign → PUT the bytes (or server multipart fallback) → POST /discover/templates
1283
+ // with the storage key. Shared by `inspiration-add` (local file) and `create`
1284
+ // (a generated MP4 wrapped into a new template). Returns the ingest ApiResult.
1285
+ async function ingestUploadedVideo(ctx, input) {
1286
+ const { buffer, fileName } = input;
1287
+ const contentType = input.contentType || guessContentType(fileName);
1288
+ const presign = await apiRequest({
1289
+ method: "POST",
1290
+ host: ctx.host,
1291
+ path: "/discover/templates/upload/presign",
1292
+ auth: ctx.auth,
1293
+ body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1294
+ });
1295
+ assertApiOk(presign, "upload (presign)");
1296
+ let storageKey = presign.json?.storage_key;
1297
+ let uploadedName = presign.json?.file_name || fileName;
1298
+ if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1299
+ const upload = presign.json.upload;
1300
+ const put = await fetch(upload.url, {
1301
+ method: upload.method || "PUT",
1302
+ headers: upload.headers || {},
1303
+ body: new Uint8Array(buffer)
1304
+ });
1305
+ if (!put.ok)
1306
+ throw new Error(`upload failed with HTTP ${put.status}.`);
1307
+ }
1308
+ else {
1309
+ const form = new FormData();
1310
+ form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1311
+ const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1312
+ method: "POST",
1313
+ headers: buildAuthHeaders(ctx.auth),
1314
+ body: form
1315
+ });
1316
+ const json = await res.json().catch(() => null);
1317
+ if (!res.ok || !json?.storage_key) {
1318
+ throw new Error(`upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1319
+ }
1320
+ storageKey = json.storage_key;
1321
+ uploadedName = json.file_name || fileName;
1322
+ }
1323
+ if (!storageKey)
1324
+ throw new Error("upload did not return a storage key.");
1325
+ const result = await apiRequest({
1326
+ method: "POST",
1327
+ host: ctx.host,
1328
+ path: "/discover/templates",
1329
+ auth: ctx.auth,
1330
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
1331
+ });
1332
+ assertApiOk(result, "ingest");
1333
+ return result;
1334
+ }
1335
+ // Poll GET /api/v1/videos/:id (which finalizes the download job and mints the
1336
+ // template once the source video lands) until the inspiration is ready/failed.
1337
+ async function pollInspirationReady(ctx, inspirationId, timeoutMs = 5 * 60 * 1000) {
1338
+ const deadline = Date.now() + timeoutMs;
1339
+ let last = null;
1340
+ for (;;) {
1341
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/videos/${encodeURIComponent(inspirationId)}`, auth: ctx.auth });
1342
+ if (res.ok && res.json) {
1343
+ last = res.json;
1344
+ const status = String(last.status ?? "");
1345
+ if (status === "ready")
1346
+ return last;
1347
+ if (status === "failed")
1348
+ throw new Error(`source video download failed${last.error ? `: ${last.error}` : ""}.`);
1349
+ }
1350
+ if (Date.now() > deadline) {
1351
+ throw new Error("source video download did not finish in time — re-run, or check with `vidfarm videos --mine`.");
1352
+ }
1353
+ await sleep(5000);
1354
+ }
1355
+ }
1356
+ async function decomposeInspiration(ctx, inspirationId, userPrompt) {
1357
+ const result = await apiRequest({
1358
+ method: "POST",
1359
+ host: ctx.host,
1360
+ path: `/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`,
1361
+ auth: ctx.auth,
1362
+ body: { user_prompt: userPrompt }
1363
+ });
1364
+ assertApiOk(result, "decompose");
1365
+ return result;
1366
+ }
1367
+ async function forkTemplate(ctx, templateId, title) {
1368
+ const result = await apiRequest({
1369
+ method: "POST",
1370
+ host: ctx.host,
1371
+ path: "/api/v1/compositions",
1372
+ auth: ctx.auth,
1373
+ body: { template_id: templateId, title }
1374
+ });
1375
+ assertApiOk(result, "fork");
1376
+ return result;
1377
+ }
1378
+ // Print the final "your video is ready" block with the openable editor URL.
1379
+ function emitVideoResult(ctx, payload, openTemplate, forkId) {
1380
+ if (ctx.json) {
1381
+ printJson({ ok: true, ...payload });
1382
+ return;
1383
+ }
1384
+ printJson({ ok: true, ...payload });
1385
+ console.log("");
1386
+ console.log(` ${BOLD}Open editor ${RESET} ${FRONTEND}${editorFrontendUrl(ctx.host, openTemplate, forkId)}${RESET}`);
1387
+ if (forkId) {
1388
+ console.log(`${DIM}Edit with an agent on disk: vidfarm pull ${forkId} --dir .vidfarm/${forkId}${RESET}`);
1389
+ }
1390
+ }
1391
+ // `replicate <url> [--prompt "...and make it about X"]` — one command from a
1392
+ // TikTok/YT/IG/X URL to an editable recreation. Chains: ingest → wait for the
1393
+ // download + template mint → AI decompose/recreate (steered by --prompt) →
1394
+ // fork a personal editable copy → print the editor URL.
1395
+ async function runReplicateCommand(argv) {
1396
+ const parsed = parseArgs({
1397
+ args: argv,
1398
+ allowPositionals: true,
1399
+ options: { ...commonOptions(), prompt: { type: "string" }, title: { type: "string" }, "no-fork": { type: "boolean", default: false } }
1400
+ });
1401
+ const url = parsed.positionals[0];
1402
+ if (!url || !/^https?:\/\//i.test(url)) {
1403
+ throw new Error('replicate requires a video URL (TikTok/YouTube/Instagram/X): `vidfarm replicate <url> --prompt "..."`.');
1404
+ }
1405
+ const ctx = commonContext(parsed.values);
1406
+ const userPrompt = parsed.values.prompt?.trim() || null;
1407
+ // 1. Ingest the URL → private inspiration (downloads the source video).
1408
+ if (!ctx.json)
1409
+ process.stdout.write(`${DIM}Fetching source video… ${RESET}`);
1410
+ const add = await apiRequest({ method: "POST", host: ctx.host, path: "/discover/templates", auth: ctx.auth, body: { source_url: url } });
1411
+ assertApiOk(add, "replicate (ingest)");
1412
+ const inspirationId = add.json?.inspiration_id;
1413
+ if (!inspirationId)
1414
+ throw new Error("replicate: ingest did not return an inspiration id.");
1415
+ // 2. Wait for the download + template mint.
1416
+ const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
1417
+ if (!ctx.json)
1418
+ console.log(`${GREEN}done${RESET}`);
1419
+ const templateId = ready.template_id;
1420
+ if (!templateId)
1421
+ throw new Error("replicate: no template was minted from the source video.");
1422
+ // 3. Decompose (AI scene analysis + recreation), steered by --prompt.
1423
+ if (!ctx.json)
1424
+ process.stdout.write(`${DIM}Analyzing + recreating scenes (can take a minute)… ${RESET}`);
1425
+ const decomposed = await decomposeInspiration(ctx, inspirationId, userPrompt);
1426
+ if (!ctx.json)
1427
+ console.log(`${GREEN}done${RESET}`);
1428
+ const defaultForkId = decomposed.json?.default_fork_id;
1429
+ // 4. Fork a personal editable copy (unless --no-fork opens the shared base).
1430
+ let forkId = defaultForkId;
1431
+ let openTemplate = templateId;
1432
+ if (!parsed.values["no-fork"]) {
1433
+ const fork = await forkTemplate(ctx, templateId, parsed.values.title);
1434
+ forkId = fork.json?.fork_id ?? defaultForkId;
1435
+ openTemplate = fork.json?.template_id ?? templateId;
1436
+ }
1437
+ emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId }, openTemplate, forkId);
1438
+ }
1439
+ // `create "<prompt>"` — spin up a brand-new editable video from a text prompt
1440
+ // (no source URL). Chains: generate a base video via the video primitive → wrap
1441
+ // the MP4 into a new template (upload-ingest) → AI decompose into an editable
1442
+ // timeline → fork → print the editor URL. Requires an AI provider key (the
1443
+ // generate + decompose steps use it), same as the web editor.
1444
+ async function runCreateCommand(argv) {
1445
+ const parsed = parseArgs({
1446
+ args: argv,
1447
+ allowPositionals: true,
1448
+ options: {
1449
+ ...commonOptions(),
1450
+ prompt: { type: "string" },
1451
+ "aspect-ratio": { type: "string" },
1452
+ duration: { type: "string" },
1453
+ resolution: { type: "string" },
1454
+ provider: { type: "string" },
1455
+ model: { type: "string" },
1456
+ audio: { type: "boolean", default: false },
1457
+ ref: { type: "string", multiple: true },
1458
+ title: { type: "string" },
1459
+ "no-decompose": { type: "boolean", default: false },
1460
+ "no-fork": { type: "boolean", default: false }
1461
+ }
1462
+ });
1463
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
1464
+ if (!prompt)
1465
+ throw new Error('create requires a prompt: `vidfarm create "a 15s ad for my coffee brand"` (or --prompt "...").');
1466
+ const ctx = commonContext(parsed.values);
1467
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
1468
+ // 1. Generate the base video from the prompt.
1469
+ const payload = { prompt };
1470
+ if (parsed.values["aspect-ratio"])
1471
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
1472
+ if (parsed.values.provider)
1473
+ payload.provider = parsed.values.provider;
1474
+ if (parsed.values.model)
1475
+ payload.model = parsed.values.model;
1476
+ if (parsed.values.duration)
1477
+ payload.duration = Math.round(Number(parsed.values.duration));
1478
+ if (parsed.values.resolution)
1479
+ payload.resolution = parsed.values.resolution;
1480
+ if (parsed.values.audio)
1481
+ payload.generate_audio = true;
1482
+ if (refs.length)
1483
+ payload.input_references = refs.slice(0, 8);
1484
+ const tracer = `devcli-create-${Date.now().toString(36)}`;
1485
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/generate", auth: ctx.auth, body: { tracer, payload } });
1486
+ assertApiOk(submit, "create (generate)");
1487
+ const jobId = submit.json?.job_id;
1488
+ if (!jobId)
1489
+ throw new Error("create: generate did not return a job id.");
1490
+ if (!ctx.json)
1491
+ console.log(`${DIM}Generating base video (${jobId})… polling every 5s.${RESET}`);
1492
+ const job = await pollPrimitiveJob(ctx, jobId);
1493
+ const mediaUrl = resolveJobMediaUrl(job);
1494
+ if (!mediaUrl)
1495
+ throw new Error(`create: generation ${String(job?.status ?? "did not finish")} — no media URL. Check your provider keys and wallet.`);
1496
+ // 2. Download the generated MP4 and wrap it into a new template (upload-ingest
1497
+ // path — the same one `inspiration-add <file>` uses).
1498
+ if (!ctx.json)
1499
+ process.stdout.write(`${DIM}Building an editable template from the generated video… ${RESET}`);
1500
+ const dl = await fetch(mediaUrl, { headers: buildAuthHeaders(ctx.auth) });
1501
+ if (!dl.ok)
1502
+ throw new Error(`create: could not fetch the generated video (${dl.status}).`);
1503
+ const buffer = Buffer.from(await dl.arrayBuffer());
1504
+ const title = parsed.values.title ?? prompt.slice(0, 80);
1505
+ const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title });
1506
+ const inspirationId = add.json?.inspiration_id;
1507
+ if (!inspirationId)
1508
+ throw new Error("create: ingest did not return an inspiration id.");
1509
+ const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
1510
+ if (!ctx.json)
1511
+ console.log(`${GREEN}done${RESET}`);
1512
+ const templateId = ready.template_id;
1513
+ if (!templateId)
1514
+ throw new Error("create: no template was minted from the generated video.");
1515
+ // 3. Decompose into an editable per-scene timeline (unless --no-decompose).
1516
+ let defaultForkId;
1517
+ if (!parsed.values["no-decompose"]) {
1518
+ if (!ctx.json)
1519
+ process.stdout.write(`${DIM}Analyzing into an editable timeline… ${RESET}`);
1520
+ const decomposed = await decomposeInspiration(ctx, inspirationId, prompt);
1521
+ defaultForkId = decomposed.json?.default_fork_id;
1522
+ if (!ctx.json)
1523
+ console.log(`${GREEN}done${RESET}`);
1524
+ }
1525
+ // 4. Fork a personal editable copy (unless --no-fork).
1526
+ let forkId = defaultForkId;
1527
+ let openTemplate = templateId;
1528
+ if (!parsed.values["no-fork"]) {
1529
+ const fork = await forkTemplate(ctx, templateId, parsed.values.title);
1530
+ forkId = fork.json?.fork_id ?? defaultForkId;
1531
+ openTemplate = fork.json?.template_id ?? templateId;
1532
+ }
1533
+ emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId, generated_video_url: mediaUrl }, openTemplate, forkId);
1534
+ }
1290
1535
  // ── Composition lifecycle ───────────────────────────────────────────────────
1291
1536
  async function runForkCommand(argv) {
1292
1537
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" } } });
@@ -1312,13 +1557,55 @@ async function runDecomposeCommand(argv) {
1312
1557
  if (!forkId)
1313
1558
  throw new Error("decompose requires a fork id.");
1314
1559
  const ctx = commonContext(parsed.values);
1315
- const result = await apiRequest({
1560
+ const decomposePath = `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`;
1561
+ // Decompose can run longer than the 60s CloudFront origin timeout on long
1562
+ // videos. `force: true` KICKS a resumable run; the server keeps working even
1563
+ // if this request 504s. So we tolerate a non-`done` kick and poll GET
1564
+ // .../auto-decompose until the job is terminal.
1565
+ let result = await apiRequest({
1316
1566
  method: "POST",
1317
1567
  host: ctx.host,
1318
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`,
1568
+ path: decomposePath,
1319
1569
  auth: ctx.auth,
1320
- body: { mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
1570
+ body: { force: true, mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
1321
1571
  });
1572
+ const kickStatus = typeof result.json?.status === "string" ? result.json.status : null;
1573
+ if (!(result.ok && kickStatus === "done")) {
1574
+ if (!ctx.json)
1575
+ process.stdout.write(`${DIM}Decomposing (may take a minute on long videos)… ${RESET}`);
1576
+ const deadline = Date.now() + 12 * 60 * 1000;
1577
+ let settled = null;
1578
+ while (Date.now() < deadline) {
1579
+ await sleep(5000);
1580
+ let statusResult;
1581
+ try {
1582
+ statusResult = await apiRequest({ method: "GET", host: ctx.host, path: decomposePath, auth: ctx.auth });
1583
+ }
1584
+ catch {
1585
+ continue;
1586
+ }
1587
+ const s = typeof statusResult.json?.status === "string" ? statusResult.json.status : "none";
1588
+ if (s === "done" || s === "failed") {
1589
+ settled = statusResult;
1590
+ break;
1591
+ }
1592
+ if (s === "none")
1593
+ break; // the run never claimed — surface the kick error below
1594
+ }
1595
+ if (settled) {
1596
+ result = settled;
1597
+ if (!ctx.json)
1598
+ console.log(settled.json?.status === "done" ? `${GREEN}done${RESET}` : `${RED}failed${RESET}`);
1599
+ }
1600
+ else {
1601
+ // Timed out or never started — surface the kick's own error if it was one.
1602
+ assertApiOk(result, "decompose");
1603
+ throw new Error("decompose did not complete in time (it may still finish server-side; re-run `vidfarm decompose` to check).");
1604
+ }
1605
+ }
1606
+ if (result.json?.status === "failed") {
1607
+ throw new Error(`decompose failed: ${result.json?.error ?? "unknown error"}`);
1608
+ }
1322
1609
  assertApiOk(result, "decompose");
1323
1610
  if (!ctx.json && result.json?.ghostcut_pending) {
1324
1611
  console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
@@ -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.