@mevdragon/vidfarm-devcli 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -25,8 +25,8 @@ import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
25
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
26
26
  // `serve` command boots the FULL editor locally (single origin, disk-backed
27
27
  // records + storage) so power users edit compositions on disk while a browser
28
- // editor and a coding agent share one source of truth; render stays on the
29
- // cloud. The remaining commands are thin 1:1 wrappers over the REST API.
28
+ // editor and a coding agent share one source of truth. Render can run locally
29
+ // in-process or hand off to cloud; the remaining commands wrap the REST API.
30
30
  const DEFAULT_HOST = "https://vidfarm.cc";
31
31
  const DEFAULT_PORT = 4321;
32
32
  // Agent skill files the `update-skill` command can install from the live host
@@ -35,18 +35,21 @@ const SKILL_TARGETS = {
35
35
  "vidfarm-director": { route: "/skill/vidfarm-director", bundled: "SKILL.director.md" },
36
36
  "vidfarm-platform": { route: "/skill/vidfarm-platform", bundled: "SKILL.platform.md" }
37
37
  };
38
- // Every command below is a thin wrapper over ONE Vidfarm REST call (the `→`
39
- // line documents the exact route). The devcli adds nothing the raw API can't
40
- // do it just resolves your fork, sets the `vidfarm-api-key` header, prints
41
- // the prod frontend URL to open, and handles the multi-step upload/download +
42
- // render-polling flows that are awkward to do by hand. Prefer the raw REST API
43
- // (or `vidfarm api <METHOD> <path>`) whenever you want full control; reach for
44
- // the named commands when you want the ergonomics and the frontend links.
38
+ // Most commands below are thin wrappers over ONE Vidfarm REST call (the `→`
39
+ // line documents the exact route). A few convenience commands compose the raw
40
+ // API for you when that is the ergonomic default. The devcli adds nothing the
41
+ // raw API can't do it just resolves your fork, sets the `vidfarm-api-key`
42
+ // header, prints the prod frontend URL to open, and handles the multi-step
43
+ // upload/download + render-polling flows that are awkward to do by hand.
44
+ // Prefer the raw REST API (or `vidfarm api <METHOD> <path>`) whenever you want
45
+ // full control; reach for the named commands when you want the ergonomics and
46
+ // the frontend links.
45
47
  const HELP = `vidfarm-devcli — command-line bridge for the Vidfarm video studio
46
48
 
47
- Every named command maps 1:1 to a single REST route (shown as → below). Use
48
- raw REST (or \`vidfarm api\`) when you want control; use named commands for the
49
- ergonomics + prod frontend links. Auth: --api-key <key> or VIDFARM_API_KEY.
49
+ Most named commands map 1:1 to a REST route (shown as → below). A few compose
50
+ the same routes for file-backed scripting. Use raw REST (or \`vidfarm api\`) when
51
+ you want control; use named commands for ergonomics + prod frontend links.
52
+ Auth: --api-key <key> or VIDFARM_API_KEY.
50
53
 
51
54
  Usage:
52
55
  vidfarm <template_id> [opts] Start local editor session (default)
@@ -118,7 +121,7 @@ Composition lifecycle (fork → decompose → snapshot → render):
118
121
  decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
119
122
 
120
123
  Generate AI media and drop it on the timeline (for local coding agents):
121
- generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
124
+ generate <image|video> Generate AI media, poll to the finished public URL → POST /api/v1/primitives/{images,videos}/generate
122
125
  --prompt <text> What to generate (required)
123
126
  --ref <url|@file> Reference image (repeatable): input_references
124
127
  for video, prompt_attachments for image. @file
@@ -372,9 +375,17 @@ Fine timeline control (trackpad-level verbs on a pulled/served composition — l
372
375
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
373
376
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
374
377
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
378
+ --dir <dir|composition.html> pushes local
379
+ composition.html + composition.json first
380
+ --target local|cloud chooses in-process or
381
+ cloud handoff when the backend supports both
375
382
  Cloud render costs ~$0.01-$0.10; against a local
376
383
  'serve' host it renders in-process for FREE.
377
384
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
385
+ template run <template_id> <operation_name>
386
+ Run one template operation via REST → POST /api/v1/templates/:templateId/operations/:operationName
387
+ --payload-file / --payload supply the input
388
+ --wait polls GET /api/v1/user/me/jobs/:jobId
378
389
  visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
379
390
  clone <forkId> [--from current|default] [--version N]
380
391
  New fork: --from current (default) branches this
@@ -495,7 +506,10 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
495
506
  Escape hatch — call ANY route directly:
496
507
  api <METHOD> <path> Raw REST call with auth + pretty errors
497
508
  --data <json> JSON request body (or --data-file <path>)
509
+ --body-file <path> Literal request body file (for HTML/text payloads)
510
+ --content-type <mime> Content-Type for --body-file (default text/plain)
498
511
  --query k=v Repeatable query param
512
+ --raw Print the exact response body, not pretty JSON
499
513
  Examples:
500
514
  vidfarm api GET /discover/feed
501
515
  vidfarm api POST /api/v1/compositions --data '{"template_id":"template_..."}'
@@ -643,6 +657,9 @@ async function main() {
643
657
  case "render-status":
644
658
  await runRenderStatusCommand(rest);
645
659
  return;
660
+ case "template":
661
+ await runTemplateCommand(rest);
662
+ return;
646
663
  case "visibility":
647
664
  await runVisibilityCommand(rest);
648
665
  return;
@@ -818,8 +835,14 @@ async function apiRequest(input) {
818
835
  const headers = { ...buildAuthHeaders(input.auth), accept: "application/json" };
819
836
  let body;
820
837
  if (input.body !== undefined) {
821
- headers["content-type"] = "application/json";
822
- body = JSON.stringify(input.body);
838
+ if (typeof input.body === "string") {
839
+ body = input.body;
840
+ headers["content-type"] = input.contentType ?? "text/plain; charset=utf-8";
841
+ }
842
+ else {
843
+ headers["content-type"] = input.contentType ?? "application/json";
844
+ body = JSON.stringify(input.body);
845
+ }
823
846
  }
824
847
  const res = await fetch(url, { method: input.method.toUpperCase(), headers, body });
825
848
  const text = await res.text();
@@ -1631,7 +1654,15 @@ async function runApiCommand(argv) {
1631
1654
  const parsed = parseArgs({
1632
1655
  args: argv,
1633
1656
  allowPositionals: true,
1634
- options: { ...commonOptions(), data: { type: "string" }, "data-file": { type: "string" }, query: { type: "string", multiple: true } }
1657
+ options: {
1658
+ ...commonOptions(),
1659
+ data: { type: "string" },
1660
+ "data-file": { type: "string" },
1661
+ "body-file": { type: "string" },
1662
+ "content-type": { type: "string" },
1663
+ query: { type: "string", multiple: true },
1664
+ raw: { type: "boolean", default: false }
1665
+ }
1635
1666
  });
1636
1667
  const method = parsed.positionals[0];
1637
1668
  const routePath = parsed.positionals[1];
@@ -1639,10 +1670,16 @@ async function runApiCommand(argv) {
1639
1670
  throw new Error(`api requires <METHOD> <path>.\n\nExample: vidfarm api GET /discover/feed`);
1640
1671
  }
1641
1672
  const ctx = commonContext(parsed.values);
1673
+ if (parsed.values["data-file"] && parsed.values["body-file"]) {
1674
+ throw new Error("api accepts either --data-file (JSON) or --body-file (literal), not both.");
1675
+ }
1642
1676
  let body;
1643
1677
  if (parsed.values["data-file"]) {
1644
1678
  body = JSON.parse(readFileSync(path.resolve(process.cwd(), String(parsed.values["data-file"])), "utf8"));
1645
1679
  }
1680
+ else if (parsed.values["body-file"]) {
1681
+ body = readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8");
1682
+ }
1646
1683
  else if (parsed.values.data) {
1647
1684
  body = JSON.parse(String(parsed.values.data));
1648
1685
  }
@@ -1652,16 +1689,139 @@ async function runApiCommand(argv) {
1652
1689
  path: routePath,
1653
1690
  auth: ctx.auth,
1654
1691
  query: collectKeyValues(parsed.values.query),
1655
- body
1692
+ body,
1693
+ contentType: parsed.values["body-file"] ? parsed.values["content-type"] : undefined
1656
1694
  });
1657
1695
  if (!ctx.json) {
1658
1696
  const color = result.ok ? GREEN : RED;
1659
1697
  console.log(`${color}${method.toUpperCase()} ${routePath} → ${result.status}${RESET}`);
1660
1698
  }
1661
- printJson(result.json ?? result.text);
1699
+ if (parsed.values.raw)
1700
+ process.stdout.write(result.text);
1701
+ else
1702
+ printJson(result.json ?? result.text);
1662
1703
  if (!result.ok)
1663
1704
  process.exitCode = 1;
1664
1705
  }
1706
+ function parseJsonInput(raw, label) {
1707
+ if (raw === undefined) {
1708
+ return undefined;
1709
+ }
1710
+ try {
1711
+ return JSON.parse(raw);
1712
+ }
1713
+ catch {
1714
+ throw new Error(`${label} must be valid JSON.`);
1715
+ }
1716
+ }
1717
+ async function waitForJobCompletion(ctx, jobId, pollMs) {
1718
+ const intervalMs = Number.isFinite(pollMs) && pollMs > 0 ? pollMs : 2000;
1719
+ while (true) {
1720
+ const result = await dispatch(ctx, {
1721
+ method: "GET",
1722
+ path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`
1723
+ }, ctx.target === "cloud" ? "cloud" : "local");
1724
+ if (!result.ok) {
1725
+ return result;
1726
+ }
1727
+ const status = String(result.json?.status ?? "").toLowerCase();
1728
+ if (!["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(status)) {
1729
+ return result;
1730
+ }
1731
+ await sleep(intervalMs);
1732
+ }
1733
+ }
1734
+ async function runTemplateCommand(argv) {
1735
+ const parsed = parseArgs({
1736
+ args: argv,
1737
+ allowPositionals: true,
1738
+ options: {
1739
+ ...commonOptions(),
1740
+ tracer: { type: "string" },
1741
+ wait: { type: "boolean", default: false },
1742
+ "poll-ms": { type: "string" },
1743
+ body: { type: "string" },
1744
+ "body-file": { type: "string" },
1745
+ payload: { type: "string" },
1746
+ "payload-file": { type: "string" },
1747
+ "webhook-url": { type: "string" }
1748
+ }
1749
+ });
1750
+ const subcommand = parsed.positionals[0];
1751
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h" || !subcommand) {
1752
+ console.log(`vidfarm template — run template operations via REST
1753
+ template run <template_id> <operation_name> [--payload-file payload.json | --payload '{...}']
1754
+ [--tracer <id>] [--webhook-url <url>] [--wait] [--poll-ms 2000]
1755
+ --body-file / --body can send the exact REST envelope instead of auto-wrapping
1756
+ `);
1757
+ return;
1758
+ }
1759
+ if (subcommand !== "run") {
1760
+ throw new Error(`Unknown template subcommand: ${subcommand}`);
1761
+ }
1762
+ const templateId = parsed.positionals[1];
1763
+ const operationName = parsed.positionals[2];
1764
+ if (!templateId || !operationName) {
1765
+ throw new Error("template run requires <template_id> <operation_name>.");
1766
+ }
1767
+ const ctx = commonContext(parsed.values);
1768
+ if (ctx.target === "both") {
1769
+ throw new Error("template run only supports one backend at a time. Use --cloud or omit it for the local backend.");
1770
+ }
1771
+ const rawBody = parsed.values["body-file"]
1772
+ ? readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8")
1773
+ : typeof parsed.values.body === "string"
1774
+ ? parsed.values.body
1775
+ : null;
1776
+ const payloadSource = parsed.values["payload-file"]
1777
+ ? readFileSync(path.resolve(process.cwd(), String(parsed.values["payload-file"])), "utf8")
1778
+ : typeof parsed.values.payload === "string"
1779
+ ? parsed.values.payload
1780
+ : null;
1781
+ let body;
1782
+ if (rawBody !== null) {
1783
+ body = parseJsonInput(rawBody, "template body");
1784
+ }
1785
+ else {
1786
+ const payload = payloadSource === null ? {} : parseJsonInput(payloadSource, "template payload");
1787
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1788
+ throw new Error("template payload must be a JSON object.");
1789
+ }
1790
+ body = {
1791
+ tracer: typeof parsed.values.tracer === "string" && parsed.values.tracer.trim()
1792
+ ? parsed.values.tracer.trim()
1793
+ : `template_run_${templateId}_${operationName}_${Date.now()}`,
1794
+ payload,
1795
+ ...(typeof parsed.values["webhook-url"] === "string" && parsed.values["webhook-url"].trim()
1796
+ ? { webhook_url: parsed.values["webhook-url"].trim() }
1797
+ : {})
1798
+ };
1799
+ }
1800
+ const result = await dispatch(ctx, {
1801
+ method: "POST",
1802
+ path: `/api/v1/templates/${encodeURIComponent(templateId)}/operations/${encodeURIComponent(operationName)}`,
1803
+ body
1804
+ }, ctx.target);
1805
+ if (!result.ok && result.status !== 202) {
1806
+ assertApiOk(result, `template run ${templateId}:${operationName}`);
1807
+ }
1808
+ if (!parsed.values.wait) {
1809
+ emitResult(result, ctx.json);
1810
+ return;
1811
+ }
1812
+ const jobId = result.json?.job_id;
1813
+ if (typeof jobId !== "string" || !jobId.trim()) {
1814
+ emitResult(result, ctx.json);
1815
+ return;
1816
+ }
1817
+ const pollMs = Number(parsed.values["poll-ms"] ?? 2000);
1818
+ const final = await waitForJobCompletion(ctx, jobId, pollMs);
1819
+ emitResult(final, ctx.json);
1820
+ const finalStatus = String(final.json?.status ?? "").toLowerCase();
1821
+ if (!final.ok || !["succeeded", "completed"].includes(finalStatus)) {
1822
+ process.exitCode = 1;
1823
+ }
1824
+ }
1665
1825
  // ── Discover & inspiration ──────────────────────────────────────────────────
1666
1826
  // `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
1667
1827
  // CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
@@ -2168,18 +2328,40 @@ async function runRenderCommand(argv) {
2168
2328
  const parsed = parseArgs({
2169
2329
  args: argv,
2170
2330
  allowPositionals: true,
2171
- options: { ...commonOptions(), title: { type: "string" }, tracer: { type: "string" }, wait: { type: "boolean", default: false } }
2331
+ options: {
2332
+ ...commonOptions(),
2333
+ dir: { type: "string" },
2334
+ title: { type: "string" },
2335
+ tracer: { type: "string" },
2336
+ target: { type: "string", default: "local" },
2337
+ wait: { type: "boolean", default: false }
2338
+ }
2172
2339
  });
2173
2340
  const forkId = parsed.positionals[0];
2174
2341
  if (!forkId)
2175
2342
  throw new Error("render requires a fork id.");
2176
2343
  const ctx = commonContext(parsed.values);
2344
+ const renderTarget = String(parsed.values.target ?? "local");
2345
+ if (renderTarget !== "local" && renderTarget !== "cloud") {
2346
+ throw new Error(`render --target must be local or cloud, got: ${renderTarget}`);
2347
+ }
2348
+ const sourceDir = parsed.values.dir ? resolveCompositionSourceDir(String(parsed.values.dir)) : null;
2349
+ if (sourceDir) {
2350
+ await pushCompositionFromDir({
2351
+ forkId,
2352
+ rootDir: sourceDir,
2353
+ host: ctx.host,
2354
+ auth: ctx.auth,
2355
+ json: ctx.json,
2356
+ action: "render"
2357
+ });
2358
+ }
2177
2359
  const result = await apiRequest({
2178
2360
  method: "POST",
2179
2361
  host: ctx.host,
2180
2362
  path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
2181
2363
  auth: ctx.auth,
2182
- body: { title: parsed.values.title, tracer: parsed.values.tracer }
2364
+ body: { title: parsed.values.title, tracer: parsed.values.tracer, render_target: renderTarget }
2183
2365
  });
2184
2366
  assertApiOk(result, "render");
2185
2367
  const renderId = result.json?.renderId;
@@ -2187,7 +2369,7 @@ async function runRenderCommand(argv) {
2187
2369
  if (!ctx.json && renderId) {
2188
2370
  console.log(`${DIM}Poll with: vidfarm render-status ${forkId} ${renderId} (or add --wait next time).${RESET}`);
2189
2371
  }
2190
- emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
2372
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
2191
2373
  return;
2192
2374
  }
2193
2375
  // --wait: poll the render job until it settles.
@@ -2206,10 +2388,52 @@ async function runRenderCommand(argv) {
2206
2388
  if (status === "SUCCEEDED" || status === "FAILED")
2207
2389
  break;
2208
2390
  }
2209
- emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.outputUrl]]);
2391
+ emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.expectedOutputPublicUrl ?? last.json?.outputUrl]]);
2210
2392
  if (String(last.json?.status) === "FAILED")
2211
2393
  process.exitCode = 1;
2212
2394
  }
2395
+ function resolveCompositionSourceDir(inputPath) {
2396
+ const absolute = path.resolve(process.cwd(), inputPath);
2397
+ if (!existsSync(absolute)) {
2398
+ throw new Error(`render --dir path does not exist: ${absolute}`);
2399
+ }
2400
+ const stat = statSync(absolute);
2401
+ if (stat.isDirectory())
2402
+ return absolute;
2403
+ if (stat.isFile() && path.basename(absolute) === "composition.html")
2404
+ return path.dirname(absolute);
2405
+ throw new Error("render --dir must point to a directory or composition.html.");
2406
+ }
2407
+ async function pushCompositionFromDir(input) {
2408
+ const htmlPath = path.join(input.rootDir, "composition.html");
2409
+ if (!existsSync(htmlPath)) {
2410
+ throw new Error(`No composition.html at ${htmlPath}.`);
2411
+ }
2412
+ const html = readFileSync(htmlPath, "utf8");
2413
+ if (!html.includes("data-composition-id=")) {
2414
+ throw new Error("composition.html is missing data-composition-id — refusing to render a malformed composition.");
2415
+ }
2416
+ const jsonPath = path.join(input.rootDir, "composition.json");
2417
+ const compositionJson = existsSync(jsonPath) ? readFileSync(jsonPath, "utf8") : null;
2418
+ if (compositionJson !== null) {
2419
+ try {
2420
+ JSON.parse(compositionJson || "{}");
2421
+ }
2422
+ catch (error) {
2423
+ throw new Error(`composition.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2424
+ }
2425
+ }
2426
+ if (!input.json)
2427
+ console.log(`[vidfarm] ${input.action}: pushing ${input.rootDir} → ${input.forkId}`);
2428
+ await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.html`, "PUT", html, "text/html; charset=utf-8", input.auth);
2429
+ if (!input.json)
2430
+ console.log(`[vidfarm] pushed composition.html (${Buffer.byteLength(html)} bytes)`);
2431
+ if (compositionJson !== null) {
2432
+ await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.json`, "PATCH", compositionJson, "application/json", input.auth);
2433
+ if (!input.json)
2434
+ console.log(`[vidfarm] pushed composition.json (${Buffer.byteLength(compositionJson)} bytes)`);
2435
+ }
2436
+ }
2213
2437
  async function runRenderStatusCommand(argv) {
2214
2438
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
2215
2439
  const forkId = parsed.positionals[0];
@@ -2219,7 +2443,7 @@ async function runRenderStatusCommand(argv) {
2219
2443
  const ctx = commonContext(parsed.values);
2220
2444
  const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/renders/${encodeURIComponent(renderId)}`, auth: ctx.auth });
2221
2445
  assertApiOk(result, "render-status");
2222
- emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
2446
+ emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
2223
2447
  }
2224
2448
  // ── AI generation + timeline placement (for local coding agents) ────────────
2225
2449
  // `generate` submits an AI image/video primitive job and (by default) polls it
@@ -66,16 +66,42 @@ async function readBytes(ctx, space, item) {
66
66
  const { withLocalBackend } = await import("./local-backend.js");
67
67
  const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.apiKey });
68
68
  const u = new URL(item.viewUrl, "http://vidfarm.local");
69
+ if (!isLocalFileViewPath(u.pathname)) {
70
+ throw new Error(`Refusing local sync read outside Vidfarm file storage: ${item.path}`);
71
+ }
69
72
  const res = await backend.app.request(u.pathname + u.search, { headers: authHeaders(ctx.apiKey) });
70
73
  if (!res.ok)
71
74
  throw new Error(`local read ${item.path} → HTTP ${res.status}`);
72
75
  return Buffer.from(await res.arrayBuffer());
73
76
  }
74
- const res = await fetch(item.viewUrl, { headers: authHeaders(ctx.apiKey) });
77
+ const url = validateCloudFileViewUrl(ctx, item);
78
+ const res = await fetch(url, { headers: authHeaders(ctx.apiKey) });
75
79
  if (!res.ok)
76
80
  throw new Error(`cloud read ${item.path} → HTTP ${res.status}`);
77
81
  return Buffer.from(await res.arrayBuffer());
78
82
  }
83
+ function isLocalFileViewPath(pathname) {
84
+ return pathname === "/api/v1/user/me/files/view" || pathname === "/template-media" || pathname.startsWith("/storage/");
85
+ }
86
+ function validateCloudFileViewUrl(ctx, item) {
87
+ let url;
88
+ try {
89
+ url = new URL(item.viewUrl || "", ctx.host);
90
+ }
91
+ catch {
92
+ throw new Error(`Invalid cloud view URL for ${item.path}`);
93
+ }
94
+ const host = new URL(ctx.host);
95
+ const isSameOrigin = url.origin === host.origin;
96
+ const isS3 = /^https:\/\/[^/]+\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
97
+ || /^https:\/\/s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
98
+ || /\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname);
99
+ if (isSameOrigin && isLocalFileViewPath(url.pathname))
100
+ return url;
101
+ if (isS3)
102
+ return url;
103
+ throw new Error(`Refusing cloud sync read from non-Vidfarm URL for ${item.path}`);
104
+ }
79
105
  /** Upload bytes into a space's My Files at folderPath/fileName (multipart — the
80
106
  * one transport that works on both S3 and local storage). */
81
107
  async function uploadBytes(ctx, space, input) {
@@ -213,9 +239,10 @@ export async function runSyncCommand(argv) {
213
239
  home: values.home
214
240
  };
215
241
  // Scope: a /files subtree (default the whole root).
216
- let rootPath = (positionals[0] ?? "/files").trim();
217
- if (!rootPath.startsWith("/files")) {
218
- throw new Error(`sync currently covers the /files (My Files) root — got "${rootPath}". Raws sync is a follow-up.`);
242
+ const rawRootPath = positionals[0] ?? "/files";
243
+ const rootPath = normalizeSyncRoot(rawRootPath);
244
+ if (!rootPath) {
245
+ throw new Error(`sync currently covers the /files (My Files) root — got "${rawRootPath}". Raws sync is a follow-up.`);
219
246
  }
220
247
  const srcSpace = direction === "push" ? "local" : "cloud";
221
248
  const dstSpace = direction === "push" ? "cloud" : "local";
@@ -235,7 +262,7 @@ export async function runSyncCommand(argv) {
235
262
  console.log(` ${GREEN}+ new${RESET} ${src.path}`);
236
263
  }
237
264
  else {
238
- await transfer(ctx, srcSpace, dstSpace, src, src.name);
265
+ await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
239
266
  console.log(` ${GREEN}✓ sent${RESET} ${src.path}`);
240
267
  }
241
268
  transferred += 1;
@@ -260,14 +287,15 @@ export async function runSyncCommand(argv) {
260
287
  continue;
261
288
  }
262
289
  if (resolution === "keep-both") {
263
- await transfer(ctx, srcSpace, dstSpace, src, suffixName(src.name, srcSpace));
264
- console.log(` ${YELLOW}✓ both${RESET} ${src.path} ${DIM}→ ${suffixName(src.name, srcSpace)}${RESET}`);
290
+ const keepBothName = suffixName(fileNameFromSyncPath(src.path), srcSpace);
291
+ await transfer(ctx, srcSpace, dstSpace, src, keepBothName);
292
+ console.log(` ${YELLOW}✓ both${RESET} ${src.path} ${DIM}→ ${keepBothName}${RESET}`);
265
293
  }
266
294
  else {
267
295
  // Overwrite: drop the target copy first so we replace rather than dup.
268
296
  if (dst.id)
269
297
  await deleteAttachment(ctx, dstSpace, dst.id);
270
- await transfer(ctx, srcSpace, dstSpace, src, src.name);
298
+ await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
271
299
  console.log(` ${GREEN}✓ over${RESET} ${src.path}`);
272
300
  }
273
301
  transferred += 1;
@@ -277,15 +305,44 @@ export async function runSyncCommand(argv) {
277
305
  console.log(` ${DIM}(dry run — nothing was written)${RESET}`);
278
306
  }
279
307
  async function transfer(ctx, from, to, item, fileName) {
308
+ if (!item.path.startsWith("/files/")) {
309
+ throw new Error(`Refusing to sync item outside /files: ${item.path}`);
310
+ }
311
+ const folderPath = folderPathFromSyncPath(item.path);
280
312
  const bytes = await readBytes(ctx, from, item);
281
313
  await uploadBytes(ctx, to, {
282
314
  fileName,
283
- folderPath: item.folderPath.replace(/^files\/?/, "").replace(/^\/+|\/+$/g, ""),
315
+ folderPath,
284
316
  contentType: item.contentType,
285
317
  notes: item.notes,
286
318
  bytes
287
319
  });
288
320
  }
321
+ function normalizeSyncRoot(value) {
322
+ const raw = value.trim();
323
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
324
+ const normalized = path.posix.normalize(withSlash).replace(/\/+$/, "") || "/files";
325
+ if (normalized === "/files" || normalized.startsWith("/files/"))
326
+ return normalized;
327
+ return null;
328
+ }
329
+ function fileNameFromSyncPath(value) {
330
+ const name = path.posix.basename(value);
331
+ if (!name || name === "." || name === ".." || name.includes("/")) {
332
+ throw new Error(`Invalid sync file path: ${value}`);
333
+ }
334
+ return name;
335
+ }
336
+ function folderPathFromSyncPath(value) {
337
+ if (!value.startsWith("/files/"))
338
+ throw new Error(`Invalid sync file path: ${value}`);
339
+ const dir = path.posix.dirname(value);
340
+ if (dir === "/files")
341
+ return "";
342
+ if (!dir.startsWith("/files/"))
343
+ throw new Error(`Invalid sync file path: ${value}`);
344
+ return dir.slice("/files/".length);
345
+ }
289
346
  function normalizePolicy(v) {
290
347
  const s = String(v ?? "").trim().toLowerCase();
291
348
  if (["newest", "skip", "keep-both", "local", "cloud"].includes(s))
@@ -79,7 +79,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
79
79
  "FINE TIMELINE CONTROL — you have trackpad-level verbs; use them instead of rewriting the whole document for small changes: (1) set_layer_keyframes authors a custom, script-free CSS @keyframes animation on ONE layer that previews AND renders — pass layer_key + keyframes:[{offset(0..1), opacity?, translate_x?, translate_y?, scale?, rotate?}, …] (>=2 stops), optional keyframe_easing (linear/ease/ease-in/ease-out/ease-in-out/cubic-bezier(...)) and keyframe_duration (seconds, default = clip duration). translate_x/translate_y are percentages of the layer's OWN box. Example fly-up-and-fade-in: keyframes=[{offset:0,opacity:0,translate_y:40},{offset:1,opacity:1,translate_y:0}]. This is the durable way to hand-author motion beyond the Ken Burns / transition / caption presets — the JS runtime adapters (anime.js/GSAP/Lottie) are devcli-only. (2) nudge_layers shifts one or more layers by a RELATIVE delta_start (seconds) and/or delta_track (lanes) — pass layer_key or layer_keys; grouped clips move together. (3) ripple_edit inserts (delta_start>0) or closes (delta_start<0) time at at_time and shifts every downstream clip so the rest of the timeline stays in sync — use it to make room before an insert or to close a gap you can see in timeline_gaps. (4) trim_layer moves ONE edge to a time: edge='start' (left; advances the in-point, and for video/audio pushes the media start so the visible frame is unchanged) or edge='end' (right; changes duration), with to_time in composition seconds. (5) set_layer_zindex restacks: z_order='front'|'back'|'forward'|'backward' (stacking == track index; higher draws on top) or an explicit track. All of these auto-resolve track collisions and note it in the summary; read the NEXT editor_context to confirm the new start/duration/track/animation landed.",
80
80
  "If the user asks to export, render, publish, or produce the final MP4 for this composition (phrases like 'export it', 'render this', 'kick off the export', 'make the mp4'), call editor_action with action_type=export_composition and a brief explanation. This is the same action as the editor's Export button — it queues the composition on production AWS Lambda. Do NOT try to hit any /api/v1/compositions/:id/export route via http_request for this; use the editor_action tool. After calling export_composition, tell the user the export has started and that the finished MP4 URL will appear once the render succeeds; do not fabricate a URL or claim success until a later turn shows last_export_url populated in editor_context.",
81
81
  "CONFIRM BEFORE RENDERING — DO NOT auto-render. Rendering the final MP4 is a slow, costly, screen-taking action, so by DEFAULT you must NOT call export_composition on your own initiative. After you finish an edit (a swap, a re-theme, a scene rebuild), STOP and let the user review the live preview, then ASK a short question like 'Happy with the preview? Want me to render the final MP4?' and wait for a yes. Only fire export_composition when EITHER (a) the user explicitly asks to render/export/'make the mp4' in that turn, or (b) the user has told you up front to just render without asking (e.g. 'edit and render it', 'no need to check, just export'). If in doubt, ask — never surprise the user with a render. Never render merely because you judged the edit 'done'.",
82
- "MEDIA SWAP — TARGET A REAL LAYER KEY, NOT A SCENE NAME. To swap a clip's media with set_layer_media, the layer_key MUST be an EXACT key (or its slug) from editor_context.layers — that array lists every targetable layer with its key, slug, kind (video/image/audio), and current src. Read it and copy the real key of the video/image layer you mean; do NOT invent a semantic name (like 'growth_examples' or 'mistakes_reflection') unless that exact string appears as a layer key/slug in editor_context, and never target a scene label/group when you mean the video inside it. If set_layer_media returns 'No change applied' or 'Layer not found', the error now lists the actual media layers — re-read it and retry with one of those exact keys. NEVER report a swap as done until the NEXT editor_context shows the layer's src changed.",
82
+ "MEDIA SWAP — TARGET A REAL LAYER KEY, NOT A SCENE NAME. To swap a clip's media with set_layer_media, the layer_key MUST be an EXACT key (or its slug) from editor_context.layers — that array lists every targetable layer with its key, slug, kind (video/image/audio), and current src. Read it and copy the real key of the video/image layer you mean; do NOT invent a semantic name (like 'growth_examples' or 'mistakes_reflection') unless that exact string appears as a layer key/slug in editor_context, and never target a scene label/group when you mean the video inside it. If set_layer_media returns 'No change applied' or 'Layer not found', the error now lists the actual media layers — re-read it and retry with one of those exact keys. This applies to EVERY set_layer_media field (src, object_fit, object_position, volume, muted, ken_burns, transitions), not just src: the layer_key you used to add a layer (e.g. add_layer layer_key='vf-fishing-snippet-1') becomes an 'element_'-prefixed key on the timeline, so target the real key/slug shown in editor_context, not the bare name you passed earlier. NEVER report a swap or fit/position change as done while any editor_action in this turn came back with a ⚠ / 'Layer not found' error — those calls did NOT apply; re-read the listed keys, retry against a real one, and only confirm success once the action returns ✓ (and, for src, the NEXT editor_context shows the layer changed). Do not claim you changed layers you never successfully targeted.",
83
83
  "GETTING THE MEDIA URL IS EASY — USE browse_files. When the user says 'use my raws' / 'it's in my file directory' / 'the fishing clips', you do NOT need them to paste a URL. Call browse_files action=search (or list path='/raws') to find the clip, then action=read with its file_id — the read result's view_url IS the public, ready-to-use media URL. Drop that view_url straight into set_layer_media src (or add_layer src). Do not stall asking the user for a URL you can look up yourself, and never pass a placeholder like 'please-ignore' as src.",
84
84
  "The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
85
85
  "Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
@@ -501,20 +501,23 @@ export function createDirectoryExplorer(mount, opts = {}) {
501
501
  return;
502
502
  selbar.innerHTML = "";
503
503
  selbar.appendChild(el("span", "rk-dir-selcount", n + " selected"));
504
- if (mode === "attach") {
505
- const att = el("button", "rk-dir-selbtn", "Attach");
506
- att.type = "button";
507
- att.addEventListener("click", () => { emitAttach(Array.from(selection.values())); clearSelection(); });
508
- selbar.appendChild(att);
509
- }
510
- const cp = el("button", "rk-dir-selbtn", "Copy paths");
504
+ const picked = () => Array.from(selection.values());
505
+ // Primary action stays on the bar; everything else tucks into a "" menu so
506
+ // the toolbar never overflows in the narrow editor dock.
507
+ const cp = el("button", "rk-dir-selbtn is-primary", "Copy paths");
511
508
  cp.type = "button";
512
- cp.addEventListener("click", () => copyPath(Array.from(selection.values()).map((s) => s.path).join("\n")));
513
- const clr = el("button", "rk-dir-selbtn is-ghost", "Clear");
514
- clr.type = "button";
515
- clr.addEventListener("click", clearSelection);
509
+ cp.addEventListener("click", () => copyPath(picked().map((s) => s.path).join("\n")));
516
510
  selbar.appendChild(cp);
517
- selbar.appendChild(clr);
511
+ const more = [];
512
+ if (mode === "attach")
513
+ more.push({ label: "Attach", run: () => { emitAttach(picked()); clearSelection(); } });
514
+ more.push({ label: "Copy paths", run: () => copyPath(picked().map((s) => s.path).join("\n")) });
515
+ more.push({ label: "Clear selection", run: clearSelection });
516
+ const menuBtn = el("button", "rk-dir-selbtn is-ghost rk-dir-selmore", "⋯");
517
+ menuBtn.type = "button";
518
+ menuBtn.setAttribute("aria-label", "More actions");
519
+ menuBtn.addEventListener("click", (e) => { e.stopPropagation(); openMenuAt(menuBtn, more); });
520
+ selbar.appendChild(menuBtn);
518
521
  }
519
522
  function checkbox(it) {
520
523
  const box = el("input", "rk-dir-check");
@@ -719,15 +722,27 @@ export function createDirectoryExplorer(mount, opts = {}) {
719
722
  return;
720
723
  uploading = true;
721
724
  const folder = currentFolder();
725
+ const root = currentRoot();
722
726
  const arr = Array.from(list);
723
727
  let done = 0, failed = 0;
728
+ // The server may re-home an upload (e.g. /temp files land in a dated
729
+ // subfolder). Track where the bytes actually landed so we can reveal them —
730
+ // otherwise the refresh shows the folder we uploaded FROM, which looks empty.
731
+ let landedFolder = null;
724
732
  dropText.textContent = "Uploading " + arr.length + " file(s)…";
733
+ const finish = () => {
734
+ uploading = false;
735
+ if (failed)
736
+ toast(failed + " of " + arr.length + " failed");
737
+ const target = root && landedFolder != null ? buildDirectoryPath(root, landedFolder) : null;
738
+ if (target && target !== currentPath)
739
+ navigate(target); // reveal the uploads
740
+ else
741
+ load(true);
742
+ };
725
743
  const next = (i) => {
726
744
  if (i >= arr.length) {
727
- uploading = false;
728
- if (failed)
729
- toast(failed + " of " + arr.length + " failed");
730
- load(true);
745
+ finish();
731
746
  return;
732
747
  }
733
748
  const fd = new FormData();
@@ -735,7 +750,13 @@ export function createDirectoryExplorer(mount, opts = {}) {
735
750
  fd.append("folder_path", folder);
736
751
  fetch(endpoint, { method: "POST", credentials: "same-origin", body: fd })
737
752
  .then((r) => { if (!r.ok)
738
- throw new Error("http " + r.status); done++; })
753
+ throw new Error("http " + r.status); return r.json().catch(() => null); })
754
+ .then((j) => {
755
+ done++;
756
+ const fp = j && ((j.file && j.file.folderPath) ?? (j.attachment && j.attachment.folderPath));
757
+ if (typeof fp === "string" && landedFolder == null)
758
+ landedFolder = fp;
759
+ })
739
760
  .catch(() => { failed++; })
740
761
  .then(() => next(i + 1));
741
762
  };
@@ -943,7 +964,9 @@ export function openDirectoryPicker(opts = {}) {
943
964
  mode: "standalone",
944
965
  showSearch: true,
945
966
  showMultiSelect: false,
946
- showUpload: false, // the picker only selects; it doesn't upload
967
+ // Opt-in upload: when allowed, the explorer shows its drop zone so users can
968
+ // add a file to the currently-open folder (Files/Temp), then click it to pick.
969
+ showUpload: opts.allowUpload === true,
947
970
  // In file mode a name-click resolves the pick; folders still navigate.
948
971
  primaryClick: select === "file" ? "attach" : "copyPath",
949
972
  onNavigate: (p) => syncFooter(p),