@mevdragon/vidfarm-devcli 0.7.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";
@@ -71,7 +72,10 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
71
72
  --query <text> Keyword search over the catalog
72
73
  --mine Only your own uploaded inspirations
73
74
  --limit <n> Max results
74
- 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.
75
79
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
76
80
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
77
81
 
@@ -99,17 +103,28 @@ Generate AI media and drop it on the timeline (for local coding agents):
99
103
  --no-wait Return the job id instead of polling to the URL
100
104
  --place <dir> After generating, place it into that composition
101
105
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
102
- place <dir|composition.html> Insert a media URL into a local composition
106
+ place <dir|composition.html> Insert media into a local composition
103
107
  (fill a gap or replace a scene) — same clip markup
104
108
  the browser editor makes; serve live-morphs it.
105
- --src <url> Direct media URL to place (required)
106
- --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)
107
118
  --at <time> Start it at this time (seconds / mm:ss) — fill a gap
108
119
  --replace <layer_key> Replace this scene/layer (copies its timing+geometry)
109
120
  --duration <n> Timeline length in seconds
110
121
  --track <n> Timeline layer index (auto if omitted)
111
122
  --x --y --width --height Geometry as % (default full canvas 0/0/100/100)
112
123
  --object-fit <fit> cover|contain|fill|none|scale-down (default cover)
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)
113
128
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
114
129
  original vs decomposed (caption-free),
115
130
  non-billing (alias: ghostcut)
@@ -125,6 +140,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
125
140
 
126
141
  Approve & schedule (publish a finished MP4 as a shareable post):
127
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)
128
147
  posts List your approved posts → GET /api/v1/approved/posts
129
148
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
130
149
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -146,7 +165,9 @@ Agent skill (install the latest director skill so your AI agent can act):
146
165
 
147
166
  Files (multi-step flows the devcli handles for you):
148
167
  upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
149
- --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.
150
171
  put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
151
172
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
152
173
  --as <name> Name the saved file (required for --content/--stdin)
@@ -160,6 +181,10 @@ Files (multi-step flows the devcli handles for you):
160
181
  --print Print text contents (md/txt/csv/json) instead of saving
161
182
  put-file / get-file / files are the My Files (persistent) read+write set;
162
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.
163
188
 
164
189
  Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
165
190
  free Reuse a decomposed template, swap captions/images/existing MP4s
@@ -496,6 +521,12 @@ async function runServeCommand(argv) {
496
521
  // local-render by definition — cloud rendering is the EXPLICIT
497
522
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
498
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 = "";
499
530
  process.env.VIDFARM_DATA_DIR = dataDir;
500
531
  process.env.VIDFARM_API_KEY = apiKey;
501
532
  process.env.PORT = String(port);
@@ -1131,17 +1162,77 @@ async function runVideosCommand(argv) {
1131
1162
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1132
1163
  }
1133
1164
  async function runInspirationAddCommand(argv) {
1134
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
1135
- const sourceUrl = parsed.positionals[0];
1136
- if (!sourceUrl)
1137
- 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.");
1138
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
+ }
1139
1230
  const result = await apiRequest({
1140
1231
  method: "POST",
1141
1232
  host: ctx.host,
1142
1233
  path: "/discover/templates",
1143
1234
  auth: ctx.auth,
1144
- 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 }
1145
1236
  });
1146
1237
  assertApiOk(result, "inspiration-add");
