@mevdragon/vidfarm-devcli 0.4.1 → 0.5.1

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,10 +7,12 @@ 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
- // vidfarm-devcli thin CLI that pairs a local composition directory with the
11
- // hosted Trackpad Editor at https://vidfarm.cc/editor/. Point it at a template
12
- // id, and it: resolves the user's fork, downloads the current composition,
13
- // starts a local dev-serve, and prints the editor URL to open in the browser.
10
+ import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./services/composition-edit.js";
11
+ // vidfarm-devcli command-line bridge for the Vidfarm video studio. The
12
+ // `serve` command boots the FULL editor locally (single origin, disk-backed
13
+ // records + storage) so power users edit compositions on disk while a browser
14
+ // editor and a coding agent share one source of truth; render stays on the
15
+ // cloud. The remaining commands are thin 1:1 wrappers over the REST API.
14
16
  const DEFAULT_HOST = "https://vidfarm.cc";
15
17
  const DEFAULT_PORT = 4321;
16
18
  // Agent skill files the `update-skill` command can install from the live host
@@ -38,23 +40,77 @@ Usage:
38
40
  vidfarm --help
39
41
 
40
42
  Local editor loop:
41
- <template_id> [opts] Fetch composition + media, dev-serve, print editor URL
43
+ serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
44
+ Records + storage run on disk; render runs in-process on
45
+ this box for FREE (no cloud, no wallet charge).
46
+ With a template_id or --fork, pulls that composition
47
+ (or the template's default/shared decomposition) from
48
+ the cloud host onto local disk to edit offline.
49
+ --port <n> Server port (default: 3000)
50
+ --dir <path> Local data dir (default: ./.vidfarm-local)
51
+ --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
52
+ --fork <id> Pull + open a specific cloud fork
53
+ --host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
54
+ --api-key <key> Cloud key for the pull (default: VIDFARM_API_KEY)
55
+ --refetch Overwrite local composition with the cloud copy
56
+ --no-open Don't auto-open the browser
57
+ <template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
42
58
  publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
43
- dev-serve Serve an existing local dir only (no fetch)
44
59
 
45
60
  Discover & inspiration (browse the viral-video catalog, add your own source):
46
- 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
47
68
  inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
48
69
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
49
70
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
50
71
 
51
72
  Composition lifecycle (fork → decompose → snapshot → render):
52
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
53
79
  decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
54
- ghostcut <forkId> Read subtitle-removal status (non-billing) → GET .../compositions/:forkId/ghostcut
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)
107
+ ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
108
+ decomposed (caption-free), non-billing
55
109
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
56
110
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
57
- 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.
58
114
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
59
115
  visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
60
116
  clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
@@ -82,8 +138,30 @@ Agent skill (install the latest director skill so your AI agent can act):
82
138
  --print Print the skill to stdout instead of writing a file
83
139
 
84
140
  Files (multi-step flows the devcli handles for you):
85
- 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
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
86
149
  download <url> [dest] Stream any Vidfarm/media URL to disk
150
+ files List My Files assets + folders → GET /api/v1/user/me/attachments
151
+ --folder <path> Only files under this folder (e.g. a product/offer)
152
+ get-file <id> [dest] Resolve a My Files id to its URL and download it
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.
87
165
 
88
166
  Escape hatch — call ANY route directly:
89
167
  api <METHOD> <path> Raw REST call with auth + pretty errors
@@ -99,11 +177,12 @@ Common options (any command):
99
177
  --share <token> Share token for a shared fork (public read)
100
178
  --json Print only the raw JSON response (agent-friendly)
101
179
 
102
- Editor-mode options:
103
- --port <n> Local dev-serve port (default: ${DEFAULT_PORT})
104
- --dir <path> Local composition directory (default: .vidfarm/<template_id>)
105
- --fork <id> Fork id (default: auto-resolve for the current user)
106
- --refetch Overwrite local composition files with the latest remote copy
180
+ Serve-mode options:
181
+ --port <n> Local server port (default: 3000)
182
+ --dir <path> Local data dir for disk records + storage (default: ./.vidfarm-local)
183
+ --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
184
+ --fork <id> Open a specific fork (multiple forks editable at once)
185
+ --no-open Don't auto-open the browser
107
186
 
