@mevdragon/vidfarm-devcli 0.8.0 → 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.
package/dist/src/app.js CHANGED
@@ -4096,6 +4096,28 @@ function createEditorFrontendActionTool(input) {
4096
4096
  }
4097
4097
  });
4098
4098
  }
4099
+ // Discover-chat only: lets the AI create a BRAND-NEW video for the user, either
4100
+ // from a text prompt (mode=generate) or by replicating a pasted video URL
4101
+ // (mode=replicate). The tool itself is a passthrough — it just structures the
4102
+ // user's intent; the chat frontend runs the ingest→decompose→fork pipeline and
4103
+ // drops an "Open in editor" link into the thread when it's ready.
4104
+ function createVideoCreationTool() {
4105
+ return tool({
4106
+ description: "Create a NEW editable video for the user from scratch. Call this the MOMENT the user asks to \"create/make a video of ...\" (set mode=generate and put the idea in `prompt`) OR to \"replicate/recreate/remix this video\" from a pasted TikTok/YouTube/Instagram/X URL (set mode=replicate, put the URL in `source_url`, and put any requested changes in `instructions`). This kicks off video creation in the browser and posts an 'Open in editor' link to the chat when the new video is ready — you do not need to poll or call any other tool. This is the ONLY way to make a brand-new video; the editor tools only edit an already-open composition.",
4107
+ inputSchema: z.object({
4108
+ mode: z.enum(["generate", "replicate"]).describe("generate = brand-new from a text prompt; replicate = recreate a pasted video URL."),
4109
+ prompt: z.string().optional().describe("For mode=generate: what the video should be about."),
4110
+ source_url: z.string().optional().describe("For mode=replicate: the TikTok/YouTube/Instagram/X video URL to recreate."),
4111
+ instructions: z.string().optional().describe("Optional changes to apply while creating/recreating (e.g. 'make it about my coffee brand', 'change the hook to ...').")
4112
+ }),
4113
+ execute: async ({ mode, prompt, source_url, instructions }) => ({
4114
+ mode,
4115
+ prompt: prompt ?? null,
4116
+ source_url: source_url ?? null,
4117
+ instructions: instructions ?? null
4118
+ })
4119
+ });
4120
+ }
4099
4121
  function createEditorActionTool() {
4100
4122
  return tool({
4101
4123
  description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url.",
@@ -4303,7 +4325,12 @@ async function streamEditorChatAgent(input, send) {
4303
4325
  frontend_action: createEditorFrontendActionTool({
4304
4326
  tracerRegistry
4305
4327
  }),
4306
- editor_action: createEditorActionTool()
4328
+ editor_action: createEditorActionTool(),
4329
+ // Discover/brainstorm surface only: "create me a video of…" / "replicate
4330
+ // this URL…". The editor surfaces edit an already-open composition.
4331
+ ...(input.template.templateId === "chat-brainstorm"
4332
+ ? { create_video: createVideoCreationTool() }
4333
+ : {})
4307
4334
  };
4308
4335
  })()
4309
4336
  : undefined;
@@ -6536,6 +6563,66 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
6536
6563
  }
6537
6564
  });
6538
6565
  });
6566
+ // A "processing" state older than this is treated as abandoned (crashed lambda)
6567
+ // so a forced re-run may start. Must exceed the longest real decompose but stay
6568
+ // under the API lambda's 15-min timeout.
6569
+ const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
6570
+ async function readDecomposeJobState(forkId) {
6571
+ const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
6572
+ if (!text)
6573
+ return null;
6574
+ try {
6575
+ return JSON.parse(text);
6576
+ }
6577
+ catch {
6578
+ return null;
6579
+ }
6580
+ }
6581
+ async function writeDecomposeJobState(forkId, state) {
6582
+ try {
6583
+ await storage.putJson(storage.compositionForkWorkingKey(forkId, "decompose.json"), state);
6584
+ }
6585
+ catch (error) {
6586
+ console.error("decompose.json write failed", error instanceof Error ? error.message : error);
6587
+ }
6588
+ }
6589
+ function decomposeDoneResponse(job, extra = {}) {
6590
+ return {
6591
+ ok: true,
6592
+ status: "done",
6593
+ mode: "smart",
6594
+ provider: job.provider ?? null,
6595
+ model: job.model ?? null,
6596
+ scene_count: job.sceneCount ?? 0,
6597
+ caption_count: job.captionCount ?? 0,
6598
+ duration_seconds: job.durationSeconds ?? null,
6599
+ became_default_fork: job.becameDefaultFork ?? false,
6600
+ ghostcut_pending: job.ghostcutPending ?? false,
6601
+ ...extra
6602
+ };
6603
+ }
6604
+ // Read-only decompose job status — the client's poll target while a decompose
6605
+ // runs (or after a 504). Fast: just reads decompose.json.
6606
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6607
+ const caller = await getBrowserCustomer(c);
6608
+ try {
6609
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: caller?.id ?? null, shareToken: readShareToken(c) }, "view");
6610
+ const job = await readDecomposeJobState(access.fork.id);
6611
+ if (!job)
6612
+ return c.json({ ok: true, status: "none" });
6613
+ if (job.status === "done")
6614
+ return c.json(decomposeDoneResponse(job, { resumed: true }));
6615
+ if (job.status === "failed")
6616
+ return c.json({ ok: true, status: "failed", error: job.failureReason ?? "Decompose failed." });
6617
+ const stale = Date.now() - (job.updatedAtMs ?? job.startedAtMs ?? 0) >= DECOMPOSE_PROCESSING_STALE_MS;
6618
+ return c.json({ ok: true, status: "processing", stale, started_at_ms: job.startedAtMs });
6619
+ }
6620
+ catch (error) {
6621
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
6622
+ return forkAccessErrorResponse(c, error);
6623
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
6624
+ }
6625
+ });
6539
6626
  app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6540
