@mevdragon/vidfarm-devcli 0.7.0 → 0.8.0

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.
Files changed (37) hide show
  1. package/SKILL.director.md +43 -10
  2. package/SKILL.platform.md +5 -3
  3. package/demo/dist/app.js +107 -57
  4. package/dist/src/app.js +1115 -17
  5. package/dist/src/cli.js +304 -43
  6. package/dist/src/composition-runtime.js +26 -0
  7. package/dist/src/config.js +6 -0
  8. package/dist/src/devcli/clip-store.js +335 -0
  9. package/dist/src/devcli/clips.js +803 -0
  10. package/dist/src/devcli/composition-edit.js +12 -0
  11. package/dist/src/editor-chat.js +1 -0
  12. package/dist/src/frontend/homepage-client.js +61 -6
  13. package/dist/src/frontend/homepage-view.js +22 -4
  14. package/dist/src/homepage.js +46 -0
  15. package/dist/src/hyperframes/composition.js +87 -0
  16. package/dist/src/primitive-registry.js +136 -0
  17. package/dist/src/services/billing.js +1 -0
  18. package/dist/src/services/clip-curation/cost.js +112 -0
  19. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  20. package/dist/src/services/clip-curation/gemini.js +342 -0
  21. package/dist/src/services/clip-curation/index.js +15 -0
  22. package/dist/src/services/clip-curation/presets.js +20 -0
  23. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  24. package/dist/src/services/clip-curation/query.js +163 -0
  25. package/dist/src/services/clip-curation/refine.js +136 -0
  26. package/dist/src/services/clip-curation/scan.js +137 -0
  27. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  28. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  29. package/dist/src/services/clip-curation/types.js +7 -0
  30. package/dist/src/services/clip-records.js +209 -0
  31. package/dist/src/services/clip-search.js +47 -0
  32. package/dist/src/services/clip-vectors.js +125 -0
  33. package/dist/src/services/hyperframes.js +373 -1
  34. package/dist/src/services/scene-annotations.js +32 -0
  35. package/dist/src/services/storage.js +6 -0
  36. package/package.json +5 -1
  37. package/public/assets/homepage-client-app.js +18 -18
package/dist/src/cli.js CHANGED
@@ -1,13 +1,15 @@
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";
8
9
  import { Readable } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
11
  import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
12
+ import { runClipsCommand } from "./devcli/clips.js";
11
13
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
12
14
  // `serve` command boots the FULL editor locally (single origin, disk-backed
13
15
  // records + storage) so power users edit compositions on disk while a browser
@@ -71,7 +73,10 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
71
73
  --query <text> Keyword search over the catalog
72
74
  --mine Only your own uploaded inspirations
73
75
  --limit <n> Max results
74
- inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
76
+ inspiration-add <url|file> Ingest a TikTok/YT/IG/X URL or upload a → POST /discover/templates
77
+ local MP4/MOV/WebM file — as a private (files: presign + PUT via
78
+ template. --title (uploads; defaults to the /discover/templates/upload)
79
+ template id), --tagline, --notes optional.
75
80
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
76
81
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
77
82
 
@@ -99,20 +104,42 @@ Generate AI media and drop it on the timeline (for local coding agents):
99
104
  --no-wait Return the job id instead of polling to the URL
100
105
  --place <dir> After generating, place it into that composition
101
106
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
102
- place <dir|composition.html> Insert a media URL into a local composition
107
+ place <dir|composition.html> Insert media into a local composition
103
108
  (fill a gap or replace a scene) — same clip markup
104
109
  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)
