@mevdragon/vidfarm-devcli 0.7.1 → 0.9.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.
Files changed (30) hide show
  1. package/README.md +8 -0
  2. package/demo/dist/app.js +81 -53
  3. package/dist/src/app.js +1010 -8
  4. package/dist/src/cli.js +390 -53
  5. package/dist/src/config.js +6 -0
  6. package/dist/src/devcli/clip-store.js +335 -0
  7. package/dist/src/devcli/clips.js +803 -0
  8. package/dist/src/devcli/telemetry.js +236 -0
  9. package/dist/src/frontend/homepage-view.js +5 -0
  10. package/dist/src/frontend/template-editor-chat.js +212 -0
  11. package/dist/src/services/clip-curation/cost.js +112 -0
  12. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  13. package/dist/src/services/clip-curation/gemini.js +342 -0
  14. package/dist/src/services/clip-curation/index.js +15 -0
  15. package/dist/src/services/clip-curation/presets.js +20 -0
  16. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  17. package/dist/src/services/clip-curation/query.js +163 -0
  18. package/dist/src/services/clip-curation/refine.js +136 -0
  19. package/dist/src/services/clip-curation/scan.js +137 -0
  20. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  21. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  22. package/dist/src/services/clip-curation/types.js +7 -0
  23. package/dist/src/services/clip-records.js +209 -0
  24. package/dist/src/services/clip-search.js +47 -0
  25. package/dist/src/services/clip-vectors.js +125 -0
  26. package/dist/src/services/hyperframes.js +369 -9
  27. package/dist/src/services/scene-annotations.js +32 -0
  28. package/package.json +5 -1
  29. package/public/assets/homepage-client-app.js +17 -17
  30. package/public/assets/page-runtime-client-app.js +31 -31
package/dist/src/cli.js CHANGED
@@ -9,6 +9,8 @@ import { spawnSync } from "node:child_process";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
11
11
  import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
12
+ import { runClipsCommand } from "./devcli/clips.js";
13
+ import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
12
14
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
13
15
  // `serve` command boots the FULL editor locally (single origin, disk-backed
14
16
  // records + storage) so power users edit compositions on disk while a browser
@@ -79,6 +81,19 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
79
81
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
80
82
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
81
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
+
82
97
  Composition lifecycle (fork → decompose → snapshot → render):
83
98
  fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
84
99
  pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
@@ -128,6 +143,17 @@ Generate AI media and drop it on the timeline (for local coding agents):
128
143
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
129
144
  original vs decomposed (caption-free),
130
145
  non-billing (alias: ghostcut)
146
+
147
+ Clip curation (the third library — mine long-form video into a reusable clip store):
148
+ clips scan <video-path> Detect scenes → tag + transcribe (Gemini) LOCAL: ffmpeg + BYOK Gemini
149
+ → embed → persist to ~/.vidfarm/clips.db. (cloud: POST /clips/scan)
150
+ --tier flash|flash-lite Gemini tier (default flash-lite, cheapest)
151
+ --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
152
+ clips list List the local clip library → GET /clips
153
+ clips search "<text>" Hybrid structured + semantic search → POST /clips/search
154
+ clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
155
+ clips export <ids...> --to <dir> Copy clip mp4s + metadata out
156
+ (run 'vidfarm clips' for the full clips help)
131
157
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
132
158
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
133
159
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -220,8 +246,11 @@ Publish-mode options:
220
246
  --message <text> Message attached to the published version snapshot
221
247
  --no-snapshot Only update the working copy; skip the version snapshot
222
248
  `;
223
- void main().catch((error) => {
249
+ void main().catch(async (error) => {
224
250
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
251
+ // Report only unexpected crashes (bugs), never the deliberate user-facing
252
+ // `throw new Error(...)` above; no-op unless a devcli DSN is configured.
253
+ await reportCliCrash(error);
225
254
  process.exit(1);
226
255
  });
227
256
  async function main() {
@@ -231,6 +260,9 @@ async function main() {
231
260
  return;
232
261
  }
233
262
  const command = argv[0];
263
+ // Opt-out crash telemetry (also covers the long-lived `serve` runtime, whose
264
+ // in-process job/render captures share this client). Guarded + no-op by default.
265
+ initTelemetry(command);
234
266
  const rest = argv.slice(1);
235
267
  switch (command) {
236
268
  // `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
