@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/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 stays on cloud.
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 List the template/inspiration feed → GET /discover/feed
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/export
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 a local file, print its durable URL → POST /api/v1/user/me/temporary-files/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: false, options: { ...commonOptions(), limit: { type: "string" } } });
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 result = await apiRequest({ method: "GET", host: ctx.host, path: "/discover/feed", auth: ctx.auth, query: { limit: parsed.values.limit } });
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)}/export`,
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