110
+ --src <url|file> Media to place (required). A URL is used as-is; a
111
+ LOCAL FILE PATH is imported without bloating S3 — a
112
+ serve working copy copies it onto the box's own disk
113
+ store (free, local render only); any other dir
114
+ uploads it to the ephemeral temp store (folder below).
115
+ --base-url <url> Serve box origin that hosts /storage (default
116
+ http://localhost:3000) — set if you ran serve --port.
117
+ --folder <path> Temp-store folder for uploaded local files (default: temp)
118
+ --kind <video|image> Media kind (inferred from the URL/path if omitted)
107
119
  --at <time> Start it at this time (seconds / mm:ss) — fill a gap
108
120
  --replace <layer_key> Replace this scene/layer (copies its timing+geometry)
109
121
  --duration <n> Timeline length in seconds
110
122
  --track <n> Timeline layer index (auto if omitted)
111
123
  --x --y --width --height Geometry as % (default full canvas 0/0/100/100)
112
124
  --object-fit <fit> cover|contain|fill|none|scale-down (default cover)
125
+ --ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
126
+ zoom-out|pan-left|pan-right|pan-up|pan-down|
127
+ zoom-in-left|zoom-in-right
128
+ --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
113
129
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
114
130
  original vs decomposed (caption-free),
115
131
  non-billing (alias: ghostcut)
132
+
133
+ Clip curation (the third library — mine long-form video into a reusable clip store):
134
+ clips scan <video-path> Detect scenes → tag + transcribe (Gemini) LOCAL: ffmpeg + BYOK Gemini
135
+ → embed → persist to ~/.vidfarm/clips.db. (cloud: POST /clips/scan)
136
+ --tier flash|flash-lite Gemini tier (default flash-lite, cheapest)
137
+ --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
138
+ clips list List the local clip library → GET /clips
139
+ clips search "<text>" Hybrid structured + semantic search → POST /clips/search
140
+ clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
141
+ clips export <ids...> --to <dir> Copy clip mp4s + metadata out
142
+ (run 'vidfarm clips' for the full clips help)
116
143
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
117
144
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
118
145
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -125,6 +152,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
125
152
 
126
153
  Approve & schedule (publish a finished MP4 as a shareable post):
127
154
  approve Approve media into a preview/share page → POST /api/v1/approved/posts
155
+ --video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
156
+ --media <url|file> Extra media (repeatable; local files auto-upload to temp/)
157
+ --folder <path> Temp-store folder for uploaded local files (default: temp)
158
+ --caption <text> Post caption (required)
128
159
  posts List your approved posts → GET /api/v1/approved/posts
129
160
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
130
161
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -146,7 +177,9 @@ Agent skill (install the latest director skill so your AI agent can act):
146
177
 
147
178
  Files (multi-step flows the devcli handles for you):
148
179
  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
180
+ --folder <path> Namescope the upload under a folder. Prefer a
181
+ throwaway 'temp' folder (--folder temp) for scratch
182
+ media so it never clutters your durable library.
150
183
  put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
151
184
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
152
185
  --as <name> Name the saved file (required for --content/--stdin)
@@ -160,6 +193,10 @@ Files (multi-step flows the devcli handles for you):
160
193
  --print Print text contents (md/txt/csv/json) instead of saving
161
194
  put-file / get-file / files are the My Files (persistent) read+write set;
162
195
  upload is the throwaway temp store for dropping media into a composition.
196
+ Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
197
+ them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
198
+ to that box's local disk store, free local render). When you DO need a durable
199
+ URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
163
200
 
164
201
  Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
165
202
  free Reuse a decomposed template, swap captions/images/existing MP4s
@@ -326,6 +363,9 @@ async function main() {
326
363
  case "save-file":
327
364
  await runPutFileCommand(rest);
328
365
  return;
366
+ case "clips":
367
+ await runClipsCommand(rest);
368
+ return;
329
369
  default:
330
370
  if (!command.startsWith("-")) {
331
371
  // Positional template id → default to booting the local editor.
@@ -496,6 +536,12 @@ async function runServeCommand(argv) {
496
536
  // local-render by definition — cloud rendering is the EXPLICIT
497
537
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
498
538
  process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
539
+ // Same trap as the state machine ARN: a dev-shell .env can carry the staging
540
+ // media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
541
+ // Lambda that cannot fetch this box's localhost storage URLs ("fetch
542
+ // failed"). Media ops on a serve box always run in-process.
543
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL = "";
544
+ process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_SECRET = "";
499
545
  process.env.VIDFARM_DATA_DIR = dataDir;
500
546
  process.env.VIDFARM_API_KEY = apiKey;
501
547
  process.env.PORT = String(port);
@@ -772,7 +818,10 @@ async function fetchCompositionFiles(input) {
772
818
  // can ground edits in what the video actually says and shows.
773
819
  // cast.json carries the identified recurring people/characters plus their
774
820
  // reference-still URLs so agents can reuse "the same person" in new gens.
775
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
821
+ // scene-annotations.json carries the detected content format + per-scene
822
+ // literal + viral DNA an agent needs to REPLACE a scene from the clip library
823
+ // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
824
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json"];
776
825
  for (const filename of files) {
777
826
  const target = path.join(input.dir, filename);
778
827
  if (existsSync(target) && !input.refetch) {
@@ -1131,17 +1180,77 @@ async function runVideosCommand(argv) {
1131
1180
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1132
1181
  }
1133
1182
  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).");
1183
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
1184
+ const source = parsed.positionals[0];
1185
+ if (!source)
1186
+ throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
1138
1187
  const ctx = commonContext(parsed.values);
1188
+ // Local file → upload flow: presign, PUT the bytes (straight to S3, or the
1189
+ // server multipart fallback on a local-storage box), then finalize with the
1190
+ // storage key. Anything that parses as http(s) keeps the classic URL ingest.
1191
+ const isUrl = /^https?:\/\//i.test(source);
1192
+ const localPath = isUrl ? null : path.resolve(process.cwd(), source);
1193
+ if (localPath) {
1194
+ if (!existsSync(localPath)) {
1195
+ throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
1196
+ }
1197
+ const fileName = path.basename(localPath);
1198
+ const buffer = readFileSync(localPath);
1199
+ const contentType = guessContentType(fileName);
1200
+ const presign = await apiRequest({
1201
+ method: "POST",
1202
+ host: ctx.host,
1203
+ path: "/discover/templates/upload/presign",
1204
+ auth: ctx.auth,
1205
+ body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
1206
+ });
1207
+ assertApiOk(presign, "inspiration-add (presign)");
1208
+ let storageKey = presign.json?.storage_key;
1209
+ let uploadedName = presign.json?.file_name || fileName;
1210
+ if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
1211
+ const upload = presign.json.upload;
1212
+ const put = await fetch(upload.url, {
1213
+ method: upload.method || "PUT",
1214
+ headers: upload.headers || {},
1215
+ body: new Uint8Array(buffer)
1216
+ });
1217
+ if (!put.ok)
1218
+ throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
1219
+ }
1220
+ else {
1221
+ const form = new FormData();
1222
+ form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
1223
+ const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
1224
+ method: "POST",
1225
+ headers: buildAuthHeaders(ctx.auth),
1226
+ body: form
1227
+ });
1228
+ const json = await res.json().catch(() => null);
1229
+ if (!res.ok || !json?.storage_key) {
1230
+ throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
1231
+ }
1232
+ storageKey = json.storage_key;
1233
+ uploadedName = json.file_name || fileName;
1234
+ }
1235
+ if (!storageKey)
1236
+ throw new Error("inspiration-add upload did not return a storage key.");
1237
+ const result = await apiRequest({
1238
+ method: "POST",
1239
+ host: ctx.host,
1240
+ path: "/discover/templates",
1241
+ auth: ctx.auth,
1242
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
1243
+ });
1244
+ assertApiOk(result, "inspiration-add");
1245
+ emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1246
+ return;
1247
+ }
1139
1248
  const result = await apiRequest({
1140
1249
  method: "POST",
1141
1250
  host: ctx.host,
1142
1251
  path: "/discover/templates",
1143
1252
  auth: ctx.auth,
1144
- body: { source_url: sourceUrl, tagline: parsed.values.tagline, notes: parsed.values.notes }
1253
+ body: { source_url: source, tagline: parsed.values.tagline, notes: parsed.values.notes }
1145
1254
  });
1146
1255
  assertApiOk(result, "inspiration-add");
1147
1256
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
@@ -1214,6 +1323,43 @@ async function runDecomposeCommand(argv) {
1214
1323
  if (!ctx.json && result.json?.ghostcut_pending) {
1215
1324
  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}`);
1216
1325
  }
1326
+ // The scene-annotation pass (per-scene clip-match + recreation DNA + content
1327
+ // format) is seeded "pending" by decompose and runs as its own bounded vision
1328
+ // call — kept out of the decompose request so that stays under the origin
1329
+ // timeout. Drive it to completion here so CLI/agent users get annotations
1330
+ // without needing the editor. Each poll is one call; usually one round
1331
+ // finishes it. Best-effort: transient failures leave it pending for another
1332
+ // client (or a later `vidfarm pull`) to finish.
1333
+ if (!ctx.json)
1334
+ process.stdout.write(`${DIM}Annotating scenes (clip-match + recreation DNA)… ${RESET}`);
1335
+ let annotationStatus = "pending";
1336
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1337
+ let poll;
1338
+ try {
1339
+ poll = await apiRequest({
1340
+ method: "POST",
1341
+ host: ctx.host,
1342
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/scene-annotations-poll`,
1343
+ auth: ctx.auth,
1344
+ body: {}
1345
+ });
1346
+ }
1347
+ catch {
1348
+ break;
1349
+ }
1350
+ annotationStatus = typeof poll.json?.status === "string" ? poll.json.status : "none";
1351
+ if (annotationStatus === "none" || annotationStatus === "done" || annotationStatus === "failed")
1352
+ break;
1353
+ await sleep(3000);
1354
+ }
1355
+ if (!ctx.json) {
1356
+ if (annotationStatus === "done")
1357
+ console.log(`${GREEN}done${RESET}`);
1358
+ else if (annotationStatus === "failed")
1359
+ console.log(`${RED}failed${RESET} ${DIM}(run \`vidfarm api POST /api/v1/compositions/${forkId}/scene-annotations-poll\` to retry)${RESET}`);
1360
+ else
1361
+ console.log(`${DIM}still running — poll .../scene-annotations-poll until done${RESET}`);
1362
+ }
1217
1363
  emitResult(result, ctx.json);