1147
1238
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
@@ -1375,25 +1466,7 @@ async function pollPrimitiveJob(ctx, jobId) {
1375
1466
  // Upload a local file to the ephemeral temp store, returning its durable URL.
1376
1467
  // Used to turn `--ref @local.png` into a direct asset URL for reference images.
1377
1468
  async function uploadTempFileForRef(ctx, absPath) {
1378
- if (!existsSync(absPath))
1379
- throw new Error(`No such reference file: ${absPath}`);
1380
- const form = new FormData();
1381
- form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
1382
- const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
1383
- const text = await res.text();
1384
- let json = null;
1385
- try {
1386
- json = text ? JSON.parse(text) : null;
1387
- }
1388
- catch {
1389
- json = null;
1390
- }
1391
- if (!res.ok)
1392
- throw new Error(`reference upload failed (${res.status}): ${text.slice(0, 200)}`);
1393
- const url = json?.file?.view_url ?? json?.file?.s3_url;
1394
- if (!url)
1395
- throw new Error("reference upload returned no durable URL.");
1396
- return url;
1469
+ return uploadLocalToTempStore(ctx, absPath);
1397
1470
  }
1398
1471
  // Resolve --ref values: pass http(s) URLs through; upload @local files.
1399
1472
  async function resolveReferenceUrls(ctx, refs) {
@@ -1431,6 +1504,100 @@ function resolveCompositionHtmlPath(target) {
1431
1504
  throw new Error(`No such composition file or dir: ${abs}.`);
1432
1505
  return abs;
1433
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
+ }
1434
1601
  // Apply an insert/replace of a media URL onto a composition.html on disk.
1435
1602
  function placeMediaOnDisk(input) {
1436
1603
  const html = readFileSync(input.htmlPath, "utf8");
@@ -1446,6 +1613,8 @@ function placeMediaOnDisk(input) {
1446
1613
  width: input.width,
1447
1614
  height: input.height,
1448
1615
  objectFit: input.objectFit,
1616
+ kenBurns: input.kenBurns,
1617
+ kenBurnsIntensity: input.kenBurnsIntensity,
1449
1618
  slug: input.slug
1450
1619
  };
1451
1620
  const result = input.replace
@@ -1475,6 +1644,8 @@ async function runGenerateCommand(argv) {
1475
1644
  at: { type: "string" },
1476
1645
  replace: { type: "string" },
1477
1646
  track: { type: "string" },
1647
+ "ken-burns": { type: "string" },
1648
+ "ken-burns-intensity": { type: "string" },
1478
1649
  "layer-key": { type: "string" }
1479
1650
  }
1480
1651
  });
@@ -1546,6 +1717,8 @@ async function runGenerateCommand(argv) {
1546
1717
  at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
1547
1718
  replace: parsed.values.replace,
1548
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,
1549
1722
  layerKey: parsed.values["layer-key"]
1550
1723
  });
1551
1724
  if (!ctx.json)
@@ -1577,21 +1750,34 @@ async function runPlaceCommand(argv) {
1577
1750
  width: { type: "string" },
1578
1751
  height: { type: "string" },
1579
1752
  "object-fit": { type: "string" },
1753
+ "ken-burns": { type: "string" },
1754
+ "ken-burns-intensity": { type: "string" },
1580
1755
  "layer-key": { type: "string" },
1581
- 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" }
1582
1761
  }
1583
1762
  });
1584
1763
  const target = parsed.positionals[0];
1585
1764
  if (!target)
1586
- throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
1587
- const src = parsed.values.src;
1588
- if (!src || !/^https?:\/\//i.test(src))
1589
- 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>.");
1590
1769
  const ctx = commonContext(parsed.values);
1591
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;
1592
1778
  const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
1593
1779
  ? parsed.values.kind
1594
- : inferMediaKindFromUrl(src);
1780
+ : inferMediaKindFromUrl(rawSrc);
1595
1781
  const num = (v) => (v !== undefined ? Number(v) : undefined);
1596
1782
  const placement = placeMediaOnDisk({
1597
1783
  htmlPath,
@@ -1606,13 +1792,21 @@ async function runPlaceCommand(argv) {
1606
1792
  width: num(parsed.values.width),
1607
1793
  height: num(parsed.values.height),
1608
1794
  objectFit: parsed.values["object-fit"],
1795
+ kenBurns: parsed.values["ken-burns"],
1796
+ kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
1609
1797
  layerKey: parsed.values["layer-key"],
1610
1798
  slug: parsed.values.slug
1611
1799
  });
1612
1800
  if (ctx.json) {
1613
- 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 });
1614
1802
  }
1615
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
+ }
1616
1810
  console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
1617
1811
  console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
1618
1812
  console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
@@ -1706,6 +1900,7 @@ async function runApproveCommand(argv) {
1706
1900
  caption: { type: "string" },
1707
1901
  title: { type: "string" },
1708
1902
  pinned: { type: "string" },
1903
+ folder: { type: "string" },
1709
1904
  tracer: { type: "string" }
1710
1905
  }
1711
1906
  });
@@ -1713,11 +1908,21 @@ async function runApproveCommand(argv) {
1713
1908
  const caption = parsed.values.caption;
1714
1909
  if (!caption)
1715
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
+ };
1716
1921
  const media = [];
1717
1922
  if (parsed.values.video)
