@mevdragon/vidfarm-devcli 0.6.0 → 0.7.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
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { parseArgs } from "node:util";
7
8
  import { spawnSync } from "node:child_process";
@@ -43,6 +44,11 @@ Local editor loop:
43
44
  serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
44
45
  Records + storage run on disk; render runs in-process on
45
46
  this box for FREE (no cloud, no wallet charge).
47
+ /discover and /library still list the CLOUD catalog —
48
+ opening a cloud template seeds it onto local disk, and
49
+ the editor's Render button can hand off to the cloud
50
+ renderer (needs the cloud --api-key). Only the editor
51
+ and its data are local.
46
52
  With a template_id or --fork, pulls that composition
47
53
  (or the template's default/shared decomposition) from
48
54
  the cloud host onto local disk to edit offline.
@@ -51,8 +57,9 @@ Local editor loop:
51
57
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
52
58
  --fork <id> Pull + open a specific cloud fork
53
59
  --host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
54
- --api-key <key> Cloud key for the pull (default: VIDFARM_API_KEY)
60
+ --api-key <key> Cloud key for pulls/library/cloud render (default: VIDFARM_API_KEY)
55
61
  --refetch Overwrite local composition with the cloud copy
62
+ --no-cloud Fully local: no cloud catalog, seeding, or cloud render
56
63
  --no-open Don't auto-open the browser
57
64
  <template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
58
65
  publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
@@ -65,7 +72,10 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
65
72
  --query <text> Keyword search over the catalog
66
73
  --mine Only your own uploaded inspirations
67
74
  --limit <n> Max results
68
- inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
75
+ inspiration-add <url|file> Ingest a TikTok/YT/IG/X URL or upload a → POST /discover/templates
76
+ local MP4/MOV/WebM file — as a private (files: presign + PUT via
77
+ template. --title (uploads; defaults to the /discover/templates/upload)
78
+ template id), --tagline, --notes optional.
69
79
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
70
80
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
71
81
 
@@ -93,19 +103,31 @@ Generate AI media and drop it on the timeline (for local coding agents):
93
103
  --no-wait Return the job id instead of polling to the URL
94
104
  --place <dir> After generating, place it into that composition
95
105
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
96
- place <dir|composition.html> Insert a media URL into a local composition
106
+ place <dir|composition.html> Insert media into a local composition
97
107
  (fill a gap or replace a scene) — same clip markup
98
108
  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)