1218
1364
  }
1219
1365
  async function runRemoveVideoCaptionsCommand(argv) {
@@ -1375,25 +1521,7 @@ async function pollPrimitiveJob(ctx, jobId) {
1375
1521
  // Upload a local file to the ephemeral temp store, returning its durable URL.
1376
1522
  // Used to turn `--ref @local.png` into a direct asset URL for reference images.
1377
1523
  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;
1524
+ return uploadLocalToTempStore(ctx, absPath);
1397
1525
  }
1398
1526
  // Resolve --ref values: pass http(s) URLs through; upload @local files.
1399
1527
  async function resolveReferenceUrls(ctx, refs) {
@@ -1431,6 +1559,100 @@ function resolveCompositionHtmlPath(target) {
1431
1559
  throw new Error(`No such composition file or dir: ${abs}.`);
1432
1560
  return abs;
1433
1561
  }
1562
+ // ── Local file paths as media (no S3 upload) ────────────────────────────────
1563
+ // Power users bulk-building compositions want to reference media that lives on
1564
+ // their own disk WITHOUT uploading every asset to Vidfarm storage. Two cases:
1565
+ //
1566
+ // 1. The composition is a `vidfarm serve` working copy (its path runs through
1567
+ // <data-dir>/storage/compositions/forks/<forkId>/working/). That box renders
1568
+ // IN-PROCESS for free and serves /storage/:key off local disk, so we just
1569
+ // copy the file into the same disk store and reference it by the box's own
1570
+ // localhost URL. Nothing ever touches S3.
1571
+ // 2. Any other composition dir (e.g. a `vidfarm pull` dir headed for cloud
1572
+ // render/publish). The cloud renderer can't reach the user's disk, so a
1573
+ // local file has to become a durable URL — we upload it to the EPHEMERAL
1574
+ // temp store, namescoped under a folder (default `temp/`) so it stays easy
1575
+ // to find and purge instead of bloating the durable library.
1576
+ //
1577
+ // A localhost /storage URL only resolves against the serve box that owns the
1578
+ // file, so case-1 compositions are for local render/preview; to render in the
1579
+ // cloud or share, upload the media (case 2) instead.
1580
+ // Read the durable URL out of a temporary-files upload response, tolerating both
1581
+ // the camelCase (viewUrl/s3Url) and snake_case (view_url/s3_url) shapes the API
1582
+ // has used across versions.
1583
+ function readUploadedFileUrl(json) {
1584
+ const file = json?.file ?? {};
1585
+ return file.viewUrl ?? file.view_url ?? file.s3Url ?? file.s3_url ?? null;
1586
+ }
1587
+ // If `htmlPath` sits inside a `vidfarm serve` disk store, return that store's
1588
+ // root (`<data-dir>/storage`); otherwise null. Detected structurally from the
1589
+ // canonical working-copy layout rather than any running server.
1590
+ function findServeStorageRoot(htmlPath) {
1591
+ const norm = path.resolve(htmlPath);
1592
+ const marker = `${path.sep}storage${path.sep}compositions${path.sep}forks${path.sep}`;
1593
+ const idx = norm.indexOf(marker);
1594
+ if (idx === -1)
1595
+ return null;
1596
+ return norm.slice(0, idx + `${path.sep}storage`.length);
1597
+ }
1598
+ // Copy a local media file into a serve box's disk store under a public-read key
1599
+ // and return that storage key. The key is stable-random so re-placing the same
1600
+ // file never collides.
1601
+ function importLocalMediaToServeStore(storageRoot, absFile) {
1602
+ const safeName = path.basename(absFile).replace(/[^\w.\-]+/g, "_") || "media.bin";
1603
+ const key = `users/local-media/${randomUUID()}/${safeName}`;
1604
+ const dest = path.join(storageRoot, ...key.split("/"));
1605
+ mkdirSync(path.dirname(dest), { recursive: true });
1606
+ copyFileSync(absFile, dest);
1607
+ return key;
1608
+ }
1609
+ function serveStorageUrl(baseUrl, key) {
1610
+ return `${trimTrailingSlash(baseUrl)}/storage/${encodeURIComponent(key)}`;
1611
+ }
1612
+ // Upload a local file to the EPHEMERAL temp store on ctx.host, optionally
1613
+ // namescoped under a folder, and return its durable URL.
1614
+ async function uploadLocalToTempStore(ctx, absPath, folder) {
1615
+ if (!existsSync(absPath))
1616
+ throw new Error(`No such local file: ${absPath}`);
1617
+ const form = new FormData();
1618
+ form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
1619
+ if (folder)
1620
+ form.append("folder_path", folder);
1621
+ const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
1622
+ const text = await res.text();
1623
+ let json = null;
1624
+ try {
1625
+ json = text ? JSON.parse(text) : null;
1626
+ }
1627
+ catch {
1628
+ json = null;
1629
+ }
1630
+ if (!res.ok)
1631
+ throw new Error(`local media upload failed (${res.status}): ${text.slice(0, 200)}`);
1632
+ const url = readUploadedFileUrl(json);
1633
+ if (!url)
1634
+ throw new Error("local media upload returned no durable URL.");
1635
+ return url;
1636
+ }
1637
+ // Turn a --src value (http(s) URL or local file path) into a fetchable media
1638
+ // URL, importing/uploading local files as described above.
1639
+ async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
1640
+ const value = rawSrc.trim();
1641
+ if (/^https?:\/\//i.test(value))
1642
+ return { src: value, origin: "url", detail: null };
1643
+ const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
1644
+ if (!existsSync(abs)) {
1645
+ throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
1646
+ }
1647
+ const storageRoot = findServeStorageRoot(opts.htmlPath);
1648
+ if (storageRoot) {
1649
+ const key = importLocalMediaToServeStore(storageRoot, abs);
1650
+ return { src: serveStorageUrl(opts.baseUrl, key), origin: "local-serve", detail: key };
1651
+ }
1652
+ const folder = opts.folder ?? "temp";
1653
+ const url = await uploadLocalToTempStore(ctx, abs, folder);
1654
+ return { src: url, origin: "cloud-temp", detail: folder };
1655
+ }
1434
1656
  // Apply an insert/replace of a media URL onto a composition.html on disk.
1435
1657
  function placeMediaOnDisk(input) {
1436
1658
  const html = readFileSync(input.htmlPath, "utf8");
@@ -1446,6 +1668,8 @@ function placeMediaOnDisk(input) {
1446
1668
  width: input.width,
1447
1669
  height: input.height,
1448
1670
  objectFit: input.objectFit,
1671
+ kenBurns: input.kenBurns,
1672
+ kenBurnsIntensity: input.kenBurnsIntensity,
1449
1673
  slug: input.slug
1450
1674
  };
1451
1675
  const result = input.replace
@@ -1475,6 +1699,8 @@ async function runGenerateCommand(argv) {
1475
1699
  at: { type: "string" },
1476
1700
  replace: { type: "string" },
1477
1701
  track: { type: "string" },
1702
+ "ken-burns": { type: "string" },
1703
+ "ken-burns-intensity": { type: "string" },
1478
1704
  "layer-key": { type: "string" }
1479
1705
  }
1480
1706
  });
@@ -1546,6 +1772,8 @@ async function runGenerateCommand(argv) {
1546
1772
  at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
1547
1773
  replace: parsed.values.replace,
1548
1774
  track: parsed.values.track ? Number(parsed.values.track) : undefined,
1775
+ kenBurns: parsed.values["ken-burns"],
1776
+ kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
1549
1777
  layerKey: parsed.values["layer-key"]
1550
1778
  });
