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