109
+ --src <url|file> Media to place (required). A URL is used as-is; a
110
+ LOCAL FILE PATH is imported without bloating S3 — a
111
+ serve working copy copies it onto the box's own disk
112
+ store (free, local render only); any other dir
113
+ uploads it to the ephemeral temp store (folder below).
114
+ --base-url <url> Serve box origin that hosts /storage (default
115
+ http://localhost:3000) — set if you ran serve --port.
116
+ --folder <path> Temp-store folder for uploaded local files (default: temp)
117
+ --kind <video|image> Media kind (inferred from the URL/path if omitted)
101
118
  --at <time> Start it at this time (seconds / mm:ss) — fill a gap
102
119
  --replace <layer_key> Replace this scene/layer (copies its timing+geometry)
103
120
  --duration <n> Timeline length in seconds
104
121
  --track <n> Timeline layer index (auto if omitted)
105
122
  --x --y --width --height Geometry as % (default full canvas 0/0/100/100)
106
123
  --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
124
+ --ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
125
+ zoom-out|pan-left|pan-right|pan-up|pan-down|
126
+ zoom-in-left|zoom-in-right
127
+ --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
128
+ remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
129
+ original vs decomposed (caption-free),
130
+ non-billing (alias: ghostcut)
109
131
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
110
132
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
111
133
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -118,6 +140,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
118
140
 
119
141
  Approve & schedule (publish a finished MP4 as a shareable post):
120
142
  approve Approve media into a preview/share page → POST /api/v1/approved/posts
143
+ --video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
144
+ --media <url|file> Extra media (repeatable; local files auto-upload to temp/)
145
+ --folder <path> Temp-store folder for uploaded local files (default: temp)
146
+ --caption <text> Post caption (required)
121
147
  posts List your approved posts → GET /api/v1/approved/posts
122
148
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
123
149
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -139,7 +165,9 @@ Agent skill (install the latest director skill so your AI agent can act):
139
165
 
140
166
  Files (multi-step flows the devcli handles for you):
141
167
  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
168
+ --folder <path> Namescope the upload under a folder. Prefer a
169
+ throwaway 'temp' folder (--folder temp) for scratch
170
+ media so it never clutters your durable library.
143
171
  put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
144
172
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
145
173
  --as <name> Name the saved file (required for --content/--stdin)
@@ -153,6 +181,10 @@ Files (multi-step flows the devcli handles for you):
153
181
  --print Print text contents (md/txt/csv/json) instead of saving
154
182
  put-file / get-file / files are the My Files (persistent) read+write set;
155
183
  upload is the throwaway temp store for dropping media into a composition.
184
+ Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
185
+ them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
186
+ to that box's local disk store, free local render). When you DO need a durable
187
+ URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
156
188
 
157
189
  Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
158
190
  free Reuse a decomposed template, swap captions/images/existing MP4s
@@ -244,8 +276,9 @@ async function main() {
244
276
  case "decompose":
245
277
  await runDecomposeCommand(rest);
246
278
  return;
279
+ case "remove-video-captions":
247
280
  case "ghostcut":
248
- await runGhostcutCommand(rest);
281
+ await runRemoveVideoCaptionsCommand(rest);
249
282
  return;
250
283
  case "versions":
251
284
  await runVersionsCommand(rest);
@@ -454,7 +487,10 @@ async function runServeCommand(argv) {
454
487
  share: { type: "string" },
455
488
  refetch: { type: "boolean", default: false },
456
489
  open: { type: "boolean", default: true },
457
- "no-open": { type: "boolean", default: false }
490
+ "no-open": { type: "boolean", default: false },
491
+ // Disable the cloud passthrough entirely: /discover and /library list
492
+ // only local data and the editor offers no "Render in Cloud".
493
+ "no-cloud": { type: "boolean", default: false }
458
494
  }
459
495
  });
460
496
  const port = Number(parsed.values.port);