1551
1779
  if (!ctx.json)
@@ -1577,21 +1805,34 @@ async function runPlaceCommand(argv) {
1577
1805
  width: { type: "string" },
1578
1806
  height: { type: "string" },
1579
1807
  "object-fit": { type: "string" },
1808
+ "ken-burns": { type: "string" },
1809
+ "ken-burns-intensity": { type: "string" },
1580
1810
  "layer-key": { type: "string" },
1581
- slug: { type: "string" }
1811
+ slug: { type: "string" },
1812
+ // Local file support: where a serve box serves /storage from, and which
1813
+ // temp folder cloud uploads land under.
1814
+ "base-url": { type: "string" },
1815
+ folder: { type: "string" }
1582
1816
  }
1583
1817
  });
1584
1818
  const target = parsed.positionals[0];
1585
1819
  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>.");
1820
+ throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url|file> ...`.");
1821
+ const rawSrc = parsed.values.src;
1822
+ if (!rawSrc)
1823
+ throw new Error("place requires --src <media URL or local file path>.");
1590
1824
  const ctx = commonContext(parsed.values);
1591
1825
  const htmlPath = resolveCompositionHtmlPath(target);
1826
+ const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
1827
+ const resolvedSrc = await resolvePlaceableMediaSrc(ctx, rawSrc, {
1828
+ htmlPath,
1829
+ baseUrl,
1830
+ folder: parsed.values.folder
1831
+ });
1832
+ const src = resolvedSrc.src;
1592
1833
  const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
1593
1834
  ? parsed.values.kind
1594
- : inferMediaKindFromUrl(src);
1835
+ : inferMediaKindFromUrl(rawSrc);
1595
1836
  const num = (v) => (v !== undefined ? Number(v) : undefined);
1596
1837
  const placement = placeMediaOnDisk({
1597
1838
  htmlPath,
@@ -1606,13 +1847,21 @@ async function runPlaceCommand(argv) {
1606
1847
  width: num(parsed.values.width),
1607
1848
  height: num(parsed.values.height),
1608
1849
  objectFit: parsed.values["object-fit"],
1850
+ kenBurns: parsed.values["ken-burns"],
1851
+ kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
1609
1852
  layerKey: parsed.values["layer-key"],
1610
1853
  slug: parsed.values.slug
1611
1854
  });
1612
1855
  if (ctx.json) {
1613
- printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
1856
+ printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps });
1614
1857
  }
1615
1858
  else {
1859
+ if (resolvedSrc.origin === "local-serve") {
1860
+ console.log(`${DIM}Local file → serve disk store (${resolvedSrc.detail}); referenced as ${src}. No S3 upload. Renders on this serve box only.${RESET}`);
1861
+ }
1862
+ else if (resolvedSrc.origin === "cloud-temp") {
1863
+ console.log(`${DIM}Local file → uploaded to ephemeral temp store under "${resolvedSrc.detail}/" on ${ctx.host}; referenced as ${src}.${RESET}`);
1864
+ }
1616
1865
  console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
1617
1866
  console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
1618
1867
  console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
@@ -1633,8 +1882,9 @@ async function runPullCommand(argv) {
1633
1882
  const info = inspectComposition(readFileSync(htmlPath, "utf8"));
1634
1883
  const hasContext = existsSync(path.join(dir, "video-context.json"));
1635
1884
  const hasCast = existsSync(path.join(dir, "cast.json"));
1885
+ const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
1636
1886
  if (ctx.json) {
1637
- printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
1887
+ printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, ...info });
1638
1888
  return;
1639
1889
  }
1640
1890
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -1645,7 +1895,7 @@ async function runPullCommand(argv) {
1645
1895
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
1646
1896
  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}`);