1718
- media.push({ url: parsed.values.video, kind: "video", role: "primary" });
1923
+ media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
1719
1924
  for (const url of parsed.values.media ?? [])
1720
- media.push({ url });
1925
+ media.push({ url: await resolveApproveMedia(String(url)) });
1721
1926
  if (media.length === 0) {
1722
1927
  console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
1723
1928
  }
@@ -1883,7 +2088,7 @@ async function runUploadCommand(argv) {
1883
2088
  }
1884
2089
  const result = { status: res.status, ok: res.ok, json, text };
1885
2090
  assertApiOk(result, "upload");
1886
- 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)]]);
1887
2092
  }
1888
2093
  async function runDownloadCommand(argv) {
1889
2094
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
@@ -146,6 +146,31 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
146
146
  }
147
147
  }
148
148
 
149
+ // Ken Burns image clips carry a CSS transform animation (vf-kb-* keyframes)
150
+ // directly on the [data-kenburns] <img>. CSS animations run on the wall clock,
151
+ // not player time, so the runtime scrubs them: pause + set currentTime from
152
+ // normalized clip progress. Normalizing against data-duration (instead of
153
+ // trusting the inline --vf-kb-dur) keeps the preview correct even while a
154
+ // timeline resize has not yet re-synced the inline duration.
155
+ function syncKenBurnsClip(clip, timing) {
156
+ if (typeof clip.getAnimations !== "function") return;
157
+ const progress = timing.duration > 0
158
+ ? Math.max(0, Math.min(1, (time - timing.start) / timing.duration))
159
+ : 0;
160
+ let anims = [];
161
+ try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
162
+ for (const anim of anims) {
163
+ if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-kb-") !== 0) continue;
164
+ let total = 0;
165
+ try { total = Number(anim.effect?.getComputedTiming?.().duration) || 0; } catch {}
166
+ if (!total) continue;
167
+ try {
168
+ anim.pause();
169
+ anim.currentTime = progress * total;
170
+ } catch {}
171
+ }
172
+ }
173
+
149
174
  function apply(nextTime, options = {}) {
150
175
  const forceSeek = Boolean(options.forceSeek);
151
176
  const fromClock = Boolean(options.fromClock);
@@ -160,6 +185,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
160
185
  } else {
161
186
  setVisibility(clip, active);
162
187
  }
188
+ if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
163
189
  }
164
190
  if (clip instanceof HTMLMediaElement) {
165
191
  applyMediaState(clip);
@@ -6,6 +6,9 @@
6
6
  // pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
7
7
  // ffmpeg, no network.
8
8
  import { parseHTML } from "linkedom";
9
+ const KEN_BURNS_PRESETS = new Set([
10
+ "zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
11
+ ]);
9
12
  const round3 = (v) => Number(v.toFixed(3));
10
13
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
11
14
  function serialize(document) {
@@ -209,6 +212,15 @@ function buildMediaClip(document, opts, id, track, geom) {
209
212
  node.setAttribute("data-playback-start", ps);
210
213
  styles.push("background:#050604");
211
214
  }
215
+ else if (opts.kenBurns && KEN_BURNS_PRESETS.has(opts.kenBurns)) {
216
+ // Ken Burns lives on the <img> itself (see src/hyperframes/composition.ts):
217
+ // data-kenburns picks the keyframes; the inline vars supply span + strength.
218
+ node.setAttribute("data-kenburns", opts.kenBurns);
219
+ styles.push(`--vf-kb-dur:${round3(geom.duration)}s`);
220
+ if (opts.kenBurnsIntensity !== undefined && Number.isFinite(opts.kenBurnsIntensity)) {
221
+ styles.push(`--vf-kb-amount:${Math.max(0.04, Math.min(0.5, opts.kenBurnsIntensity))}`);
222
+ }
223
+ }
212
224
  node.setAttribute("style", `${styles.join(";")};`);
213
225
  return node;
214
226
  }
@@ -77,6 +77,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
77
77
  "If the composition brief's cast is absent or empty, or the referenced person is not in it, fall back to extracting a still yourself: call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source video, then POST /api/v1/primitives/videos/extract-frame with source_video_url set to that URL and at_ms at a moment the person is clearly on screen (use video_context scene timings), then feed the returned image URL as generate_layer input_references (video) or prompt_attachments (image) exactly as above. Prefer the cast reference_url when present because it is already isolated from other people and reusable.",
78
78
  "When placing generated character media on the timeline, prefer generate_layer (it applies the placement rules for you): to drop the character into a blank moment use intent='fill_gap' with start/duration on a timeline_gaps span; to swap them into an existing scene use intent='replace_layer' with replace_layer_key (its timing and full-canvas geometry are copied automatically). Always set aspect_ratio to the canvas (e.g. 9:16 for vertical) so the clip fills the frame. Only when you already have a finished media URL should you fall back to add_layer with explicit collision-free start/duration and full-canvas geometry (x=0, y=0, width=100, height=100) for a timeline-proxy/full-canvas replacement.",
79
79
  "Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
80
+ "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
80
81
  "Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
81
82
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
82
83
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -142,21 +142,76 @@ function HomepageClientApp({ boot }) {
142
142
  }, [refreshFeed]);
