@mevdragon/vidfarm-devcli 0.5.0 → 0.5.2
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/SKILL.director.md +107 -15
- package/SKILL.platform.md +15 -11
- package/dist/src/cli.js +550 -7
- package/dist/src/devcli/composition-edit.js +287 -0
- package/package.json +2 -5
package/dist/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
|
|
10
11
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
11
12
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
12
13
|
// records + storage) so power users edit compositions on disk while a browser
|
|
@@ -40,7 +41,8 @@ Usage:
|
|
|
40
41
|
|
|
41
42
|
Local editor loop:
|
|
42
43
|
serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
|
|
43
|
-
Records + storage run on disk; render
|
|
44
|
+
Records + storage run on disk; render runs in-process on
|
|
45
|
+
this box for FREE (no cloud, no wallet charge).
|
|
44
46
|
With a template_id or --fork, pulls that composition
|
|
45
47
|
(or the template's default/shared decomposition) from
|
|
46
48
|
the cloud host onto local disk to edit offline.
|
|
@@ -56,19 +58,59 @@ Local editor loop:
|
|
|
56
58
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
57
59
|
|
|
58
60
|
Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
59
|
-
discover
|
|
61
|
+
discover [query...] List/search TEMPLATES (best for an offer) → GET /discover/feed
|
|
62
|
+
--query <text> Keyword search (e.g. "weight loss app")
|
|
63
|
+
--limit <n> Max results
|
|
64
|
+
videos [query...] List/search source INSPIRATIONS → GET /api/v1/videos
|
|
65
|
+
--query <text> Keyword search over the catalog
|
|
66
|
+
--mine Only your own uploaded inspirations
|
|
67
|
+
--limit <n> Max results
|
|
60
68
|
inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
|
|
61
69
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
62
70
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
63
71
|
|
|
64
72
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
65
73
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
74
|
+
pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
|
|
75
|
+
+ cast.json to disk, then print timeline gaps
|
|
76
|
+
(blank space) and scene/layer keys to target.
|
|
77
|
+
--dir <path> Local dir (default: .vidfarm/<forkId>)
|
|
78
|
+
--refetch Overwrite local copies with the cloud version
|
|
66
79
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
80
|
+
|
|
81
|
+
Generate AI media and drop it on the timeline (for local coding agents):
|
|
82
|
+
generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
|
|
83
|
+
--prompt <text> What to generate (required)
|
|
84
|
+
--ref <url|@file> Reference image (repeatable): input_references
|
|
85
|
+
for video, prompt_attachments for image. @file
|
|
86
|
+
auto-uploads a local image and uses its URL.
|
|
87
|
+
--aspect-ratio <r> e.g. 9:16 (match the composition canvas)
|
|
88
|
+
--duration <n> Video only: seconds of footage (e.g. 4)
|
|
89
|
+
--resolution <r> Video only: 480p|720p|1080p|1K|2K|4K
|
|
90
|
+
--audio Video only: generate an audio track
|
|
91
|
+
--provider <p> openai|gemini|openrouter (omit to auto-pick)
|
|
92
|
+
--model <m> Model override (omit for provider defaults)
|
|
93
|
+
--no-wait Return the job id instead of polling to the URL
|
|
94
|
+
--place <dir> After generating, place it into that composition
|
|
95
|
+
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
96
|
+
place <dir|composition.html> Insert a media URL into a local composition
|
|
97
|
+
(fill a gap or replace a scene) — same clip markup
|
|
98
|
+
the browser editor makes; serve live-morphs it.
|
|
99
|
+
--src <url> Direct media URL to place (required)
|
|
100
|
+
--kind <video|image> Media kind (inferred from the URL if omitted)
|
|
101
|
+
--at <time> Start it at this time (seconds / mm:ss) — fill a gap
|
|
102
|
+
--replace <layer_key> Replace this scene/layer (copies its timing+geometry)
|
|
103
|
+
--duration <n> Timeline length in seconds
|
|
104
|
+
--track <n> Timeline layer index (auto if omitted)
|
|
105
|
+
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
106
|
+
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
67
107
|
ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
|
|
68
108
|
decomposed (caption-free), non-billing
|
|
69
109
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
70
110
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
71
|
-
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/
|
|
111
|
+
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
112
|
+
Cloud render costs ~$0.01-$0.10; against a local
|
|
113
|
+
'serve' host it renders in-process for FREE.
|
|
72
114
|
render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
|
|
73
115
|
visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
|
|
74
116
|
clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
|
|
@@ -96,13 +138,30 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
96
138
|
--print Print the skill to stdout instead of writing a file
|
|
97
139
|
|
|
98
140
|
Files (multi-step flows the devcli handles for you):
|
|
99
|
-
upload <file> Upload
|
|
141
|
+
upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
|
|
100
142
|
--folder <path> Namescope the upload under a product/offer folder
|
|
143
|
+
put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
|
|
144
|
+
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
145
|
+
--as <name> Name the saved file (required for --content/--stdin)
|
|
146
|
+
--content <text> Inline text to save (needs --as); or pipe stdin with --stdin
|
|
147
|
+
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
148
|
+
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
101
149
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
102
150
|
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
103
151
|
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
104
152
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
105
153
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
154
|
+
put-file / get-file / files are the My Files (persistent) read+write set;
|
|
155
|
+
upload is the throwaway temp store for dropping media into a composition.
|
|
156
|
+
|
|
157
|
+
Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
|
|
158
|
+
free Reuse a decomposed template, swap captions/images/existing MP4s
|
|
159
|
+
(My Files / your computer / web search), render on a local 'serve' box.
|
|
160
|
+
~$0.001-$0.03 Same reuse, but cloud 'render' (~$0.01-$0.10). Image gen is cheap too.
|
|
161
|
+
~$1 AI-generate a few scenes for specificity (generate video ...).
|
|
162
|
+
$10+ Heavy AI generation (many/long AI clips, custom characters).
|
|
163
|
+
Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
|
|
164
|
+
first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
|
|
106
165
|
|
|
107
166
|
Escape hatch — call ANY route directly:
|
|
108
167
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
@@ -158,6 +217,9 @@ async function main() {
|
|
|
158
217
|
case "discover":
|
|
159
218
|
await runDiscoverCommand(rest);
|
|
160
219
|
return;
|
|
220
|
+
case "videos":
|
|
221
|
+
await runVideosCommand(rest);
|
|
222
|
+
return;
|
|
161
223
|
case "inspiration-add":
|
|
162
224
|
await runInspirationAddCommand(rest);
|
|
163
225
|
return;
|
|
@@ -170,6 +232,15 @@ async function main() {
|
|
|
170
232
|
case "fork":
|
|
171
233
|
await runForkCommand(rest);
|
|
172
234
|
return;
|
|
235
|
+
case "pull":
|
|
236
|
+
await runPullCommand(rest);
|
|
237
|
+
return;
|
|
238
|
+
case "generate":
|
|
239
|
+
await runGenerateCommand(rest);
|
|
240
|
+
return;
|
|
241
|
+
case "place":
|
|
242
|
+
await runPlaceCommand(rest);
|
|
243
|
+
return;
|
|
173
244
|
case "decompose":
|
|
174
245
|
await runDecomposeCommand(rest);
|
|
175
246
|
return;
|
|
@@ -243,6 +314,10 @@ async function main() {
|
|
|
243
314
|
case "get-file":
|
|
244
315
|
await runGetFileCommand(rest);
|
|
245
316
|
return;
|
|
317
|
+
case "put-file":
|
|
318
|
+
case "save-file":
|
|
319
|
+
await runPutFileCommand(rest);
|
|
320
|
+
return;
|
|
246
321
|
default:
|
|
247
322
|
if (!command.startsWith("-")) {
|
|
248
323
|
// Positional template id → default to booting the local editor.
|
|
@@ -972,6 +1047,39 @@ function safeSize(target) {
|
|
|
972
1047
|
return 0;
|
|
973
1048
|
}
|
|
974
1049
|
}
|
|
1050
|
+
// Guess a content type from a file name so My Files stores text docs as text/*
|
|
1051
|
+
// (a bare multipart Blob otherwise defaults to application/octet-stream, which
|
|
1052
|
+
// makes browsers download .md/.txt instead of showing them). Mirrors the common
|
|
1053
|
+
// cases of the server's inferAttachmentContentType; returns "" when unknown so
|
|
1054
|
+
// the server can fall back to its own inference.
|
|
1055
|
+
function guessContentType(fileName) {
|
|
1056
|
+
const dot = fileName.lastIndexOf(".");
|
|
1057
|
+
const ext = dot >= 0 ? fileName.slice(dot).toLowerCase() : "";
|
|
1058
|
+
switch (ext) {
|
|
1059
|
+
case ".md": return "text/markdown; charset=utf-8";
|
|
1060
|
+
case ".txt": return "text/plain; charset=utf-8";
|
|
1061
|
+
case ".csv": return "text/csv; charset=utf-8";
|
|
1062
|
+
case ".json": return "application/json";
|
|
1063
|
+
case ".srt":
|
|
1064
|
+
case ".vtt": return "text/plain; charset=utf-8";
|
|
1065
|
+
case ".png": return "image/png";
|
|
1066
|
+
case ".jpg":
|
|
1067
|
+
case ".jpeg": return "image/jpeg";
|
|
1068
|
+
case ".gif": return "image/gif";
|
|
1069
|
+
case ".webp": return "image/webp";
|
|
1070
|
+
case ".svg": return "image/svg+xml";
|
|
1071
|
+
case ".mp4":
|
|
1072
|
+
case ".m4v": return "video/mp4";
|
|
1073
|
+
case ".mov": return "video/quicktime";
|
|
1074
|
+
case ".webm": return "video/webm";
|
|
1075
|
+
case ".mp3": return "audio/mpeg";
|
|
1076
|
+
case ".wav": return "audio/wav";
|
|
1077
|
+
case ".m4a": return "audio/mp4";
|
|
1078
|
+
case ".aac": return "audio/aac";
|
|
1079
|
+
case ".pdf": return "application/pdf";
|
|
1080
|
+
default: return "";
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
975
1083
|
function formatBytes(bytes) {
|
|
976
1084
|
if (bytes >= 1024 * 1024)
|
|
977
1085
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -1032,13 +1140,33 @@ async function runApiCommand(argv) {
|
|
|
1032
1140
|
process.exitCode = 1;
|
|
1033
1141
|
}
|
|
1034
1142
|
// ── Discover & inspiration ──────────────────────────────────────────────────
|
|
1143
|
+
// `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
|
|
1144
|
+
// CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
|
|
1145
|
+
// result carries decompose-derived promotions/keywords/summary to reason over.
|
|
1035
1146
|
async function runDiscoverCommand(argv) {
|
|
1036
|
-
const parsed = parseArgs({ args: argv, allowPositionals:
|
|
1147
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" } } });
|
|
1037
1148
|
const ctx = commonContext(parsed.values);
|
|
1038
|
-
const
|
|
1149
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1150
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/discover/feed", auth: ctx.auth, query: { limit: parsed.values.limit, q: query } });
|
|
1039
1151
|
assertApiOk(result, "discover");
|
|
1040
1152
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1041
1153
|
}
|
|
1154
|
+
// `videos` lists source INSPIRATIONS (the raw viral videos), with the same
|
|
1155
|
+
// keyword search and an optional --mine for the caller's own uploads.
|
|
1156
|
+
async function runVideosCommand(argv) {
|
|
1157
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" }, mine: { type: "boolean", default: false } } });
|
|
1158
|
+
const ctx = commonContext(parsed.values);
|
|
1159
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1160
|
+
const result = await apiRequest({
|
|
1161
|
+
method: "GET",
|
|
1162
|
+
host: ctx.host,
|
|
1163
|
+
path: "/api/v1/videos",
|
|
1164
|
+
auth: ctx.auth,
|
|
1165
|
+
query: { limit: parsed.values.limit, q: query, mine: parsed.values.mine ? "true" : undefined }
|
|
1166
|
+
});
|
|
1167
|
+
assertApiOk(result, "videos");
|
|
1168
|
+
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1169
|
+
}
|
|
1042
1170
|
async function runInspirationAddCommand(argv) {
|
|
1043
1171
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1044
1172
|
const sourceUrl = parsed.positionals[0];
|
|
@@ -1168,7 +1296,7 @@ async function runRenderCommand(argv) {
|
|
|
1168
1296
|
const result = await apiRequest({
|
|
1169
1297
|
method: "POST",
|
|
1170
1298
|
host: ctx.host,
|
|
1171
|
-
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/
|
|
1299
|
+
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
|
|
1172
1300
|
auth: ctx.auth,
|
|
1173
1301
|
body: { title: parsed.values.title, tracer: parsed.values.tracer }
|
|
1174
1302
|
});
|
|
@@ -1212,6 +1340,351 @@ async function runRenderStatusCommand(argv) {
|
|
|
1212
1340
|
assertApiOk(result, "render-status");
|
|
1213
1341
|
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
|
|
1214
1342
|
}
|
|
1343
|
+
// ── AI generation + timeline placement (for local coding agents) ────────────
|
|
1344
|
+
// `generate` submits an AI image/video primitive job and (by default) polls it
|
|
1345
|
+
// to the finished media URL — the async poll the raw `api` command can't do for
|
|
1346
|
+
// you. `place` drops any media URL into a local composition.html (fill a gap or
|
|
1347
|
+
// replace a scene), producing the SAME clip markup the browser editor makes, so
|
|
1348
|
+
// `serve` live-morphs it and `publish` pushes it. Together they let a Claude
|
|
1349
|
+
// Code / Codex agent do end-to-end "generate → insert" without hand-editing HTML.
|
|
1350
|
+
// Parse "5", "5.5", "mm:ss", or "hh:mm:ss" into seconds.
|
|
1351
|
+
function parseTimeToSeconds(raw) {
|
|
1352
|
+
const trimmed = raw.trim();
|
|
1353
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
1354
|
+
return Number(trimmed);
|
|
1355
|
+
const parts = trimmed.split(":").map((p) => Number(p));
|
|
1356
|
+
if (parts.some((n) => !Number.isFinite(n)))
|
|
1357
|
+
throw new Error(`Invalid time: ${raw} (use seconds, mm:ss, or hh:mm:ss).`);
|
|
1358
|
+
return parts.reduce((acc, n) => acc * 60 + n, 0);
|
|
1359
|
+
}
|
|
1360
|
+
// Pull the finished media URL out of a settled primitive job (GET /me/jobs/:id).
|
|
1361
|
+
// Mirrors the server's extractOutputUrlFromJob.
|
|
1362
|
+
function resolveJobMediaUrl(job) {
|
|
1363
|
+
if (!job || typeof job !== "object")
|
|
1364
|
+
return null;
|
|
1365
|
+
const readStr = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
1366
|
+
const result = (job.result && typeof job.result === "object") ? job.result : {};
|
|
1367
|
+
const output = (result.output && typeof result.output === "object") ? result.output : result;
|
|
1368
|
+
const direct = readStr(output.primary_file_url)
|
|
1369
|
+
?? readStr(output.video?.file_url)
|
|
1370
|
+
?? readStr(output.image?.file_url)
|
|
1371
|
+
?? readStr(output.render?.output_url);
|
|
1372
|
+
if (direct)
|
|
1373
|
+
return direct;
|
|
1374
|
+
if (Array.isArray(output.files)) {
|
|
1375
|
+
for (const f of output.files) {
|
|
1376
|
+
const u = readStr(typeof f === "string" ? f : (f?.file_url ?? f?.url));
|
|
1377
|
+
if (u)
|
|
1378
|
+
return u;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (Array.isArray(job.artifacts)) {
|
|
1382
|
+
for (const a of job.artifacts) {
|
|
1383
|
+
const u = readStr(a?.public_url);
|
|
1384
|
+
if (u)
|
|
1385
|
+
return u;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
// Poll a primitive job until it settles (or times out). Returns the final job.
|
|
1391
|
+
async function pollPrimitiveJob(ctx, jobId) {
|
|
1392
|
+
const MAX_WAIT_MS = 8 * 60 * 1000;
|
|
1393
|
+
const startedAt = Date.now();
|
|
1394
|
+
let last = null;
|
|
1395
|
+
for (;;) {
|
|
1396
|
+
await sleep(5000);
|
|
1397
|
+
const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, auth: ctx.auth });
|
|
1398
|
+
if (res.ok && res.json) {
|
|
1399
|
+
last = res.json;
|
|
1400
|
+
const status = String(last.status ?? "");
|
|
1401
|
+
if (!ctx.json)
|
|
1402
|
+
console.log(`${DIM} ${status}${typeof last.progress === "number" ? ` ${Math.round(last.progress * 100)}%` : ""}${RESET}`);
|
|
1403
|
+
if (status === "succeeded" || status === "failed" || status === "cancelled")
|
|
1404
|
+
return last;
|
|
1405
|
+
if (resolveJobMediaUrl(last))
|
|
1406
|
+
return last;
|
|
1407
|
+
}
|
|
1408
|
+
if (Date.now() - startedAt > MAX_WAIT_MS)
|
|
1409
|
+
return last ?? { status: "timeout" };
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// Upload a local file to the ephemeral temp store, returning its durable URL.
|
|
1413
|
+
// Used to turn `--ref @local.png` into a direct asset URL for reference images.
|
|
1414
|
+
async function uploadTempFileForRef(ctx, absPath) {
|
|
1415
|
+
if (!existsSync(absPath))
|
|
1416
|
+
throw new Error(`No such reference file: ${absPath}`);
|
|
1417
|
+
const form = new FormData();
|
|
1418
|
+
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1419
|
+
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1420
|
+
const text = await res.text();
|
|
1421
|
+
let json = null;
|
|
1422
|
+
try {
|
|
1423
|
+
json = text ? JSON.parse(text) : null;
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
json = null;
|
|
1427
|
+
}
|
|
1428
|
+
if (!res.ok)
|
|
1429
|
+
throw new Error(`reference upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1430
|
+
const url = json?.file?.view_url ?? json?.file?.s3_url;
|
|
1431
|
+
if (!url)
|
|
1432
|
+
throw new Error("reference upload returned no durable URL.");
|
|
1433
|
+
return url;
|
|
1434
|
+
}
|
|
1435
|
+
// Resolve --ref values: pass http(s) URLs through; upload @local files.
|
|
1436
|
+
async function resolveReferenceUrls(ctx, refs) {
|
|
1437
|
+
const out = [];
|
|
1438
|
+
for (const raw of refs ?? []) {
|
|
1439
|
+
const value = raw.trim();
|
|
1440
|
+
if (!value)
|
|
1441
|
+
continue;
|
|
1442
|
+
if (value.startsWith("@")) {
|
|
1443
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value.slice(1))));
|
|
1444
|
+
}
|
|
1445
|
+
else if (/^https?:\/\//i.test(value)) {
|
|
1446
|
+
out.push(value);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
// Bare path with no @ — treat as a local file too, for convenience.
|
|
1450
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value)));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
function inferMediaKindFromUrl(url) {
|
|
1456
|
+
return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
|
|
1457
|
+
}
|
|
1458
|
+
// Resolve a composition.html on disk from a file path or a directory holding one.
|
|
1459
|
+
function resolveCompositionHtmlPath(target) {
|
|
1460
|
+
const abs = path.resolve(process.cwd(), target);
|
|
1461
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
1462
|
+
const inside = path.join(abs, "composition.html");
|
|
1463
|
+
if (!existsSync(inside))
|
|
1464
|
+
throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
|
|
1465
|
+
return inside;
|
|
1466
|
+
}
|
|
1467
|
+
if (!existsSync(abs))
|
|
1468
|
+
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
1469
|
+
return abs;
|
|
1470
|
+
}
|
|
1471
|
+
// Apply an insert/replace of a media URL onto a composition.html on disk.
|
|
1472
|
+
function placeMediaOnDisk(input) {
|
|
1473
|
+
const html = readFileSync(input.htmlPath, "utf8");
|
|
1474
|
+
const opts = {
|
|
1475
|
+
kind: input.kind,
|
|
1476
|
+
src: input.src,
|
|
1477
|
+
layerKey: input.layerKey,
|
|
1478
|
+
start: input.at,
|
|
1479
|
+
duration: input.duration,
|
|
1480
|
+
track: input.track,
|
|
1481
|
+
x: input.x,
|
|
1482
|
+
y: input.y,
|
|
1483
|
+
width: input.width,
|
|
1484
|
+
height: input.height,
|
|
1485
|
+
objectFit: input.objectFit,
|
|
1486
|
+
slug: input.slug
|
|
1487
|
+
};
|
|
1488
|
+
const result = input.replace
|
|
1489
|
+
? replaceLayerWithMedia(html, input.replace, opts)
|
|
1490
|
+
: insertMediaLayer(html, opts);
|
|
1491
|
+
writeFileSync(input.htmlPath, result.html);
|
|
1492
|
+
return { layerKey: result.layerKey, gaps: computeCompositionGaps(result.html) };
|
|
1493
|
+
}
|
|
1494
|
+
async function runGenerateCommand(argv) {
|
|
1495
|
+
const parsed = parseArgs({
|
|
1496
|
+
args: argv,
|
|
1497
|
+
allowPositionals: true,
|
|
1498
|
+
options: {
|
|
1499
|
+
...commonOptions(),
|
|
1500
|
+
prompt: { type: "string" },
|
|
1501
|
+
ref: { type: "string", multiple: true },
|
|
1502
|
+
"aspect-ratio": { type: "string" },
|
|
1503
|
+
duration: { type: "string" },
|
|
1504
|
+
provider: { type: "string" },
|
|
1505
|
+
model: { type: "string" },
|
|
1506
|
+
resolution: { type: "string" },
|
|
1507
|
+
audio: { type: "boolean", default: false },
|
|
1508
|
+
"no-wait": { type: "boolean", default: false },
|
|
1509
|
+
tracer: { type: "string" },
|
|
1510
|
+
// Fused placement onto a local composition.html.
|
|
1511
|
+
place: { type: "string" },
|
|
1512
|
+
at: { type: "string" },
|
|
1513
|
+
replace: { type: "string" },
|
|
1514
|
+
track: { type: "string" },
|
|
1515
|
+
"layer-key": { type: "string" }
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
const mediaType = parsed.positionals[0];
|
|
1519
|
+
if (mediaType !== "image" && mediaType !== "video") {
|
|
1520
|
+
throw new Error("generate requires a media type: `vidfarm generate image ...` or `vidfarm generate video ...`.");
|
|
1521
|
+
}
|
|
1522
|
+
const prompt = parsed.values.prompt;
|
|
1523
|
+
if (!prompt || !prompt.trim())
|
|
1524
|
+
throw new Error("generate requires --prompt \"...\".");
|
|
1525
|
+
const ctx = commonContext(parsed.values);
|
|
1526
|
+
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
1527
|
+
const payload = { prompt };
|
|
1528
|
+
if (parsed.values["aspect-ratio"])
|
|
1529
|
+
payload.aspect_ratio = parsed.values["aspect-ratio"];
|
|
1530
|
+
if (parsed.values.provider)
|
|
1531
|
+
payload.provider = parsed.values.provider;
|
|
1532
|
+
if (parsed.values.model)
|
|
1533
|
+
payload.model = parsed.values.model;
|
|
1534
|
+
if (mediaType === "video") {
|
|
1535
|
+
if (parsed.values.duration)
|
|
1536
|
+
payload.duration = Math.round(Number(parsed.values.duration));
|
|
1537
|
+
if (parsed.values.resolution)
|
|
1538
|
+
payload.resolution = parsed.values.resolution;
|
|
1539
|
+
if (parsed.values.audio)
|
|
1540
|
+
payload.generate_audio = true;
|
|
1541
|
+
if (refs.length)
|
|
1542
|
+
payload.input_references = refs.slice(0, 8);
|
|
1543
|
+
}
|
|
1544
|
+
else if (refs.length) {
|
|
1545
|
+
payload.prompt_attachments = refs.slice(0, 16);
|
|
1546
|
+
}
|
|
1547
|
+
const tracer = parsed.values.tracer ?? `devcli-gen-${Date.now().toString(36)}`;
|
|
1548
|
+
const route = mediaType === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
|
|
1549
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
|
|
1550
|
+
assertApiOk(submit, "generate");
|
|
1551
|
+
const jobId = submit.json?.job_id;
|
|
1552
|
+
const wait = !parsed.values["no-wait"];
|
|
1553
|
+
if (!wait || !jobId) {
|
|
1554
|
+
if (!ctx.json && jobId)
|
|
1555
|
+
console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
|
|
1556
|
+
emitResult(submit, ctx.json);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (!ctx.json)
|
|
1560
|
+
console.log(`${DIM}Generating ${mediaType} (${jobId})… polling every 5s.${RESET}`);
|
|
1561
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
1562
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
1563
|
+
const status = String(job?.status ?? "");
|
|
1564
|
+
if (!mediaUrl) {
|
|
1565
|
+
if (ctx.json) {
|
|
1566
|
+
printJson({ ok: false, job_id: jobId, status, job });
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
console.log(`${RED}Generation ${status || "did not finish"} — no media URL.${RESET}`);
|
|
1570
|
+
printJson(job);
|
|
1571
|
+
}
|
|
1572
|
+
process.exitCode = 1;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
// Optional fused placement onto a local composition.html.
|
|
1576
|
+
let placement = null;
|
|
1577
|
+
if (parsed.values.place) {
|
|
1578
|
+
const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
|
|
1579
|
+
placement = placeMediaOnDisk({
|
|
1580
|
+
htmlPath,
|
|
1581
|
+
src: mediaUrl,
|
|
1582
|
+
kind: mediaType,
|
|
1583
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1584
|
+
replace: parsed.values.replace,
|
|
1585
|
+
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
1586
|
+
layerKey: parsed.values["layer-key"]
|
|
1587
|
+
});
|
|
1588
|
+
if (!ctx.json)
|
|
1589
|
+
console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
|
|
1590
|
+
}
|
|
1591
|
+
if (ctx.json) {
|
|
1592
|
+
printJson({ ok: true, job_id: jobId, media_url: mediaUrl, media_type: mediaType, placement });
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
console.log(`${GREEN}${mediaType} ready:${RESET} ${mediaUrl}`);
|
|
1596
|
+
if (!placement)
|
|
1597
|
+
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function runPlaceCommand(argv) {
|
|
1601
|
+
const parsed = parseArgs({
|
|
1602
|
+
args: argv,
|
|
1603
|
+
allowPositionals: true,
|
|
1604
|
+
options: {
|
|
1605
|
+
...commonOptions(),
|
|
1606
|
+
src: { type: "string" },
|
|
1607
|
+
kind: { type: "string" },
|
|
1608
|
+
at: { type: "string" },
|
|
1609
|
+
duration: { type: "string" },
|
|
1610
|
+
replace: { type: "string" },
|
|
1611
|
+
track: { type: "string" },
|
|
1612
|
+
x: { type: "string" },
|
|
1613
|
+
y: { type: "string" },
|
|
1614
|
+
width: { type: "string" },
|
|
1615
|
+
height: { type: "string" },
|
|
1616
|
+
"object-fit": { type: "string" },
|
|
1617
|
+
"layer-key": { type: "string" },
|
|
1618
|
+
slug: { type: "string" }
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
const target = parsed.positionals[0];
|
|
1622
|
+
if (!target)
|
|
1623
|
+
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
|
|
1624
|
+
const src = parsed.values.src;
|
|
1625
|
+
if (!src || !/^https?:\/\//i.test(src))
|
|
1626
|
+
throw new Error("place requires --src <direct media URL>.");
|
|
1627
|
+
const ctx = commonContext(parsed.values);
|
|
1628
|
+
const htmlPath = resolveCompositionHtmlPath(target);
|
|
1629
|
+
const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
|
|
1630
|
+
? parsed.values.kind
|
|
1631
|
+
: inferMediaKindFromUrl(src);
|
|
1632
|
+
const num = (v) => (v !== undefined ? Number(v) : undefined);
|
|
1633
|
+
const placement = placeMediaOnDisk({
|
|
1634
|
+
htmlPath,
|
|
1635
|
+
src,
|
|
1636
|
+
kind,
|
|
1637
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1638
|
+
duration: num(parsed.values.duration),
|
|
1639
|
+
replace: parsed.values.replace,
|
|
1640
|
+
track: num(parsed.values.track),
|
|
1641
|
+
x: num(parsed.values.x),
|
|
1642
|
+
y: num(parsed.values.y),
|
|
1643
|
+
width: num(parsed.values.width),
|
|
1644
|
+
height: num(parsed.values.height),
|
|
1645
|
+
objectFit: parsed.values["object-fit"],
|
|
1646
|
+
layerKey: parsed.values["layer-key"],
|
|
1647
|
+
slug: parsed.values.slug
|
|
1648
|
+
});
|
|
1649
|
+
if (ctx.json) {
|
|
1650
|
+
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
|
|
1654
|
+
console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
|
|
1655
|
+
console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
async function runPullCommand(argv) {
|
|
1659
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
|
|
1660
|
+
const forkId = parsed.positionals[0];
|
|
1661
|
+
if (!forkId)
|
|
1662
|
+
throw new Error("pull requires a fork id: `vidfarm pull <forkId> [--dir <path>]`.");
|
|
1663
|
+
const ctx = commonContext(parsed.values);
|
|
1664
|
+
const dir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", forkId));
|
|
1665
|
+
mkdirSync(dir, { recursive: true });
|
|
1666
|
+
await fetchCompositionFiles({ host: ctx.host, forkId, dir, apiKey: ctx.auth.apiKey, shareToken: ctx.auth.shareToken, refetch: Boolean(parsed.values.refetch) });
|
|
1667
|
+
const htmlPath = path.join(dir, "composition.html");
|
|
1668
|
+
if (!existsSync(htmlPath))
|
|
1669
|
+
throw new Error(`pull did not fetch composition.html for ${forkId} (check credentials / fork id).`);
|
|
1670
|
+
const info = inspectComposition(readFileSync(htmlPath, "utf8"));
|
|
1671
|
+
const hasContext = existsSync(path.join(dir, "video-context.json"));
|
|
1672
|
+
const hasCast = existsSync(path.join(dir, "cast.json"));
|
|
1673
|
+
if (ctx.json) {
|
|
1674
|
+
printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
|
|
1678
|
+
console.log(`${DIM}Canvas ${info.width ?? "?"}x${info.height ?? "?"} (${info.aspect_ratio ?? "?"}), ${info.duration_seconds}s, ${info.layers.length} layers.${RESET}`);
|
|
1679
|
+
console.log(`${BOLD}Timeline gaps (blank space to fill):${RESET} ${info.gaps.length ? info.gaps.map((g) => `${g.start}-${g.end}s (${g.duration}s)`).join(", ") : "none"}`);
|
|
1680
|
+
console.log(`${BOLD}Scenes / layers (keys to replace):${RESET}`);
|
|
1681
|
+
for (const layer of info.layers) {
|
|
1682
|
+
const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
|
|
1683
|
+
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}`);
|
|
1684
|
+
}
|
|
1685
|
+
console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
|
|
1686
|
+
console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
|
|
1687
|
+
}
|
|
1215
1688
|
async function runVisibilityCommand(argv) {
|
|
1216
1689
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
1217
1690
|
const forkId = parsed.positionals[0];
|
|
@@ -1526,6 +1999,76 @@ async function runGetFileCommand(argv) {
|
|
|
1526
1999
|
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
1527
2000
|
}
|
|
1528
2001
|
}
|
|
2002
|
+
// Write a file INTO My Files — the persistent per-user library (`/me/attachments`),
|
|
2003
|
+
// NOT the ephemeral temporary-files store that `upload` targets. This is what an
|
|
2004
|
+
// agent uses to save durable context docs during Getting Started (About.md,
|
|
2005
|
+
// Interview.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) or to drop
|
|
2006
|
+
// brand assets under /brand-assets, product demos under /product-demos, etc.
|
|
2007
|
+
// Content can come from a local file (positional), inline --content, or piped stdin.
|
|
2008
|
+
// It is the write counterpart to `files`/`get-file` and the same store the /editor
|
|
2009
|
+
// AI copilot writes to via the browse_files write action.
|
|
2010
|
+
async function runPutFileCommand(argv) {
|
|
2011
|
+
const parsed = parseArgs({
|
|
2012
|
+
args: argv,
|
|
2013
|
+
allowPositionals: true,
|
|
2014
|
+
options: {
|
|
2015
|
+
...commonOptions(),
|
|
2016
|
+
folder: { type: "string" },
|
|
2017
|
+
as: { type: "string" },
|
|
2018
|
+
content: { type: "string" },
|
|
2019
|
+
stdin: { type: "boolean", default: false }
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
const ctx = commonContext(parsed.values);
|
|
2023
|
+
const localPath = parsed.positionals[0];
|
|
2024
|
+
const asName = parsed.values.as ? String(parsed.values.as) : undefined;
|
|
2025
|
+
let buffer;
|
|
2026
|
+
let fileName = asName;
|
|
2027
|
+
if (parsed.values.stdin) {
|
|
2028
|
+
if (!fileName)
|
|
2029
|
+
throw new Error("put-file --stdin requires --as <name> to name the file in My Files.");
|
|
2030
|
+
buffer = readFileSync(0); // fd 0 = stdin (piped content)
|
|
2031
|
+
}
|
|
2032
|
+
else if (parsed.values.content != null) {
|
|
2033
|
+
if (!fileName)
|
|
2034
|
+
throw new Error("put-file --content requires --as <name> to name the file in My Files.");
|
|
2035
|
+
buffer = Buffer.from(String(parsed.values.content), "utf8");
|
|
2036
|
+
}
|
|
2037
|
+
else if (localPath) {
|
|
2038
|
+
const abs = path.resolve(process.cwd(), localPath);
|
|
2039
|
+
if (!existsSync(abs))
|
|
2040
|
+
throw new Error(`No such file: ${abs}`);
|
|
2041
|
+
buffer = readFileSync(abs);
|
|
2042
|
+
if (!fileName)
|
|
2043
|
+
fileName = path.basename(abs);
|
|
2044
|
+
}
|
|
2045
|
+
else {
|
|
2046
|
+
throw new Error("put-file requires a local file path, or --content/--stdin with --as <name>.");
|
|
2047
|
+
}
|
|
2048
|
+
if (!fileName)
|
|
2049
|
+
throw new Error("put-file could not determine a file name. Pass --as <name>.");
|
|
2050
|
+
const contentType = guessContentType(fileName);
|
|
2051
|
+
const form = new FormData();
|
|
2052
|
+
form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
|
|
2053
|
+
if (parsed.values.folder)
|
|
2054
|
+
form.append("folder_path", String(parsed.values.folder));
|
|
2055
|
+
const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
2058
|
+
body: form
|
|
2059
|
+
});
|
|
2060
|
+
const text = await res.text();
|
|
2061
|
+
let json = null;
|
|
2062
|
+
try {
|
|
2063
|
+
json = text ? JSON.parse(text) : null;
|
|
2064
|
+
}
|
|
2065
|
+
catch {
|
|
2066
|
+
json = null;
|
|
2067
|
+
}
|
|
2068
|
+
const result = { status: res.status, ok: res.ok, json, text };
|
|
2069
|
+
assertApiOk(result, "put-file");
|
|
2070
|
+
emitResult(result, ctx.json, [["My Files URL", result.json?.attachment?.viewUrl]]);
|
|
2071
|
+
}
|
|
1529
2072
|
// ── Agent skill ───────────────────────────────────────────────────────────────
|
|
1530
2073
|
// Install the latest director skill onto disk as a Claude Code / agent skill so
|
|
1531
2074
|
// the user's AI agent can read SKILL.director.md and act. We pull the freshest
|