@@ -476,16 +512,33 @@ async function runServeCommand(argv) {
476
512
  const templateId = parsed.positionals[0];
477
513
  const forkFlag = parsed.values.fork;
478
514
  // 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.
515
+ // load. Force the fully-local drivers.
481
516
  process.env.RECORDS_DRIVER = "local";
482
517
  process.env.STORAGE_DRIVER = "local";
483
518
  process.env.AWS_S3_BUCKET = "";
519
+ // A dev-shell .env can carry the staging Step Functions ARN, which would
520
+ // silently route this box's renders to the cloud state machine. serve is
521
+ // local-render by definition — cloud rendering is the EXPLICIT
522
+ // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
523
+ process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
524
+ // Same trap as the state machine ARN: a dev-shell .env can carry the staging
525
+ // media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
526
+ // Lambda that cannot fetch this box's localhost storage URLs ("fetch
527
+ // failed"). Media ops on a serve box always run in-process.
528
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL = "";
529
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_SECRET = "";
484
530
  process.env.VIDFARM_DATA_DIR = dataDir;
485
531
  process.env.VIDFARM_API_KEY = apiKey;
486
532
  process.env.PORT = String(port);
487
533
  if (!process.env.NODE_ENV)
488
534
  process.env.NODE_ENV = "development";
535
+ // Cloud passthrough: only the editor is local — /discover and /library keep
536
+ // listing the upstream (cloud) catalog, templates seed onto disk on demand,
537
+ // and the editor's Render button can hand off to the cloud renderer. The
538
+ // upstream key is the AMBIENT cloud key (never the local bootstrap key).
539
+ const cloudEnabled = !parsed.values["no-cloud"];
540
+ process.env.VIDFARM_UPSTREAM_HOST = cloudEnabled ? host : "";
541
+ process.env.VIDFARM_UPSTREAM_API_KEY = cloudEnabled ? (cloudApiKey ?? "") : "";
489
542
  const { startRuntime } = await import("./runtime.js");
490
543
  await startRuntime();
491
544
  // If a template/fork was requested, pull its composition from the cloud host
@@ -493,13 +546,14 @@ async function runServeCommand(argv) {
493
546
  // resolves it. Best-effort: a failure just drops us to /discover.
494
547
  let openTemplateId = templateId;
495
548
  let openForkId = forkFlag;
496
- if (templateId || forkFlag) {
549
+ if ((templateId || forkFlag) && !cloudEnabled) {
550
+ console.warn("[vidfarm] serve: --no-cloud set — skipping cloud seed of the requested template/fork.");
551
+ }
552
+ else if (templateId || forkFlag) {
497
553
  try {
498
554
  const seeded = await seedForkFromCloud({
499
- host,
500
555
  templateId,
501
556
  forkId: forkFlag,
502
- cloudApiKey,
503
557
  shareToken: parsed.values.share,
504
558
  bootstrapKey: apiKey,
505
559
  refetch: parsed.values.refetch
@@ -518,105 +572,45 @@ async function runServeCommand(argv) {
518
572
  ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
519
573
  : "/discover";
520
574
  const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
521
- printServeBanner({ base, dataDir, openUrl, editorPath });
575
+ printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
522
576
  const shouldOpen = parsed.values.open && !parsed.values["no-open"];
523
577
  if (shouldOpen)
524
578
  openInBrowser(openUrl);
525
579
  // startRuntime() keeps the process alive via its HTTP server; nothing else to do.
526
580
  }
527
581
  // 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.
582
+ // template + fork records so the editor resolves it. Thin wrapper over
583
+ // services/upstream.ts seedFromUpstream (which reads VIDFARM_UPSTREAM_HOST/
584
+ // _API_KEY set above from --host/--api-key). Runs in-process after
585
+ // startRuntime(), so it shares the server's disk-backed records/storage.
533
586
  async function seedForkFromCloud(input) {
534
- const { serverlessRecords } = await import("./services/serverless-records.js");
535
- const { StorageService } = await import("./services/storage.js");
536
587
  const { ServerlessAuthService } = await import("./services/serverless-auth.js");
537
- const storage = new StorageService();
538
- const auth = new ServerlessAuthService();
588
+ const { seedFromUpstream } = await import("./services/upstream.js");
539
589
  // 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 };
590
+ const customer = await new ServerlessAuthService().authenticate(input.bootstrapKey);
591
+ return seedFromUpstream({
592
+ customerId: customer.id,
593
+ templateId: input.templateId,
594
+ forkId: input.forkId,
595
+ shareToken: input.shareToken,
596
+ refetch: input.refetch
597
+ });
610
598
  }
611
599
  function printServeBanner(input) {
612
600
  const line = `${DIM}${"─".repeat(74)}${RESET}`;
613
601
  console.log("");
614
602
  console.log(line);
615
- console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(fully local — records + storage on disk)${RESET}`);
603
+ console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(local editor — records + storage on disk)${RESET}`);
616
604
  console.log(line);
617
605
  console.log(` server ${input.base}`);
618
606
  console.log(` data dir ${input.dataDir}`);
619
607
  console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
608
+ if (input.cloud) {
609
+ console.log(` cloud ${input.cloud.host} ${DIM}(/discover + /library list the cloud catalog; templates seed locally on open${input.cloud.hasKey ? "; Render can hand off to the cloud renderer" : "; set VIDFARM_API_KEY for /library + cloud render"})${RESET}`);
610
+ }
611
+ else {
612
+ console.log(` cloud ${DIM}off (--no-cloud) — local data only${RESET}`);
613
+ }
620
614
  console.log("");
621
615
  console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
622
616
  console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
@@ -1168,17 +1162,77 @@ async function runVideosCommand(argv) {
1168
1162
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1169
1163
  }
1170
1164
  async function runInspirationAddCommand(argv) {
1171
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
1172
- const sourceUrl = parsed.positionals[0];
1173
- if (!sourceUrl)
1174
- throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X).");
1165
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
1166
+ const source = parsed.positionals[0];
1167
+ if (!source)
1168
+ throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
1175
1169
  const ctx = commonContext(parsed.values);
1170
+ // Local file → upload flow: presign, PUT the bytes (straight to S3, or the
1171
+ // server multipart fallback on a local-storage box), then finalize with the
1172
+ // storage key. Anything that parses as http(s) keeps the classic URL ingest.
1173
+ const isUrl = /^https?:\/\//i.test(source);
1174
+ const localPath = isUrl ? null : path.resolve(process.cwd(), source);
1175
+ if (localPath) {
1176
+ if (!existsSync(localPath)) {
1177
+ throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
1178
+ }
1179
+ const fileName = path.basename(localPath);
1180
+ const buffer = readFileSync(localPath);
1181
+ const contentType = guessContentType(fileName);
1182
+ const presign = await apiRequest({
1183
+ method: "POST",
1184
+ host: ctx.host,
1185
+ path: "/discover/templates/upload/presign",
1186
+ auth: ctx.auth,
1187
+ body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1188
+ });
1189
+ assertApiOk(presign, "inspiration-add (presign)");
1190
+ let storageKey = presign.json?.storage_key;
1191
+ let uploadedName = presign.json?.file_name || fileName;
1192
+ if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1193
+ const upload = presign.json.upload;
1194
+ const put = await fetch(upload.url, {
1195
+ method: upload.method || "PUT",
1196
+ headers: upload.headers || {},
1197
+ body: new Uint8Array(buffer)
1198
+ });
1199
+ if (!put.ok)
1200
+ throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
1201
+ }
1202
+ else {
1203
+ const form = new FormData();
1204
+ form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1205
+ const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1206
+ method: "POST",
1207
+ headers: buildAuthHeaders(ctx.auth),
1208
+ body: form
1209
+ });
1210
+ const json = await res.json().catch(() => null);
1211
+ if (!res.ok || !json?.storage_key) {
1212
+ throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1213
+ }
1214
+ storageKey = json.storage_key;
1215
+ uploadedName = json.file_name || fileName;
1216
+ }
1217
+ if (!storageKey)
1218
+ throw new Error("inspiration-add upload did not return a storage key.");
1219
+ const result = await apiRequest({
1220
+ method: "POST",
1221
+ host: ctx.host,
1222
+ path: "/discover/templates",
1223
+ auth: ctx.auth,
1224
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
1225
+ });
1226
+ assertApiOk(result, "inspiration-add");
1227
+ emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1228
+ return;
1229
+ }
1176
1230
  const result = await apiRequest({
1177
1231
  method: "POST",
1178
1232
  host: ctx.host,
1179
1233
  path: "/discover/templates",
1180
1234
  auth: ctx.auth,
1181
- body: { source_url: sourceUrl, tagline: parsed.values.tagline, notes: parsed.values.notes }
1235
+ body: { source_url: source, tagline: parsed.values.tagline, notes: parsed.values.notes }
1182
1236
  });
1183
1237
  assertApiOk(result, "inspiration-add");
1184
1238
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
@@ -1249,18 +1303,18 @@ async function runDecomposeCommand(argv) {
1249
1303
  });
1250
1304
  assertApiOk(result, "decompose");
1251
1305
  if (!ctx.json && result.json?.ghostcut_pending) {
1252
- console.log(`${DIM}GhostCut subtitle-removal is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/ghostcut-poll\` or GET .../ghostcut until done.${RESET}`);
1306
+ console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
1253
1307
  }
1254
1308
  emitResult(result, ctx.json);
1255
1309
  }
1256
- async function runGhostcutCommand(argv) {
1310
+ async function runRemoveVideoCaptionsCommand(argv) {
1257
1311
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
1258
1312
  const forkId = parsed.positionals[0];
1259
1313
  if (!forkId)
1260
- throw new Error("ghostcut requires a fork id.");
1314
+ throw new Error("remove-video-captions requires a fork id.");
1261
1315
  const ctx = commonContext(parsed.values);
1262
- const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/ghostcut`, auth: ctx.auth });
1263
- assertApiOk(result, "ghostcut");
1316
+ const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/remove-video-captions`, auth: ctx.auth });
1317
+ assertApiOk(result, "remove-video-captions");
1264
1318
  emitResult(result, ctx.json);
1265
1319
  }
1266
1320
  async function runVersionsCommand(argv) {
@@ -1412,25 +1466,7 @@ async function pollPrimitiveJob(ctx, jobId) {
1412
1466
  // Upload a local file to the ephemeral temp store, returning its durable URL.
1413
1467
  // Used to turn `--ref @local.png` into a direct asset URL for reference images.
1414
1468
  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;
1469
+ return uploadLocalToTempStore(ctx, absPath);
1434
1470
  }
1435
1471
  // Resolve --ref values: pass http(s) URLs through; upload @local files.
1436
1472
  async function resolveReferenceUrls(ctx, refs) {
@@ -1468,6 +1504,100 @@ function resolveCompositionHtmlPath(target) {
1468
1504
  throw new Error(`No such composition file or dir: ${abs}.`);
1469
1505
  return abs;
1470
1506
  }
1507
+ // ── Local file paths as media (no S3 upload) ────────────────────────────────
1508
+ // Power users bulk-building compositions want to reference media that lives on
1509
+ // their own disk WITHOUT uploading every asset to Vidfarm storage. Two cases:
1510
+ //
1511
+ // 1. The composition is a `vidfarm serve` working copy (its path runs through
1512
+ // <data-dir>/storage/compositions/forks/<forkId>/working/). That box renders
1513
+ // IN-PROCESS for free and serves /storage/:key off local disk, so we just
1514
+ // copy the file into the same disk store and reference it by the box's own
1515
+ // localhost URL. Nothing ever touches S3.
1516
+ // 2. Any other composition dir (e.g. a `vidfarm pull` dir headed for cloud
1517
+ // render/publish). The cloud renderer can't reach the user's disk, so a
1518
+ // local file has to become a durable URL — we upload it to the EPHEMERAL
1519
+ // temp store, namescoped under a folder (default `temp/`) so it stays easy
1520
+ // to find and purge instead of bloating the durable library.
1521
+ //
1522
+ // A localhost /storage URL only resolves against the serve box that owns the
1523
+ // file, so case-1 compositions are for local render/preview; to render in the
1524
+ // cloud or share, upload the media (case 2) instead.
1525
+ // Read the durable URL out of a temporary-files upload response, tolerating both
1526
+ // the camelCase (viewUrl/s3Url) and snake_case (view_url/s3_url) shapes the API
1527
+ // has used across versions.
1528
+ function readUploadedFileUrl(json) {
1529
+ const file = json?.file ?? {};
1530
+ return file.viewUrl ?? file.view_url ?? file.s3Url ?? file.s3_url ?? null;
1531
+ }
1532
+ // If `htmlPath` sits inside a `vidfarm serve` disk store, return that store's
1533
+ // root (`<data-dir>/storage`); otherwise null. Detected structurally from the
1534
+ // canonical working-copy layout rather than any running server.
1535
+ function findServeStorageRoot(htmlPath) {
1536
+ const norm = path.resolve(htmlPath);
1537
+ const marker = `${path.sep}storage${path.sep}compositions${path.sep}forks${path.sep}`;
1538
+ const idx = norm.indexOf(marker);
1539
+ if (idx === -1)
1540
+ return null;
1541
+ return norm.slice(0, idx + `${path.sep}storage`.length);
1542
+ }
1543
+ // Copy a local media file into a serve box's disk store under a public-read key
1544
+ // and return that storage key. The key is stable-random so re-placing the same
1545
+ // file never collides.
1546
+ function importLocalMediaToServeStore(storageRoot, absFile) {
1547
+ const safeName = path.basename(absFile).replace(/[^\w.\-]+/g, "_") || "media.bin";
1548
+ const key = `users/local-media/${randomUUID()}/${safeName}`;
1549
+ const dest = path.join(storageRoot, ...key.split("/"));
1550
+ mkdirSync(path.dirname(dest), { recursive: true });
1551
+ copyFileSync(absFile, dest);
1552
+ return key;
1553
+ }
1554
+ function serveStorageUrl(baseUrl, key) {
1555
+ return `${trimTrailingSlash(baseUrl)}/storage/${encodeURIComponent(key)}`;
1556
+ }
1557
+ // Upload a local file to the EPHEMERAL temp store on ctx.host, optionally
1558
+ // namescoped under a folder, and return its durable URL.
1559
+ async function uploadLocalToTempStore(ctx, absPath, folder) {
1560
+ if (!existsSync(absPath))
1561
+ throw new Error(`No such local file: ${absPath}`);
1562
+ const form = new FormData();
1563
+ form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
1564
+ if (folder)
1565
+ form.append("folder_path", folder);
1566
+ const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
1567
+ const text = await res.text();
1568
+ let json = null;
1569
+ try {
1570
+ json = text ? JSON.parse(text) : null;
1571
+ }
1572
+ catch {
1573
+ json = null;
1574
+ }
1575
+ if (!res.ok)
1576
+ throw new Error(`local media upload failed (${res.status}): ${text.slice(0, 200)}`);
1577
+ const url = readUploadedFileUrl(json);
1578
+ if (!url)
1579
+ throw new Error("local media upload returned no durable URL.");
1580
+ return url;
1581
+ }
1582
+ // Turn a --src value (http(s) URL or local file path) into a fetchable media
1583
+ // URL, importing/uploading local files as described above.
1584
+ async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
1585
+ const value = rawSrc.trim();
1586
+ if (/^https?:\/\//i.test(value))
1587
+ return { src: value, origin: "url", detail: null };
1588
+ const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
1589
+ if (!existsSync(abs)) {
1590
+ throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
1591
+ }
1592
+ const storageRoot = findServeStorageRoot(opts.htmlPath);
1593
+ if (storageRoot) {
1594
+ const key = importLocalMediaToServeStore(storageRoot, abs);
1595
+ return { src: serveStorageUrl(opts.baseUrl, key), origin: "local-serve", detail: key };
1596
+ }
1597
+ const folder = opts.folder ?? "temp";
1598
+ const url = await uploadLocalToTempStore(ctx, abs, folder);
1599
+ return { src: url, origin: "cloud-temp", detail: folder };
1600
+ }
1471
1601
  // Apply an insert/replace of a media URL onto a composition.html on disk.
1472
1602
  function placeMediaOnDisk(input) {
1473
1603
  const html = readFileSync(input.htmlPath, "utf8");
@@ -1483,6 +1613,8 @@ function placeMediaOnDisk(input) {
1483
1613
  width: input.width,
1484
1614
  height: input.height,
1485
1615
  objectFit: input.objectFit,
1616
+ kenBurns: input.kenBurns,
1617
+ kenBurnsIntensity: input.kenBurnsIntensity,
1486
1618
  slug: input.slug
1487
1619
  };
1488
1620
  const result = input.replace
@@ -1512,6 +1644,8 @@ async function runGenerateCommand(argv) {
1512
1644
  at: { type: "string" },
1513
1645
  replace: { type: "string" },
1514
1646
  track: { type: "string" },
1647
+ "ken-burns": { type: "string" },
1648
+ "ken-burns-intensity": { type: "string" },
1515
1649
  "layer-key": { type: "string" }
1516
1650
  }
1517
1651
  });
@@ -1583,6 +1717,8 @@ async function runGenerateCommand(argv) {
1583
1717
  at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
1584
1718
  replace: parsed.values.replace,
1585
1719
  track: parsed.values.track ? Number(parsed.values.track) : undefined,
1720
+ kenBurns: parsed.values["ken-burns"],
1721
+ kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
1586
1722
  layerKey: parsed.values["layer-key"]
1587
1723
  });
1588
1724
  if (!ctx.json)
@@ -1614,21 +1750,34 @@ async function runPlaceCommand(argv) {
1614
1750
  width: { type: "string" },
1615
1751
  height: { type: "string" },
1616
1752
  "object-fit": { type: "string" },
1753
+ "ken-burns": { type: "string" },
1754
+ "ken-burns-intensity": { type: "string" },
1617
1755
  "layer-key": { type: "string" },
1618
- slug: { type: "string" }
1756
+ slug: { type: "string" },
1757
+ // Local file support: where a serve box serves /storage from, and which
1758
+ // temp folder cloud uploads land under.
1759
+ "base-url": { type: "string" },
1760
+ folder: { type: "string" }
1619
1761
  }
1620
1762
  });