1647
1897
  }
1648
- console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
1898
+ console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}.${RESET}`);
1649
1899
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
1650
1900
  }
1651
1901
  async function runVisibilityCommand(argv) {
@@ -1706,6 +1956,7 @@ async function runApproveCommand(argv) {
1706
1956
  caption: { type: "string" },
1707
1957
  title: { type: "string" },
1708
1958
  pinned: { type: "string" },
1959
+ folder: { type: "string" },
1709
1960
  tracer: { type: "string" }
1710
1961
  }
1711
1962
  });
@@ -1713,11 +1964,21 @@ async function runApproveCommand(argv) {
1713
1964
  const caption = parsed.values.caption;
1714
1965
  if (!caption)
1715
1966
  throw new Error("approve requires --caption <text>.");
1967
+ // An approved post is a shareable cloud page, so any media must be a durable
1968
+ // URL the page can load. Pass an http(s) URL through; a local file is uploaded
1969
+ // to the ephemeral temp store (default folder `temp/`) first.
1970
+ const uploadFolder = parsed.values.folder ?? "temp";
1971
+ const resolveApproveMedia = async (value) => {
1972
+ if (/^https?:\/\//i.test(value.trim()))
1973
+ return value.trim();
1974
+ const abs = path.resolve(process.cwd(), value.trim());
1975
+ return uploadLocalToTempStore(ctx, abs, uploadFolder);
1976
+ };
1716
1977
  const media = [];
1717
1978
  if (parsed.values.video)
1718
- media.push({ url: parsed.values.video, kind: "video", role: "primary" });
1979
+ media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
1719
1980
  for (const url of parsed.values.media ?? [])
1720
- media.push({ url });
1981
+ media.push({ url: await resolveApproveMedia(String(url)) });
1721
1982
  if (media.length === 0) {
1722
1983
  console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
1723
1984
  }
@@ -1883,7 +2144,7 @@ async function runUploadCommand(argv) {
1883
2144
  }
1884
2145
  const result = { status: res.status, ok: res.ok, json, text };
1885
2146
  assertApiOk(result, "upload");
1886
- emitResult(result, ctx.json, [["Durable URL ", result.json?.file?.view_url ?? result.json?.file?.s3_url]]);
2147
+ emitResult(result, ctx.json, [["Durable URL ", readUploadedFileUrl(result.json)]]);
1887
2148
  }
1888
2149
  async function runDownloadCommand(argv) {
1889
2150
  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);
@@ -108,6 +108,12 @@ const schema = z.object({
108
108
  VIDFARM_RATE_LIMITS_TABLE_NAME: z.string().optional(),
109
109
  VIDFARM_SERVERLESS_MAIN_TABLE_NAME: z.string().optional(),
110
110
  VIDFARM_JOB_STATE_MACHINE_ARN: z.string().optional(),
111
+ // Clip-curation scan pipeline (Step Functions). Optional: when unset, the
112
+ // /clips/scan route records the scan request but doesn't dispatch the machine.
113
+ VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN: z.string().optional(),
114
+ VIDFARM_CLIP_VECTOR_BACKEND: z.enum(["dynamo", "s3vectors"]).default("dynamo"),
115
+ VIDFARM_CLIP_VECTORS_BUCKET: z.string().optional(),
116
+ VIDFARM_CLIP_VECTORS_INDEX: z.string().optional(),
111
117
  SENTRY_DSN: z.string().optional(),
112
118
  SENTRY_ENVIRONMENT: z.string().optional(),
113
119
  SENTRY_RELEASE: z.string().optional(),