@@ -261,6 +293,12 @@ async function main() {
261
293
  case "inspiration-decompose":
262
294
  await runInspirationDecomposeCommand(rest);
263
295
  return;
296
+ case "replicate":
297
+ await runReplicateCommand(rest);
298
+ return;
299
+ case "create":
300
+ await runCreateCommand(rest);
301
+ return;
264
302
  case "fork":
265
303
  await runForkCommand(rest);
266
304
  return;
@@ -351,6 +389,9 @@ async function main() {
351
389
  case "save-file":
352
390
  await runPutFileCommand(rest);
353
391
  return;
392
+ case "clips":
393
+ await runClipsCommand(rest);
394
+ return;
354
395
  default:
355
396
  if (!command.startsWith("-")) {
356
397
  // Positional template id → default to booting the local editor.
@@ -803,7 +844,10 @@ async function fetchCompositionFiles(input) {
803
844
  // can ground edits in what the video actually says and shows.
804
845
  // cast.json carries the identified recurring people/characters plus their
805
846
  // reference-still URLs so agents can reuse "the same person" in new gens.
806
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
847
+ // scene-annotations.json carries the detected content format + per-scene
848
+ // literal + viral DNA an agent needs to REPLACE a scene from the clip library
849
+ // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
850
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json"];
807
851
  for (const filename of files) {
808
852
  const target = path.join(input.dir, filename);
809
853
  if (existsSync(target) && !input.refetch) {
@@ -1176,54 +1220,14 @@ async function runInspirationAddCommand(argv) {
1176
1220
  if (!existsSync(localPath)) {
1177
1221
  throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
1178
1222
  }
1179
- const fileName = path.basename(localPath);
1180
1223
  const buffer = readFileSync(localPath);
1181
- const contentType = guessContentType(fileName);
1182
- const presign = await apiRequest({
1183
- method: "POST",
1184
- host: ctx.host,
1185
- path: "/discover/templates/upload/presign",
1186
- auth: ctx.auth,
1187
- body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1188
- });
1189
- assertApiOk(presign, "inspiration-add (presign)");
1190
- let storageKey = presign.json?.storage_key;
1191
- let uploadedName = presign.json?.file_name || fileName;
1192
- if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1193
- const upload = presign.json.upload;
1194
- const put = await fetch(upload.url, {
1195
- method: upload.method || "PUT",
1196
- headers: upload.headers || {},
1197
- body: new Uint8Array(buffer)
1198
- });
1199
- if (!put.ok)
1200
- throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
1201
- }
1202
- else {
1203
- const form = new FormData();
1204
- form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1205
- const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1206
- method: "POST",
1207
- headers: buildAuthHeaders(ctx.auth),
1208
- body: form
1209
- });
1210
- const json = await res.json().catch(() => null);
1211
- if (!res.ok || !json?.storage_key) {
1212
- throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1213
- }
1214
- storageKey = json.storage_key;
1215
- uploadedName = json.file_name || fileName;
1216
- }
1217
- if (!storageKey)
1218
- throw new Error("inspiration-add upload did not return a storage key.");
1219
- const result = await apiRequest({
1220
- method: "POST",
1221
- host: ctx.host,
1222
- path: "/discover/templates",
1223
- auth: ctx.auth,
1224
- body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
1224
+ const result = await ingestUploadedVideo(ctx, {
1225
+ buffer,
1226
+ fileName: path.basename(localPath),
1227
+ title: parsed.values.title,
1228
+ tagline: parsed.values.tagline,
1229
+ notes: parsed.values.notes
1225
1230
  });
1226
- assertApiOk(result, "inspiration-add");
1227
1231
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1228
1232
  return;
1229
1233
  }
@@ -1269,6 +1273,259 @@ async function runInspirationDecomposeCommand(argv) {
1269
1273
  }
1270
1274
  emitResult(result, ctx.json);
1271
1275
  }
1276
+ // Presign → PUT the bytes (or server multipart fallback) → POST /discover/templates
1277
+ // with the storage key. Shared by `inspiration-add` (local file) and `create`
1278
+ // (a generated MP4 wrapped into a new template). Returns the ingest ApiResult.
1279
+ async function ingestUploadedVideo(ctx, input) {
1280
+ const { buffer, fileName } = input;
1281
+ const contentType = input.contentType || guessContentType(fileName);
1282
+ const presign = await apiRequest({
1283
+ method: "POST",
1284
+ host: ctx.host,
1285
+ path: "/discover/templates/upload/presign",
1286
+ auth: ctx.auth,
1287
+ body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1288
+ });
1289
+ assertApiOk(presign, "upload (presign)");
1290
+ let storageKey = presign.json?.storage_key;
1291
+ let uploadedName = presign.json?.file_name || fileName;
1292
+ if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1293
+ const upload = presign.json.upload;
1294
+ const put = await fetch(upload.url, {
1295
+ method: upload.method || "PUT",
1296
+ headers: upload.headers || {},
1297
+ body: new Uint8Array(buffer)
1298
+ });
1299
+ if (!put.ok)
1300
+ throw new Error(`upload failed with HTTP ${put.status}.`);
1301
+ }
1302
+ else {
1303
+ const form = new FormData();
1304
+ form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1305
+ const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1306
+ method: "POST",
1307
+ headers: buildAuthHeaders(ctx.auth),
1308
+ body: form
1309
+ });
1310
+ const json = await res.json().catch(() => null);
1311
+ if (!res.ok || !json?.storage_key) {
1312
+ throw new Error(`upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1313
+ }
1314
+ storageKey = json.storage_key;
1315
+ uploadedName = json.file_name || fileName;
1316
+ }
1317
+ if (!storageKey)
1318
+ throw new Error("upload did not return a storage key.");
1319
+ const result = await apiRequest({
1320
+ method: "POST",
1321
+ host: ctx.host,
1322
+ path: "/discover/templates",
1323
+ auth: ctx.auth,
1324
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
1325
+ });
1326
+ assertApiOk(result, "ingest");
1327
+ return result;
1328
+ }
1329
+ // Poll GET /api/v1/videos/:id (which finalizes the download job and mints the
1330
+ // template once the source video lands) until the inspiration is ready/failed.
1331
+ async function pollInspirationReady(ctx, inspirationId, timeoutMs = 5 * 60 * 1000) {
1332
+ const deadline = Date.now() + timeoutMs;
1333
+ let last = null;
1334
+ for (;;) {
1335
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/videos/${encodeURIComponent(inspirationId)}`, auth: ctx.auth });
1336
+ if (res.ok && res.json) {
1337
+ last = res.json;
1338
+ const status = String(last.status ?? "");
1339
+ if (status === "ready")
1340
+ return last;
1341
+ if (status === "failed")
1342
+ throw new Error(`source video download failed${last.error ? `: ${last.error}` : ""}.`);
1343
+ }
1344
+ if (Date.now() > deadline) {
1345
+ throw new Error("source video download did not finish in time — re-run, or check with `vidfarm videos --mine`.");
1346
+ }
1347
+ await sleep(5000);
1348
+ }
1349
+ }
1350
+ async function decomposeInspiration(ctx, inspirationId, userPrompt) {
1351
+ const result = await apiRequest({
1352
+ method: "POST",
1353
+ host: ctx.host,
1354
+ path: `/api/v1/inspirations/${encodeURIComponent(inspirationId)}/decompose`,
1355
+ auth: ctx.auth,
1356
+ body: { user_prompt: userPrompt }
1357
+ });
1358
+ assertApiOk(result, "decompose");
1359
+ return result;
1360
+ }
1361
+ async function forkTemplate(ctx, templateId, title) {
1362
+ const result = await apiRequest({
1363
+ method: "POST",
1364
+ host: ctx.host,
1365
+ path: "/api/v1/compositions",
1366
+ auth: ctx.auth,
1367
+ body: { template_id: templateId, title }
1368
+ });
1369
+ assertApiOk(result, "fork");
1370
+ return result;
1371
+ }
1372
+ // Print the final "your video is ready" block with the openable editor URL.
1373
+ function emitVideoResult(ctx, payload, openTemplate, forkId) {
1374
+ if (ctx.json) {
1375
+ printJson({ ok: true, ...payload });
1376
+ return;
1377
+ }
1378
+ printJson({ ok: true, ...payload });
1379
+ console.log("");
1380
+ console.log(` ${BOLD}Open editor ${RESET} ${FRONTEND}${editorFrontendUrl(ctx.host, openTemplate, forkId)}${RESET}`);
1381
+ if (forkId) {
1382
+ console.log(`${DIM}Edit with an agent on disk: vidfarm pull ${forkId} --dir .vidfarm/${forkId}${RESET}`);
1383
+ }
1384
+ }
1385
+ // `replicate <url> [--prompt "...and make it about X"]` — one command from a
1386
+ // TikTok/YT/IG/X URL to an editable recreation. Chains: ingest → wait for the
1387
+ // download + template mint → AI decompose/recreate (steered by --prompt) →
1388
+ // fork a personal editable copy → print the editor URL.
1389
+ async function runReplicateCommand(argv) {
1390
+ const parsed = parseArgs({
1391
+ args: argv,
1392
+ allowPositionals: true,
1393
+ options: { ...commonOptions(), prompt: { type: "string" }, title: { type: "string" }, "no-fork": { type: "boolean", default: false } }
1394
+ });
1395
+ const url = parsed.positionals[0];
1396
+ if (!url || !/^https?:\/\//i.test(url)) {
1397
+ throw new Error('replicate requires a video URL (TikTok/YouTube/Instagram/X): `vidfarm replicate <url> --prompt "..."`.');
1398
+ }
1399
+ const ctx = commonContext(parsed.values);
1400
+ const userPrompt = parsed.values.prompt?.trim() || null;
1401
+ // 1. Ingest the URL → private inspiration (downloads the source video).
1402
+ if (!ctx.json)
1403
+ process.stdout.write(`${DIM}Fetching source video… ${RESET}`);
1404
+ const add = await apiRequest({ method: "POST", host: ctx.host, path: "/discover/templates", auth: ctx.auth, body: { source_url: url } });
1405
+ assertApiOk(add, "replicate (ingest)");
1406
+ const inspirationId = add.json?.inspiration_id;
1407
+ if (!inspirationId)
1408
+ throw new Error("replicate: ingest did not return an inspiration id.");
1409
+ // 2. Wait for the download + template mint.
1410
+ const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
1411
+ if (!ctx.json)
1412
+ console.log(`${GREEN}done${RESET}`);
1413
+ const templateId = ready.template_id;
1414
+ if (!templateId)
1415
+ throw new Error("replicate: no template was minted from the source video.");
1416
+ // 3. Decompose (AI scene analysis + recreation), steered by --prompt.
1417
+ if (!ctx.json)
1418
+ process.stdout.write(`${DIM}Analyzing + recreating scenes (can take a minute)… ${RESET}`);
1419
+ const decomposed = await decomposeInspiration(ctx, inspirationId, userPrompt);
1420
+ if (!ctx.json)
1421
+ console.log(`${GREEN}done${RESET}`);
1422
+ const defaultForkId = decomposed.json?.default_fork_id;
1423
+ // 4. Fork a personal editable copy (unless --no-fork opens the shared base).
1424
+ let forkId = defaultForkId;
1425
+ let openTemplate = templateId;
1426
+ if (!parsed.values["no-fork"]) {
1427
+ const fork = await forkTemplate(ctx, templateId, parsed.values.title);
1428
+ forkId = fork.json?.fork_id ?? defaultForkId;
1429
+ openTemplate = fork.json?.template_id ?? templateId;
1430
+ }
1431
+ emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId }, openTemplate, forkId);
1432
+ }
1433
+ // `create "<prompt>"` — spin up a brand-new editable video from a text prompt
1434
+ // (no source URL). Chains: generate a base video via the video primitive → wrap
1435
+ // the MP4 into a new template (upload-ingest) → AI decompose into an editable
1436
+ // timeline → fork → print the editor URL. Requires an AI provider key (the
1437
+ // generate + decompose steps use it), same as the web editor.
1438
+ async function runCreateCommand(argv) {
1439
+ const parsed = parseArgs({
1440
+ args: argv,
1441
+ allowPositionals: true,
1442
+ options: {
1443
+ ...commonOptions(),
1444
+ prompt: { type: "string" },
1445
+ "aspect-ratio": { type: "string" },
1446
+ duration: { type: "string" },
1447
+ resolution: { type: "string" },
1448
+ provider: { type: "string" },
1449
+ model: { type: "string" },
1450
+ audio: { type: "boolean", default: false },
1451
+ ref: { type: "string", multiple: true },
1452
+ title: { type: "string" },
1453
+ "no-decompose": { type: "boolean", default: false },
1454
+ "no-fork": { type: "boolean", default: false }
1455
+ }
1456
+ });
1457
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
1458
+ if (!prompt)
1459
+ throw new Error('create requires a prompt: `vidfarm create "a 15s ad for my coffee brand"` (or --prompt "...").');
1460
+ const ctx = commonContext(parsed.values);
1461
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
1462
+ // 1. Generate the base video from the prompt.
1463
+ const payload = { prompt };
1464
+ if (parsed.values["aspect-ratio"])
1465
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
1466
+ if (parsed.values.provider)
1467
+ payload.provider = parsed.values.provider;
1468
+ if (parsed.values.model)
1469
+ payload.model = parsed.values.model;
1470
+ if (parsed.values.duration)
1471
+ payload.duration = Math.round(Number(parsed.values.duration));
1472
+ if (parsed.values.resolution)
1473
+ payload.resolution = parsed.values.resolution;
1474
+ if (parsed.values.audio)
1475
+ payload.generate_audio = true;
1476
+ if (refs.length)
1477
+ payload.input_references = refs.slice(0, 8);
1478
+ const tracer = `devcli-create-${Date.now().toString(36)}`;
1479
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/generate", auth: ctx.auth, body: { tracer, payload } });
1480
+ assertApiOk(submit, "create (generate)");
1481
+ const jobId = submit.json?.job_id;
1482
+ if (!jobId)
1483
+ throw new Error("create: generate did not return a job id.");
1484
+ if (!ctx.json)
1485
+ console.log(`${DIM}Generating base video (${jobId})… polling every 5s.${RESET}`);
1486
+ const job = await pollPrimitiveJob(ctx, jobId);
1487
+ const mediaUrl = resolveJobMediaUrl(job);
1488
+ if (!mediaUrl)
1489
+ throw new Error(`create: generation ${String(job?.status ?? "did not finish")} — no media URL. Check your provider keys and wallet.`);
1490
+ // 2. Download the generated MP4 and wrap it into a new template (upload-ingest
1491
+ // path — the same one `inspiration-add <file>` uses).
1492
+ if (!ctx.json)
1493
+ process.stdout.write(`${DIM}Building an editable template from the generated video… ${RESET}`);
1494
+ const dl = await fetch(mediaUrl, { headers: buildAuthHeaders(ctx.auth) });
1495
+ if (!dl.ok)
1496
+ throw new Error(`create: could not fetch the generated video (${dl.status}).`);
1497
+ const buffer = Buffer.from(await dl.arrayBuffer());
1498
+ const title = parsed.values.title ?? prompt.slice(0, 80);
1499
+ const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title });
1500
+ const inspirationId = add.json?.inspiration_id;
1501
+ if (!inspirationId)
1502
+ throw new Error("create: ingest did not return an inspiration id.");
1503
+ const ready = add.json?.status === "ready" ? add.json : await pollInspirationReady(ctx, inspirationId);
1504
+ if (!ctx.json)
1505
+ console.log(`${GREEN}done${RESET}`);
1506
+ const templateId = ready.template_id;
1507
+ if (!templateId)
1508
+ throw new Error("create: no template was minted from the generated video.");
1509
+ // 3. Decompose into an editable per-scene timeline (unless --no-decompose).
1510
+ let defaultForkId;
1511
+ if (!parsed.values["no-decompose"]) {
1512
+ if (!ctx.json)
1513
+ process.stdout.write(`${DIM}Analyzing into an editable timeline… ${RESET}`);
1514
+ const decomposed = await decomposeInspiration(ctx, inspirationId, prompt);
1515
+ defaultForkId = decomposed.json?.default_fork_id;
1516
+ if (!ctx.json)
1517
+ console.log(`${GREEN}done${RESET}`);
1518
+ }
1519
+ // 4. Fork a personal editable copy (unless --no-fork).
1520
+ let forkId = defaultForkId;
1521
+ let openTemplate = templateId;
1522
+ if (!parsed.values["no-fork"]) {
1523
+ const fork = await forkTemplate(ctx, templateId, parsed.values.title);
1524
+ forkId = fork.json?.fork_id ?? defaultForkId;
1525
+ openTemplate = fork.json?.template_id ?? templateId;
1526
+ }
1527
+ emitVideoResult(ctx, { inspiration_id: inspirationId, template_id: openTemplate, fork_id: forkId, default_fork_id: defaultForkId, generated_video_url: mediaUrl }, openTemplate, forkId);
1528
+ }
1272
1529
  // ── Composition lifecycle ───────────────────────────────────────────────────