108
187
  Publish-mode options:
109
188
  --message <text> Message attached to the published version snapshot
@@ -122,13 +201,11 @@ async function main() {
122
201
  const command = argv[0];
123
202
  const rest = argv.slice(1);
124
203
  switch (command) {
125
- case "dev-serve": {
126
- const { runDevServeCommand } = await import("./dev-serve.js");
127
- await runDevServeCommand(rest);
128
- return;
129
- }
204
+ // `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
205
+ // editor locally. The old cross-origin dev-serve/`?dev=` flow is gone.
206
+ case "serve":
130
207
  case "edit":
131
- await runEditCommand(rest);
208
+ await runServeCommand(rest);
132
209
  return;
133
210
  case "publish":
134
211
  await runPublishCommand(rest);
@@ -140,6 +217,9 @@ async function main() {
140
217
  case "discover":
141
218
  await runDiscoverCommand(rest);
142
219
  return;
220
+ case "videos":
221
+ await runVideosCommand(rest);
222
+ return;
143
223
  case "inspiration-add":
144
224
  await runInspirationAddCommand(rest);
145
225
  return;
@@ -152,6 +232,15 @@ async function main() {
152
232
  case "fork":
153
233
  await runForkCommand(rest);
154
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;
155
244
  case "decompose":
156
245
  await runDecomposeCommand(rest);
157
246
  return;
@@ -219,10 +308,20 @@ async function main() {
219
308
  case "download":
220
309
  await runDownloadCommand(rest);
221
310
  return;
311
+ case "files":
312
+ await runFilesCommand(rest);
313
+ return;
314
+ case "get-file":
315
+ await runGetFileCommand(rest);
316
+ return;
317
+ case "put-file":
318
+ case "save-file":
319
+ await runPutFileCommand(rest);
320
+ return;
222
321
  default:
223
322
  if (!command.startsWith("-")) {
224
- // Positional template id → default `edit` command.
225
- await runEditCommand(argv);
323
+ // Positional template id → default to booting the local editor.
324
+ await runServeCommand(argv);
226
325
  return;
227
326
  }
228
327
  throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
@@ -337,61 +436,205 @@ function collectKeyValues(entries) {
337
436
  }
338
437
  return out;
339
438
  }
340
- async function runEditCommand(argv) {
439
+ // `vidfarm serve` — boot the FULL Vidfarm backend locally (single origin), on
440
+ // disk-backed records + storage, so power users edit compositions locally while
441
+ // a browser editor and a coding agent share one source of truth. Render stays
442
+ // on the cloud (see Phase 4). This replaces the cross-origin `?dev=` shim.
443
+ async function runServeCommand(argv) {
341
444
  const parsed = parseArgs({
342
445
  args: argv,
343
446
  allowPositionals: true,
344
447
  options: {
345
- port: { type: "string", default: String(DEFAULT_PORT) },
448
+ port: { type: "string", default: "3000" },
346
449
  dir: { type: "string" },
347
- host: { type: "string", default: DEFAULT_HOST },
450
+ key: { type: "string" },
348
451
  fork: { type: "string" },
452
+ host: { type: "string", default: DEFAULT_HOST },
349
453
  "api-key": { type: "string" },
350
454
  share: { type: "string" },
351
- refetch: { type: "boolean", default: false }
455
+ refetch: { type: "boolean", default: false },
456
+ open: { type: "boolean", default: true },
457
+ "no-open": { type: "boolean", default: false }
352
458
  }
353
459
  });
354
- const templateId = parsed.positionals[0];
355
- if (!templateId) {
356
- throw new Error(`edit requires a template id.\n\n${HELP}`);
357
- }
358
460
  const port = Number(parsed.values.port);
359
461
  if (!Number.isFinite(port) || port <= 0) {
360
462
  throw new Error(`Invalid --port: ${parsed.values.port}`);
361
463
  }
464
+ const dataDir = path.resolve(process.cwd(), parsed.values.dir ?? ".vidfarm-local");
465
+ mkdirSync(dataDir, { recursive: true });
466
+ // Capture the ambient cloud key BEFORE we overwrite VIDFARM_API_KEY with the
467
+ // local bootstrap key, so `--fork`/template seeding can still authenticate to
468
+ // the cloud host.
469
+ const envKey = process.env.VIDFARM_API_KEY;
470
+ // A stable local bootstrap key: first --key, else VIDFARM_API_KEY, else a
471
+ // fixed dev key. The same value auto-provisions the paid customer (via
472
+ // tryBootstrapFromEnv) and pre-auths the browser through /auto-login.
473
+ const apiKey = parsed.values.key ?? envKey ?? "vidfarm-local-dev-key";
474
+ const cloudApiKey = parsed.values["api-key"] ?? envKey;
362
475
  const host = trimTrailingSlash(parsed.values.host);
363
- const apiKey = parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY;
364
- const shareToken = parsed.values.share;
365
- const rootDir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", templateId));
366
- mkdirSync(rootDir, { recursive: true });
367
- const forkId = parsed.values.fork ?? await resolveForkId({ host, templateId, apiKey, shareToken });
368
- if (!forkId) {
369
- throw new Error(`Could not resolve a fork id for template ${templateId}. Pass --fork <id>, or open ${host}/editor/${templateId} once so a fork is created.`);
370
- }
371
- await fetchCompositionFiles({
372
- host,
373
- forkId,
374
- dir: rootDir,
375
- apiKey,
376
- shareToken,
377
- refetch: parsed.values.refetch
378
- });
379
- // Give local coding agents (Claude Code / Codex / opencode) the actual media
380
- // to look at: the original source video, the composition's decomposed source
381
- // (which may differ after ghostcut subtitle-removal), one still frame per
382
- // scene, and a sources.json manifest tying it all together. Best-effort —
383
- // never blocks the editor session if a download or frame extraction fails.
476
+ const templateId = parsed.positionals[0];
477
+ const forkFlag = parsed.values.fork;
478
+ // Env MUST be set before importing runtime — config.ts reads it at module
479
+ // load. Force the fully-local drivers; keep any cloud creds already present
480
+ // in the environment so render passthrough still works.
481
+ process.env.RECORDS_DRIVER = "local";
482
+ process.env.STORAGE_DRIVER = "local";
483
+ process.env.AWS_S3_BUCKET = "";
484
+ process.env.VIDFARM_DATA_DIR = dataDir;
485
+ process.env.VIDFARM_API_KEY = apiKey;
486
+ process.env.PORT = String(port);
487
+ if (!process.env.NODE_ENV)
488
+ process.env.NODE_ENV = "development";
489
+ const { startRuntime } = await import("./runtime.js");
490
+ await startRuntime();
491
+ // If a template/fork was requested, pull its composition from the cloud host
492
+ // onto local disk and materialize the matching local records so the editor
493
+ // resolves it. Best-effort: a failure just drops us to /discover.
494
+ let openTemplateId = templateId;
495
+ let openForkId = forkFlag;
496
+ if (templateId || forkFlag) {
497
+ try {
498
+ const seeded = await seedForkFromCloud({
499
+ host,
500
+ templateId,
501
+ forkId: forkFlag,
502
+ cloudApiKey,
503
+ shareToken: parsed.values.share,
504
+ bootstrapKey: apiKey,
505
+ refetch: parsed.values.refetch
506
+ });
507
+ if (seeded) {
508
+ openTemplateId = seeded.templateId;
509
+ openForkId = seeded.forkId;
510
+ }
511
+ }
512
+ catch (error) {
513
+ console.warn(`[vidfarm] serve: cloud seed failed (${error instanceof Error ? error.message : String(error)}); starting empty.`);
514
+ }
515
+ }
516
+ const base = `http://localhost:${port}`;
517
+ const editorPath = openTemplateId
518
+ ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
519
+ : "/discover";
520
+ const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
521
+ printServeBanner({ base, dataDir, openUrl, editorPath });
522
+ const shouldOpen = parsed.values.open && !parsed.values["no-open"];
523
+ if (shouldOpen)
524
+ openInBrowser(openUrl);
525
+ // startRuntime() keeps the process alive via its HTTP server; nothing else to do.
526
+ }
527
+ // Pull a cloud fork's composition onto local disk and create the local
528
+ // template + fork records so the editor resolves it. The fork is either an
529
+ // explicit --fork or the template's default fork (which may be another user's
530
+ // public decomposition). Media stays on cloud URLs — only the composition +
531
+ // records are localized. Runs in-process after startRuntime(), so it shares the
532
+ // server's disk-backed records/storage singletons.
533
+ async function seedForkFromCloud(input) {
534
+ const { serverlessRecords } = await import("./services/serverless-records.js");
535
+ const { StorageService } = await import("./services/storage.js");
536
+ const { ServerlessAuthService } = await import("./services/serverless-auth.js");
537
+ const storage = new StorageService();
538
+ const auth = new ServerlessAuthService();
539
+ // Ensure the local bootstrap customer exists and grab its id to own the seed.
540
+ const customer = await auth.authenticate(input.bootstrapKey);
541
+ const authHeaders = buildAuthHeaders({ apiKey: input.cloudApiKey, shareToken: input.shareToken });
542
+ // 1. Resolve the source fork: explicit --fork wins, else the template's
543
+ // default fork (grabs the shared decomposition even if another user owns it).
544
+ let cloudForkId = input.forkId ?? null;
545
+ if (!cloudForkId && input.templateId) {
546
+ cloudForkId = await resolveForkId({ host: input.host, templateId: input.templateId, apiKey: input.cloudApiKey, shareToken: input.shareToken });
547
+ }
548
+ if (!cloudForkId) {
549
+ console.warn("[vidfarm] serve: no --fork and no default fork resolvable from cloud; starting empty.");
550
+ return null;
551
+ }
552
+ // 2. Read the serialized fork for its template id + title.
553
+ const forkMetaRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}`, { headers: authHeaders });
554
+ const forkMeta = forkMetaRes.ok ? (await forkMetaRes.json().catch(() => null)) : null;
555
+ const templateId = input.templateId ?? (typeof forkMeta?.template_id === "string" ? forkMeta.template_id : null);
556
+ if (!templateId || !templateId.startsWith("template_")) {
557
+ console.warn(`[vidfarm] serve: could not determine a template id for fork ${cloudForkId}; starting empty.`);
558
+ return null;
559
+ }
560
+ const title = (typeof forkMeta?.title === "string" && forkMeta.title) || `Local · ${templateId}`;
561
+ // 3. Materialize the local template record (the editor 404s without it).
562
+ const existingTemplate = await serverlessRecords.getTemplate(templateId);
563
+ if (!existingTemplate) {
564
+ await serverlessRecords.createTemplate({
565
+ id: templateId,
566
+ inspirationId: `local:${templateId}`,
567
+ customerId: customer.id,
568
+ originalUrl: typeof forkMeta?.original_url === "string" ? forkMeta.original_url : "",
569
+ sourceHost: "local",
570
+ title,
571
+ visibility: "public"
572
+ });
573
+ }
574
+ // 4. Materialize the local fork (reuse the cloud id for stable URLs).
575
+ const localForkId = cloudForkId;
576
+ const existingFork = await serverlessRecords.getCompositionFork(localForkId);
577
+ if (!existingFork) {
578
+ await serverlessRecords.createCompositionFork({
579
+ id: localForkId,
580
+ customerId: customer.id,
581
+ templateId,
582
+ title,
583
+ visibility: "private"
584
+ });
585
+ }
586
+ // 5. Pull composition.html/json onto disk (skip if present unless --refetch,
587
+ // so local edits aren't clobbered on re-serve).
588
+ const htmlKey = storage.compositionForkWorkingKey(localForkId, "composition.html");
589
+ const jsonKey = storage.compositionForkWorkingKey(localForkId, "composition.json");
590
+ const hasLocalHtml = Boolean(await storage.readText(htmlKey));
591
+ if (!hasLocalHtml || input.refetch) {
592
+ const htmlRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.html`, { headers: authHeaders });
593
+ if (!htmlRes.ok) {
594
+ console.warn(`[vidfarm] serve: could not fetch composition.html for ${cloudForkId} (${htmlRes.status}); starting empty.`);
595
+ return null;
596
+ }
597
+ await storage.putText(htmlKey, await htmlRes.text(), "text/html; charset=utf-8");
598
+ const jsonRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.json`, { headers: authHeaders });
599
+ if (jsonRes.ok)
600
+ await storage.putBuffer(jsonKey, Buffer.from(await jsonRes.text(), "utf8"), "application/json");
601
+ console.log(`[vidfarm] seeded fork ${localForkId} from ${input.host}`);
602
+ }
603
+ else {
604
+ console.log(`[vidfarm] fork ${localForkId} already local (use --refetch to overwrite)`);
605
+ }
606
+ // 6. Point the template's default fork at the local copy so a bare
607
+ // /editor/:templateId resolves it too.
608
+ await serverlessRecords.stampDefaultForkIfUnset({ templateId, forkId: localForkId });
609
+ return { templateId, forkId: localForkId };
610
+ }
611
+ function printServeBanner(input) {
612
+ const line = `${DIM}${"─".repeat(74)}${RESET}`;
613
+ console.log("");
614
+ console.log(line);
615
+ console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(fully local — records + storage on disk)${RESET}`);
616
+ console.log(line);
617
+ console.log(` server ${input.base}`);
618
+ console.log(` data dir ${input.dataDir}`);
619
+ console.log(` render ${DIM}cloud (submitted to the deployed renderer)${RESET}`);
620
+ console.log("");
621
+ console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
622
+ console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
623
+ console.log("");
624
+ console.log(` ${BOLD}Open (pre-authed):${RESET}`);
625
+ console.log(` ${FRONTEND}${input.openUrl}${RESET}`);
626
+ console.log(line);
627
+ console.log("");
628
+ }
629
+ function openInBrowser(url) {
630
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
631
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
384
632
  try {
385
- await fetchVideoAssets({ dir: rootDir, apiKey, shareToken, refetch: parsed.values.refetch });
633
+ spawnSync(opener, args, { stdio: "ignore" });
386
634
  }
387
- catch (error) {
388
- console.warn(`[vidfarm] video assets skipped: ${error instanceof Error ? error.message : String(error)}`);
635
+ catch {
636
+ // Non-fatal: the banner already printed the URL to open manually.
389
637
  }
390
- const devSource = `http://localhost:${port}`;
391
- const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
392
- printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
393
- const { runDevServeCommand } = await import("./dev-serve.js");
394
- await runDevServeCommand(["--dir", rootDir, "--port", String(port)]);
395
638
  }
396
639
  async function runPublishCommand(argv) {
397
640
  const parsed = parseArgs({
@@ -564,7 +807,9 @@ async function fetchCompositionFiles(input) {
564
807
  // video-context.json carries the source video's audio transcript and
565
808
  // per-scene visual descriptions so desktop agents (Claude Code, Codex)
566
809
  // can ground edits in what the video actually says and shows.
567
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json"];
810
+ // cast.json carries the identified recurring people/characters plus their
811
+ // reference-still URLs so agents can reuse "the same person" in new gens.
812
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
568
813
  for (const filename of files) {
569
814
  const target = path.join(input.dir, filename);
570
815
  if (existsSync(target) && !input.refetch) {
@@ -588,10 +833,10 @@ async function fetchVideoAssets(input) {
588
833
  console.log("[vidfarm] no source video URL found in composition — skipping video assets");
589
834
  return;
590
835
  }
591
- // "decomposed video in composition" = the source the composition currently
592
- // renders from (post-ghostcut if applicable). "original" = the raw source the
593
- // decompose ran against, before any processing. They coincide when the fork
594
- // was never ghostcut/subtitle-removed.
836
+ // "decomposed video" = the source the composition currently renders from
837
+ // (caption-free after subtitle removal, if applicable). "original video" =
838
+ // the raw source the decompose ran against, before any processing. They
839
+ // coincide when the fork was never subtitle-removed.
595
840
  const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
596
841
  const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
597
842
  const sameSource = compositionUrl === originalUrl;
@@ -630,19 +875,20 @@ async function fetchVideoAssets(input) {
630
875
  : new Map();
631
876
  const manifest = {
632
877
  generated_by: "vidfarm-devcli",
633
- note: "Local media references for coding agents. `original.mp4` is the raw source video; " +
634
- "`source.mp4` is the video the composition timeline renders from (may differ from the " +
635
- "original after ghostcut subtitle-removal). Scene entries map timeline segments to a still " +
636
- "frame you can open as an image. Run `--refetch` to refresh.",
878
+ note: "Local media references for coding agents. Two video sources: `original.mp4` is the " +
879
+ "ORIGINAL video (raw source, captions intact); `source.mp4` is the DECOMPOSED video, the " +
880
+ "processed caption-free copy the composition timeline renders from (differs from the original " +
881
+ "only when the fork was decomposed with subtitle removal). Scene entries map timeline segments " +
882
+ "to a still frame you can open as an image. Run `--refetch` to refresh.",
637
883
  videos: {
638
884
  decomposed: {
639
- role: "video the composition timeline renders from (decomposed source)",
885
+ role: "decomposed video: processed caption-free source the composition timeline renders from",
640
886
  url: compositionUrl,
641
887
  path: decomposed ? decomposedName : null,
642
888
  bytes: decomposed?.bytes ?? null
643
889
  },
644
890
  original: {
645
- role: "raw original source video (before any processing)",
891
+ role: "original video: raw source before any processing",
646
892
  url: originalUrl,
647
893
  path: original ? original.path : null,
648
894
  bytes: original?.bytes ?? null,
@@ -801,6 +1047,39 @@ function safeSize(target) {
801
1047
  return 0;
802
1048
  }
803
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
+ }
804
1083
  function formatBytes(bytes) {
805
1084
  if (bytes >= 1024 * 1024)
806
1085
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -818,29 +1097,6 @@ function buildAuthHeaders(input) {
818
1097
  headers["vidfarm-share-token"] = input.shareToken;
819
1098
  return headers;
820
1099
  }
821
- function printEditorBanner(input) {
822
- const bold = "\x1b[1m";
823
- const green = "\x1b[32m";
824
- const cyan = "\x1b[36m";
825
- const dim = "\x1b[2m";
826
- const reset = "\x1b[0m";
827
- const line = `${dim}${"─".repeat(74)}${reset}`;
828
- console.log("");
829
- console.log(line);
830
- console.log(`${bold}${green} Vidfarm local editor${reset}`);
831
- console.log(line);
832
- console.log(` template ${input.templateId}`);
833
- console.log(` fork ${input.forkId}`);
834
- console.log(` local dir ${input.dir}`);
835
- console.log(` dev-serve http://localhost:${input.port}`);
836
- console.log("");
837
- console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
838
- console.log("");
839
- console.log(` ${bold}Open in browser:${reset}`);
840
- console.log(` ${cyan}${input.editorUrl}${reset}`);
841
- console.log(line);
842
- console.log("");
843
- }
844
1100
  function trimTrailingSlash(value) {
845
1101
  return value.replace(/\/+$/, "");
846
1102
  }
@@ -884,13 +1140,33 @@ async function runApiCommand(argv) {
884
1140
  process.exitCode = 1;
885
1141
  }
886
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.
887
1146
  async function runDiscoverCommand(argv) {
888
- 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" } } });
889
1148
  const ctx = commonContext(parsed.values);
890
- 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 } });
891
1151
  assertApiOk(result, "discover");
892
1152
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
893
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
+ }
894
1170
  async function runInspirationAddCommand(argv) {
895
1171
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
896
1172
  const sourceUrl = parsed.positionals[0];
@@ -931,6 +1207,12 @@ async function runInspirationDecomposeCommand(argv) {
931
1207
  body: { user_prompt: parsed.values.prompt ?? null }
932
1208
  });
933
1209
  assertApiOk(result, "inspiration-decompose");
1210
+ if (!ctx.json && result.json?.default_fork_id) {
1211
+ const forkId = result.json.default_fork_id;
1212
+ console.log(result.json.became_default_fork
1213
+ ? `${DIM}This decompose set the template default fork (${forkId}) — everyone now forks off it.${RESET}`
1214
+ : `${DIM}Template already has a default fork (${forkId}); left it unchanged.${RESET}`);
1215
+ }
934
1216
  emitResult(result, ctx.json);
935
1217
  }
936
1218
  // ── Composition lifecycle ───────────────────────────────────────────────────
@@ -1014,7 +1296,7 @@ async function runRenderCommand(argv) {
1014
1296
  const result = await apiRequest({
1015
1297
  method: "POST",
1016
1298
  host: ctx.host,
1017
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/export`,
1299
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
1018
1300
  auth: ctx.auth,
1019
1301
  body: { title: parsed.values.title, tracer: parsed.values.tracer }
1020
1302
  });
@@ -1058,6 +1340,351 @@ async function runRenderStatusCommand(argv) {
1058
1340
  assertApiOk(result, "render-status");
1059
1341
  emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
1060
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
+ }
1061
1688
  async function runVisibilityCommand(argv) {
1062
1689
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1063
1690
  const forkId = parsed.positionals[0];
@@ -1316,6 +1943,132 @@ async function runDownloadCommand(argv) {
1316
1943
  console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
1317
1944
  }
1318
1945
  }
1946
+ // Browse the user's My Files library — the persistent per-user asset store
1947
+ // (videos, images, audio, docs) organized into virtual folders. `files` lists
1948
+ // them (optionally scoped to a folder); `get-file` resolves one attachment id to
1949
+ // its durable URL and either streams the bytes to disk or prints text contents.
1950
+ // This is the same filesystem the /editor AI agent browses via the browse_files
1951
+ // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
1952
+ async function runFilesCommand(argv) {
1953
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
1954
+ const ctx = commonContext(parsed.values);
1955
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
1956
+ assertApiOk(result, "files");
1957
+ const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
1958
+ if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
1959
+ result.json = {
1960
+ ...result.json,
1961
+ attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
1962
+ };
1963
+ }
1964
+ emitResult(result, ctx.json);
1965
+ }
1966
+ async function runGetFileCommand(argv) {
1967
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
1968
+ const fileId = parsed.positionals[0];
1969
+ if (!fileId)
1970
+ throw new Error("get-file requires a file id (run `files` to list ids).");
1971
+ const ctx = commonContext(parsed.values);
1972
+ const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
1973
+ assertApiOk(list, "get-file");
1974
+ const attachments = Array.isArray(list.json?.attachments) ? list.json.attachments : [];
1975
+ const match = attachments.find((file) => file?.id === fileId) ?? attachments.find((file) => file?.fileName === fileId);
1976
+ if (!match)
1977
+ throw new Error(`No file with id or name "${fileId}" in My Files. Run \`files\` to list ids.`);
1978
+ const viewUrl = match.viewUrl;
1979
+ if (!viewUrl)
1980
+ throw new Error(`File "${match.fileName}" has no resolvable URL.`);
1981
+ const absoluteUrl = new URL(viewUrl, ctx.host).toString();
1982
+ const res = await fetch(absoluteUrl, { headers: buildAuthHeaders(ctx.auth) });
1983
+ if (!res.ok || !res.body)
1984
+ throw new Error(`get-file failed: ${res.status} ${res.statusText} (${absoluteUrl}).`);
1985
+ if (parsed.values.print) {
1986
+ const text = await res.text();
1987
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1988
+ return;
1989
+ }
1990
+ const dest = parsed.positionals[1]
1991
+ ? path.resolve(process.cwd(), parsed.positionals[1])
1992
+ : path.resolve(process.cwd(), match.fileName || path.basename(new URL(absoluteUrl).pathname) || "download.bin");
1993
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
1994
+ const bytes = safeSize(dest);
1995
+ if (ctx.json) {
1996
+ printJson({ ok: true, file_id: match.id, file_name: match.fileName, url: absoluteUrl, path: dest, bytes });
1997
+ }
1998
+ else {
1999
+ console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
2000
+ }
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
+ }
1319
2072
  // ── Agent skill ───────────────────────────────────────────────────────────────
1320
2073
  // Install the latest director skill onto disk as a Claude Code / agent skill so
1321
2074
  // the user's AI agent can read SKILL.director.md and act. We pull the freshest