@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/SKILL.director.md +79 -7
- package/SKILL.platform.md +2 -2
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +75 -75
- package/dist/src/app.js +306 -18
- package/dist/src/cli.js +247 -23
- package/dist/src/devcli/sync.js +66 -9
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/file-directory.js +41 -18
- package/dist/src/reskin/theme.js +7 -4
- package/dist/src/services/hyperframes.js +2 -2
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +3 -2
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
|
|
29
|
-
//
|
|
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
|
-
//
|
|
39
|
-
// line documents the exact route).
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// the
|
|
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
|
-
|
|
48
|
-
raw REST (or \`vidfarm api\`) when
|
|
49
|
-
ergonomics + prod frontend links.
|
|
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
|
-
|
|
822
|
-
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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
|
package/dist/src/devcli/sync.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
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
|
-
|
|
264
|
-
|
|
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.
|
|
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
|
|
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))
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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),
|