1273
1530
  async function runForkCommand(argv) {
1274
1531
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" } } });
@@ -1294,17 +1551,96 @@ async function runDecomposeCommand(argv) {
1294
1551
  if (!forkId)
1295
1552
  throw new Error("decompose requires a fork id.");
1296
1553
  const ctx = commonContext(parsed.values);
1297
- const result = await apiRequest({
1554
+ const decomposePath = `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`;
1555
+ // Decompose can run longer than the 60s CloudFront origin timeout on long
1556
+ // videos. `force: true` KICKS a resumable run; the server keeps working even
1557
+ // if this request 504s. So we tolerate a non-`done` kick and poll GET
1558
+ // .../auto-decompose until the job is terminal.
1559
+ let result = await apiRequest({
1298
1560
  method: "POST",
1299
1561
  host: ctx.host,
1300
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`,
1562
+ path: decomposePath,
1301
1563
  auth: ctx.auth,
1302
- body: { mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
1564
+ body: { force: true, mode: parsed.values.mode, user_prompt: parsed.values.prompt ?? null }
1303
1565
  });
1566
+ const kickStatus = typeof result.json?.status === "string" ? result.json.status : null;
1567
+ if (!(result.ok && kickStatus === "done")) {
1568
+ if (!ctx.json)
1569
+ process.stdout.write(`${DIM}Decomposing (may take a minute on long videos)… ${RESET}`);
1570
+ const deadline = Date.now() + 12 * 60 * 1000;
1571
+ let settled = null;
1572
+ while (Date.now() < deadline) {
1573
+ await sleep(5000);
1574
+ let statusResult;
1575
+ try {
1576
+ statusResult = await apiRequest({ method: "GET", host: ctx.host, path: decomposePath, auth: ctx.auth });
1577
+ }
1578
+ catch {
1579
+ continue;
1580
+ }
1581
+ const s = typeof statusResult.json?.status === "string" ? statusResult.json.status : "none";
1582
+ if (s === "done" || s === "failed") {
1583
+ settled = statusResult;
1584
+ break;
1585
+ }
1586
+ if (s === "none")
1587
+ break; // the run never claimed — surface the kick error below
1588
+ }
1589
+ if (settled) {
1590
+ result = settled;
1591
+ if (!ctx.json)
1592
+ console.log(settled.json?.status === "done" ? `${GREEN}done${RESET}` : `${RED}failed${RESET}`);
1593
+ }
1594
+ else {
1595
+ // Timed out or never started — surface the kick's own error if it was one.
1596
+ assertApiOk(result, "decompose");
1597
+ throw new Error("decompose did not complete in time (it may still finish server-side; re-run `vidfarm decompose` to check).");
1598
+ }
1599
+ }
1600
+ if (result.json?.status === "failed") {
1601
+ throw new Error(`decompose failed: ${result.json?.error ?? "unknown error"}`);
1602
+ }
1304
1603
  assertApiOk(result, "decompose");
1305
1604
  if (!ctx.json && result.json?.ghostcut_pending) {
1306
1605
  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}`);
1307
1606
  }
1607
+ // The scene-annotation pass (per-scene clip-match + recreation DNA + content
1608
+ // format) is seeded "pending" by decompose and runs as its own bounded vision
1609
+ // call — kept out of the decompose request so that stays under the origin
1610
+ // timeout. Drive it to completion here so CLI/agent users get annotations
1611
+ // without needing the editor. Each poll is one call; usually one round
1612
+ // finishes it. Best-effort: transient failures leave it pending for another
1613
+ // client (or a later `vidfarm pull`) to finish.
1614
+ if (!ctx.json)
1615
+ process.stdout.write(`${DIM}Annotating scenes (clip-match + recreation DNA)… ${RESET}`);
1616
+ let annotationStatus = "pending";
1617
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1618
+ let poll;
1619
+ try {
1620
+ poll = await apiRequest({
1621
+ method: "POST",
1622
+ host: ctx.host,
1623
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/scene-annotations-poll`,
1624
+ auth: ctx.auth,
1625
+ body: {}
1626
+ });
1627
+ }
1628
+ catch {
1629
+ break;
1630
+ }
1631
+ annotationStatus = typeof poll.json?.status === "string" ? poll.json.status : "none";
1632
+ if (annotationStatus === "none" || annotationStatus === "done" || annotationStatus === "failed")
1633
+ break;
1634
+ await sleep(3000);
1635
+ }
1636
+ if (!ctx.json) {
1637
+ if (annotationStatus === "done")
1638
+ console.log(`${GREEN}done${RESET}`);
1639
+ else if (annotationStatus === "failed")
1640
+ console.log(`${RED}failed${RESET} ${DIM}(run \`vidfarm api POST /api/v1/compositions/${forkId}/scene-annotations-poll\` to retry)${RESET}`);
1641
+ else
1642
+ console.log(`${DIM}still running — poll .../scene-annotations-poll until done${RESET}`);
1643
+ }
1308
1644
  emitResult(result, ctx.json);
1309
1645
  }
1310
1646
  async function runRemoveVideoCaptionsCommand(argv) {
@@ -1827,8 +2163,9 @@ async function runPullCommand(argv) {
1827
2163
  const info = inspectComposition(readFileSync(htmlPath, "utf8"));
1828
2164
  const hasContext = existsSync(path.join(dir, "video-context.json"));
1829
2165
  const hasCast = existsSync(path.join(dir, "cast.json"));
2166
+ const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
1830
2167
  if (ctx.json) {
1831
- printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
2168
+ printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, ...info });
1832
2169
  return;
1833
2170
  }
1834
2171
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -1839,7 +2176,7 @@ async function runPullCommand(argv) {
1839
2176
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
1840
2177
  console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
1841
2178
  }
1842
- console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
2179
+ console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}.${RESET}`);
1843
2180
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
1844
2181
  }
1845
2182
  async function runVisibilityCommand(argv) {
@@ -108,6 +108,12 @@ const schema = z.object({
108
108
  VIDFARM_RATE_LIMITS_TABLE_NAME: z.string().optional(),
109
109
  VIDFARM_SERVERLESS_MAIN_TABLE_NAME: z.string().optional(),
110
110
  VIDFARM_JOB_STATE_MACHINE_ARN: z.string().optional(),
111
+ // Clip-curation scan pipeline (Step Functions). Optional: when unset, the
112
+ // /clips/scan route records the scan request but doesn't dispatch the machine.
113
+ VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN: z.string().optional(),
114
+ VIDFARM_CLIP_VECTOR_BACKEND: z.enum(["dynamo", "s3vectors"]).default("dynamo"),
115
+ VIDFARM_CLIP_VECTORS_BUCKET: z.string().optional(),
116
+ VIDFARM_CLIP_VECTORS_INDEX: z.string().optional(),
111
117
  SENTRY_DSN: z.string().optional(),
112
118
  SENTRY_ENVIRONMENT: z.string().optional(),
113
119
  SENTRY_RELEASE: z.string().optional(),