6627
  const caller = await getBrowserCustomer(c);
6541
6628
  if (!caller)
@@ -6552,6 +6639,36 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6552
6639
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
6553
6640
  const userPromptRaw = typeof body.user_prompt === "string" ? body.user_prompt.trim() : "";
6554
6641
  const userPrompt = userPromptRaw.length > 0 ? userPromptRaw.slice(0, 4000) : null;
6642
+ // Idempotency / resumability guard. POST STARTS a decompose (idempotently);
6643
+ // GET /auto-decompose is the poll. An in-flight run always wins so a client's
6644
+ // post-504 retry (or a second caller) never starts a duplicate. A completed
6645
+ // job is returned from cache unless `force` (the editor's decompose click)
6646
+ // asks for a fresh re-run. Absent / failed / abandoned jobs (re)start — so a
6647
+ // plain POST with no `force` still kicks the first decompose (back-compat for
6648
+ // the editor-chat copilot's http_request call).
6649
+ const forceRun = body.force === true;
6650
+ const existingJob = await readDecomposeJobState(fork.id);
6651
+ const jobFresh = existingJob
6652
+ ? Date.now() - (existingJob.updatedAtMs ?? existingJob.startedAtMs ?? 0) < DECOMPOSE_PROCESSING_STALE_MS
6653
+ : false;
6654
+ if (existingJob?.status === "processing" && jobFresh) {
6655
+ return c.json({ ok: true, status: "processing", started_at_ms: existingJob.startedAtMs });
6656
+ }
6657
+ if (existingJob?.status === "done" && !forceRun) {
6658
+ return c.json(decomposeDoneResponse(existingJob, { resumed: true }));
6659
+ }
6660
+ // Claim the job so concurrent polls/kicks back off. A local helper stamps
6661
+ // "failed" on every early-error return so a poller never hangs on processing.
6662
+ await writeDecomposeJobState(fork.id, { status: "processing", startedAtMs, updatedAtMs: Date.now() });
6663
+ const failDecompose = async (payload, statusCode) => {
6664
+ await writeDecomposeJobState(fork.id, {
6665
+ status: "failed",
6666
+ startedAtMs,
6667
+ updatedAtMs: Date.now(),
6668
+ failureReason: typeof payload.error === "string" ? payload.error : "Auto-decompose failed."
6669
+ });
6670
+ return c.json(payload, statusCode);
6671
+ };
6555
6672
  // Parse the current composition HTML with a real DOM (linkedom) so
6556
6673
  // attribute values come back UNESCAPED — `data-source-video="foo?a=1&amp;b=2"`
6557
6674
  // reads as `foo?a=1&b=2`, which is what ghostcut and the vision extractor
@@ -6588,7 +6705,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6588
6705
  }
6589
6706
  catch (error) {
6590
6707
  if (error instanceof MediaDurationExceededError) {
6591
- return c.json({
6708
+ return failDecompose({
6592
6709
  ok: false,
6593
6710
  error: `Source video is ${error.durationSeconds.toFixed(1)}s — Vidfarm caps auto-decompose at ${error.maxDurationSeconds}s. Trim the source before forking.`,
6594
6711
  code: "source_too_long",
@@ -6706,13 +6823,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6706
6823
  const customerKeys = await loadCustomerVisionKeys(caller.id);
6707
6824
  const hasAnyKey = Boolean(customerKeys.gemini || customerKeys.openai || customerKeys.openrouter);
6708
6825
  if (!hasAnyKey) {
6709
- return c.json({
6826
+ return failDecompose({
6710
6827
  ok: false,
6711
6828
  error: "Decompose requires an AI provider key. Add a Gemini, OpenAI, or OpenRouter key in Settings and try again."
6712
6829
  }, 400);
6713
6830
  }
6714
6831
  if (!sourceUrl) {
6715
- return c.json({ ok: false, error: "Composition has no source video URL." }, 400);
6832
+ return failDecompose({ ok: false, error: "Composition has no source video URL." }, 400);
6716
6833
  }
6717
6834
  const inspirationForTagline = forkInspirationId
6718
6835
  ? await serverlessRecords.getInspiration(forkInspirationId)
@@ -6742,13 +6859,13 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6742
6859
  }
6743
6860
  catch (error) {
6744
6861
  console.error("smart decompose failed", error instanceof Error ? error.message : error);
6745
- return c.json({
6862
+ return failDecompose({
6746
6863
  ok: false,
6747
6864
  error: error instanceof Error ? error.message : "Smart decompose failed."
6748
6865
  }, 502);
6749
6866
  }
6750
6867
  if (smart.scenes.length === 0 && smart.captions.length === 0) {
6751
- return c.json({
6868
+ return failDecompose({
6752
6869
  ok: false,
6753
6870
  error: "Smart decompose returned no scenes. Try again with different guidance."
6754
6871
  }, 502);
@@ -6894,8 +7011,23 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6894
7011
  }
6895
7012
  }
6896
7013
  await chargeDecomposeUsage({ mode: "smart" });
7014
+ // Stamp the job "done" so a client whose request 504'd mid-run (or any later
7015
+ // poll) resolves to completion and reloads the composition.
7016
+ await writeDecomposeJobState(fork.id, {
7017
+ status: "done",
7018
+ startedAtMs,
7019
+ updatedAtMs: Date.now(),
7020
+ provider: smart.provider,
7021
+ model: smart.model,
7022
+ sceneCount: smart.scenes.length,
7023
+ captionCount: smart.captions.length,
7024
+ durationSeconds: smart.durationSeconds,
7025
+ becameDefaultFork: becameDefault,
7026
+ ghostcutPending: ghostcutTaskId !== null
7027
+ });
6897
7028
  return c.json({
6898
7029
  ok: true,
7030
+ status: "done",
6899
7031
  mode: "smart",
6900
7032
  provider: smart.provider,
6901
7033
  model: smart.model,
@@ -6911,6 +7043,15 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
6911
7043
  catch (error) {
6912
7044
  if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
6913
7045
  return forkAccessErrorResponse(c, error);
7046
+ // Persist a failed job (best-effort) so the client's poll reads a terminal
7047
+ // failure instead of hanging on "processing". Safe: only reached after the
7048
+ // caller passed access control.
7049
+ await writeDecomposeJobState(c.req.param("forkId"), {
7050
+ status: "failed",
7051
+ startedAtMs,
7052
+ updatedAtMs: Date.now(),
7053
+ failureReason: error instanceof Error ? error.message : "Auto-decompose failed."
7054
+ });
6914
7055
  return c.json({ ok: false, error: error instanceof Error ? error.message : "Auto-decompose failed." }, 500);
6915
7056
  }
6916
7057
  });
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,...}
@@ -232,8 +246,11 @@ Publish-mode options:
232
246
  --message <text> Message attached to the published version snapshot
233
247
  --no-snapshot Only update the working copy; skip the version snapshot
234
248
  `;
235
- void main().catch((error) => {
249
+ void main().catch(async (error) => {
236
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);
237
254
  process.exit(1);
238
255
  });
239
256
  async function main() {
@@ -243,6 +260,9 @@ async function main() {
243
260
  return;
244
261
  }
245
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);
246
266
  const rest = argv.slice(1);
247
267
  switch (command) {
248
268
  // `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
@@ -273,6 +293,12 @@ async function main() {
273
293
  case "inspiration-decompose":
274
294
  await runInspirationDecomposeCommand(rest);
275
295
  return;
296
+ case "replicate":
297
+ await runReplicateCommand(rest);
298
+ return;
299
+ case "create":
300
+ await runCreateCommand(rest);
301
+ return;
276
302
  case "fork":
277
303
  await runForkCommand(rest);
278
304
  return;
@@ -1194,54 +1220,14 @@ async function runInspirationAddCommand(argv) {
1194
1220
  if (!existsSync(localPath)) {
1195
1221
  throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
1196
1222
  }
1197
- const fileName = path.basename(localPath);
1198
1223
  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 }
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
1243
1230
  });
1244
- assertApiOk(result, "inspiration-add");
1245
1231
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1246
1232
  return;
1247
1233
  }
@@ -1287,6 +1273,259 @@ async function runInspirationDecomposeCommand(argv) {
1287
1273
  }
1288
1274
  emitResult(result, ctx.json);
1289
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
+ }
1290
1529
  // ── Composition lifecycle ───────────────────────────────────────────────────
1291
1530
  async function runForkCommand(argv) {
1292
1531
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" } } });
@@ -1312,13 +1551,55 @@ async function runDecomposeCommand(argv) {
1312
1551
  if (!forkId)
1313
1552
  throw new Error("decompose requires a fork id.");
1314
1553
  const ctx = commonContext(parsed.values);
1315
- 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({
1316
1560
  method: "POST",
1317
1561
  host: ctx.host,
1318
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/auto-decompose`,
1562
+ path: decomposePath,
1319
1563
  auth: ctx.auth,
1320
- 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 }
1321
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
+ }
1322
1603
  assertApiOk(result, "decompose");
1323
1604
  if (!ctx.json && result.json?.ghostcut_pending) {
1324
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}`);