143
143
  const handleAddTemplate = useCallback(async (submission) => {
144
144
  try {
145
+ // Upload path: presign → PUT the bytes (straight to S3, or the local
146
+ // multipart fallback) → finalize with the storage key. The URL path
147
+ // posts source_url directly, exactly as before.
148
+ let uploadRef = null;
149
+ if (submission.file) {
150
+ const file = submission.file;
151
+ const presignResponse = await fetch("/discover/templates/upload/presign", {
152
+ method: "POST",
153
+ credentials: "same-origin",
154
+ headers: { "content-type": "application/json", Accept: "application/json" },
155
+ body: JSON.stringify({
156
+ file_name: file.name,
157
+ content_type: file.type || null,
158
+ size_bytes: file.size
159
+ })
160
+ });
161
+ const presign = await presignResponse.json().catch(() => ({}));
162
+ if (!presignResponse.ok) {
163
+ return { ok: false, error: presign.error || "Unable to start the video upload." };
164
+ }
165
+ if (presign.transport === "presigned" && presign.upload?.url && presign.storage_key) {
166
+ const put = await fetch(presign.upload.url, {
167
+ method: presign.upload.method || "PUT",
168
+ headers: presign.upload.headers || {},
169
+ body: file
170
+ });
171
+ if (!put.ok) {
172
+ return { ok: false, error: `Video upload failed (${put.status}). Try again.` };
173
+ }
174
+ uploadRef = { storage_key: presign.storage_key, file_name: presign.file_name || file.name };
175
+ }
176
+ else {
177
+ const form = new FormData();
178
+ form.append("file", file, presign.file_name || file.name);
179
+ const direct = await fetch(presign.upload?.url || "/discover/templates/upload", {
180
+ method: "POST",
181
+ credentials: "same-origin",
182
+ body: form
183
+ });
184
+ const directPayload = await direct.json().catch(() => ({}));
185
+ if (!direct.ok || !directPayload.storage_key) {
186
+ return { ok: false, error: directPayload.error || "Video upload failed. Try again." };
187
+ }
188
+ uploadRef = { storage_key: directPayload.storage_key, file_name: directPayload.file_name || file.name };
189
+ }
190
+ }
145
191
  const response = await fetch("/discover/templates", {
146
192
  method: "POST",
147
193
  credentials: "same-origin",
148
194
  headers: { "content-type": "application/json", Accept: "application/json" },
149
- body: JSON.stringify({
150
- source_url: submission.sourceUrl,
151
- tagline: submission.tagline || null,
152
- notes: submission.notes || null
153
- })
195
+ body: JSON.stringify(uploadRef
196
+ ? {
197
+ upload: uploadRef,
198
+ title: submission.title || null,
199
+ tagline: submission.tagline || null,
200
+ notes: submission.notes || null
201
+ }
202
+ : {
203
+ source_url: submission.sourceUrl,
204
+ tagline: submission.tagline || null,
205
+ notes: submission.notes || null
206
+ })
154
207
  });
155
208
  const payload = await response.json().catch(() => ({}));
156
209
  if (!response.ok) {
157
210
  return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to add that video." };
158
211
  }
159
- debugLog("homepage.template_added", { source_url: submission.sourceUrl });
212
+ debugLog("homepage.template_added", uploadRef
213
+ ? { storage_key: uploadRef.storage_key }
214
+ : { source_url: submission.sourceUrl });
160
215
  await refreshFeed();
161
216
  return { ok: true };
162
217
  }