1621
1763
  const target = parsed.positionals[0];
1622
1764
  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>.");
1765
+ throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url|file> ...`.");
1766
+ const rawSrc = parsed.values.src;
1767
+ if (!rawSrc)
1768
+ throw new Error("place requires --src <media URL or local file path>.");
1627
1769
  const ctx = commonContext(parsed.values);
1628
1770
  const htmlPath = resolveCompositionHtmlPath(target);
1771
+ const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
1772
+ const resolvedSrc = await resolvePlaceableMediaSrc(ctx, rawSrc, {
1773
+ htmlPath,
1774
+ baseUrl,
1775
+ folder: parsed.values.folder
1776
+ });
1777
+ const src = resolvedSrc.src;
1629
1778
  const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
1630
1779
  ? parsed.values.kind
1631
- : inferMediaKindFromUrl(src);
1780
+ : inferMediaKindFromUrl(rawSrc);
1632
1781
  const num = (v) => (v !== undefined ? Number(v) : undefined);
1633
1782
  const placement = placeMediaOnDisk({
1634
1783
  htmlPath,
@@ -1643,13 +1792,21 @@ async function runPlaceCommand(argv) {
1643
1792
  width: num(parsed.values.width),
1644
1793
  height: num(parsed.values.height),
1645
1794
  objectFit: parsed.values["object-fit"],
1795
+ kenBurns: parsed.values["ken-burns"],
1796
+ kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
1646
1797
  layerKey: parsed.values["layer-key"],
1647
1798
  slug: parsed.values.slug
1648
1799
  });
1649
1800
  if (ctx.json) {
1650
- printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
1801
+ printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps });
1651
1802
  }
1652
1803
  else {
1804
+ if (resolvedSrc.origin === "local-serve") {
1805
+ console.log(`${DIM}Local file → serve disk store (${resolvedSrc.detail}); referenced as ${src}. No S3 upload. Renders on this serve box only.${RESET}`);
1806
+ }
1807
+ else if (resolvedSrc.origin === "cloud-temp") {
1808
+ console.log(`${DIM}Local file → uploaded to ephemeral temp store under "${resolvedSrc.detail}/" on ${ctx.host}; referenced as ${src}.${RESET}`);
1809
+ }
1653
1810
  console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
1654
1811
  console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
1655
1812
  console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
@@ -1743,6 +1900,7 @@ async function runApproveCommand(argv) {
1743
1900
  caption: { type: "string" },
1744
1901
  title: { type: "string" },
1745
1902
  pinned: { type: "string" },
1903
+ folder: { type: "string" },
1746
1904
  tracer: { type: "string" }
1747
1905
  }
1748
1906
  });
@@ -1750,11 +1908,21 @@ async function runApproveCommand(argv) {
1750
1908
  const caption = parsed.values.caption;
1751
1909
  if (!caption)
1752
1910
  throw new Error("approve requires --caption <text>.");
1911
+ // An approved post is a shareable cloud page, so any media must be a durable
1912
+ // URL the page can load. Pass an http(s) URL through; a local file is uploaded
1913
+ // to the ephemeral temp store (default folder `temp/`) first.
1914
+ const uploadFolder = parsed.values.folder ?? "temp";
1915
+ const resolveApproveMedia = async (value) => {
1916
+ if (/^https?:\/\//i.test(value.trim()))
1917
+ return value.trim();
1918
+ const abs = path.resolve(process.cwd(), value.trim());
1919
+ return uploadLocalToTempStore(ctx, abs, uploadFolder);
1920
+ };
1753
1921
  const media = [];
1754
1922
  if (parsed.values.video)
1755
- media.push({ url: parsed.values.video, kind: "video", role: "primary" });
1923
+ media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
1756
1924
  for (const url of parsed.values.media ?? [])
1757
- media.push({ url });
1925
+ media.push({ url: await resolveApproveMedia(String(url)) });
1758
1926
  if (media.length === 0) {
1759
1927
  console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
1760
1928
  }
@@ -1920,7 +2088,7 @@ async function runUploadCommand(argv) {
1920
2088
  }
1921
2089
  const result = { status: res.status, ok: res.ok, json, text };
1922
2090
  assertApiOk(result, "upload");
1923
- emitResult(result, ctx.json, [["Durable URL ", result.json?.file?.view_url ?? result.json?.file?.s3_url]]);
2091
+ emitResult(result, ctx.json, [["Durable URL ", readUploadedFileUrl(result.json)]]);
1924
2092
  }
1925
2093
  async function runDownloadCommand(argv) {
1926
2094
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });