@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.
- package/README.md +8 -0
- package/demo/dist/app.js +81 -53
- package/dist/src/app.js +1010 -8
- package/dist/src/cli.js +390 -53
- package/dist/src/config.js +6 -0
- package/dist/src/devcli/clip-store.js +335 -0
- package/dist/src/devcli/clips.js +803 -0
- package/dist/src/devcli/telemetry.js +236 -0
- package/dist/src/frontend/homepage-view.js +5 -0
- package/dist/src/frontend/template-editor-chat.js +212 -0
- package/dist/src/services/clip-curation/cost.js +112 -0
- package/dist/src/services/clip-curation/ffmpeg.js +258 -0
- package/dist/src/services/clip-curation/gemini.js +342 -0
- package/dist/src/services/clip-curation/index.js +15 -0
- package/dist/src/services/clip-curation/presets.js +20 -0
- package/dist/src/services/clip-curation/presets.v1.json +59 -0
- package/dist/src/services/clip-curation/query.js +163 -0
- package/dist/src/services/clip-curation/refine.js +136 -0
- package/dist/src/services/clip-curation/scan.js +137 -0
- package/dist/src/services/clip-curation/taxonomy.js +71 -0
- package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
- package/dist/src/services/clip-curation/types.js +7 -0
- package/dist/src/services/clip-records.js +209 -0
- package/dist/src/services/clip-search.js +47 -0
- package/dist/src/services/clip-vectors.js +125 -0
- package/dist/src/services/hyperframes.js +369 -9
- package/dist/src/services/scene-annotations.js +32 -0
- package/package.json +5 -1
- package/public/assets/homepage-client-app.js +17 -17
- 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
|
-
|
|
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
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
|
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:
|
|
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) {
|
package/dist/src/config.js
CHANGED
|
@@ -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(),
|