@mevdragon/vidfarm-devcli 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.director.md +43 -10
- package/SKILL.platform.md +5 -3
- package/demo/dist/app.js +106 -56
- package/dist/src/app.js +252 -15
- package/dist/src/cli.js +245 -40
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +1 -0
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +136 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
package/dist/src/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
@@ -71,7 +72,10 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
71
72
|
--query <text> Keyword search over the catalog
|
|
72
73
|
--mine Only your own uploaded inspirations
|
|
73
74
|
--limit <n> Max results
|
|
74
|
-
inspiration-add <url>
|
|
75
|
+
inspiration-add <url|file> Ingest a TikTok/YT/IG/X URL — or upload a → POST /discover/templates
|
|
76
|
+
local MP4/MOV/WebM file — as a private (files: presign + PUT via
|
|
77
|
+
template. --title (uploads; defaults to the /discover/templates/upload)
|
|
78
|
+
template id), --tagline, --notes optional.
|
|
75
79
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
76
80
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
77
81
|
|
|
@@ -99,17 +103,28 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
99
103
|
--no-wait Return the job id instead of polling to the URL
|
|
100
104
|
--place <dir> After generating, place it into that composition
|
|
101
105
|
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
102
|
-
place <dir|composition.html> Insert
|
|
106
|
+
place <dir|composition.html> Insert media into a local composition
|
|
103
107
|
(fill a gap or replace a scene) — same clip markup
|
|
104
108
|
the browser editor makes; serve live-morphs it.
|
|
105
|
-
--src <url>
|
|
106
|
-
|
|
109
|
+
--src <url|file> Media to place (required). A URL is used as-is; a
|
|
110
|
+
LOCAL FILE PATH is imported without bloating S3 — a
|
|
111
|
+
serve working copy copies it onto the box's own disk
|
|
112
|
+
store (free, local render only); any other dir
|
|
113
|
+
uploads it to the ephemeral temp store (folder below).
|
|
114
|
+
--base-url <url> Serve box origin that hosts /storage (default
|
|
115
|
+
http://localhost:3000) — set if you ran serve --port.
|
|
116
|
+
--folder <path> Temp-store folder for uploaded local files (default: temp)
|
|
117
|
+
--kind <video|image> Media kind (inferred from the URL/path if omitted)
|
|
107
118
|
--at <time> Start it at this time (seconds / mm:ss) — fill a gap
|
|
108
119
|
--replace <layer_key> Replace this scene/layer (copies its timing+geometry)
|
|
109
120
|
--duration <n> Timeline length in seconds
|
|
110
121
|
--track <n> Timeline layer index (auto if omitted)
|
|
111
122
|
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
112
123
|
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
124
|
+
--ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
|
|
125
|
+
zoom-out|pan-left|pan-right|pan-up|pan-down|
|
|
126
|
+
zoom-in-left|zoom-in-right
|
|
127
|
+
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
113
128
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
114
129
|
original vs decomposed (caption-free),
|
|
115
130
|
non-billing (alias: ghostcut)
|
|
@@ -125,6 +140,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
125
140
|
|
|
126
141
|
Approve & schedule (publish a finished MP4 as a shareable post):
|
|
127
142
|
approve Approve media into a preview/share page → POST /api/v1/approved/posts
|
|
143
|
+
--video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
|
|
144
|
+
--media <url|file> Extra media (repeatable; local files auto-upload to temp/)
|
|
145
|
+
--folder <path> Temp-store folder for uploaded local files (default: temp)
|
|
146
|
+
--caption <text> Post caption (required)
|
|
128
147
|
posts List your approved posts → GET /api/v1/approved/posts
|
|
129
148
|
post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
|
|
130
149
|
schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
|
|
@@ -146,7 +165,9 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
146
165
|
|
|
147
166
|
Files (multi-step flows the devcli handles for you):
|
|
148
167
|
upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
|
|
149
|
-
--folder <path> Namescope the upload under a
|
|
168
|
+
--folder <path> Namescope the upload under a folder. Prefer a
|
|
169
|
+
throwaway 'temp' folder (--folder temp) for scratch
|
|
170
|
+
media so it never clutters your durable library.
|
|
150
171
|
put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
|
|
151
172
|
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
152
173
|
--as <name> Name the saved file (required for --content/--stdin)
|
|
@@ -160,6 +181,10 @@ Files (multi-step flows the devcli handles for you):
|
|
|
160
181
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
161
182
|
put-file / get-file / files are the My Files (persistent) read+write set;
|
|
162
183
|
upload is the throwaway temp store for dropping media into a composition.
|
|
184
|
+
Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
|
|
185
|
+
them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
|
|
186
|
+
to that box's local disk store, free local render). When you DO need a durable
|
|
187
|
+
URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
|
|
163
188
|
|
|
164
189
|
Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
|
|
165
190
|
free Reuse a decomposed template, swap captions/images/existing MP4s
|
|
@@ -496,6 +521,12 @@ async function runServeCommand(argv) {
|
|
|
496
521
|
// local-render by definition — cloud rendering is the EXPLICIT
|
|
497
522
|
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
498
523
|
process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
|
|
524
|
+
// Same trap as the state machine ARN: a dev-shell .env can carry the staging
|
|
525
|
+
// media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
|
|
526
|
+
// Lambda that cannot fetch this box's localhost storage URLs ("fetch
|
|
527
|
+
// failed"). Media ops on a serve box always run in-process.
|
|
528
|
+
process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL = "";
|
|
529
|
+
process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_SECRET = "";
|
|
499
530
|
process.env.VIDFARM_DATA_DIR = dataDir;
|
|
500
531
|
process.env.VIDFARM_API_KEY = apiKey;
|
|
501
532
|
process.env.PORT = String(port);
|
|
@@ -1131,17 +1162,77 @@ async function runVideosCommand(argv) {
|
|
|
1131
1162
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1132
1163
|
}
|
|
1133
1164
|
async function runInspirationAddCommand(argv) {
|
|
1134
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1135
|
-
const
|
|
1136
|
-
if (!
|
|
1137
|
-
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X).");
|
|
1165
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1166
|
+
const source = parsed.positionals[0];
|
|
1167
|
+
if (!source)
|
|
1168
|
+
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
|
|
1138
1169
|
const ctx = commonContext(parsed.values);
|
|
1170
|
+
// Local file → upload flow: presign, PUT the bytes (straight to S3, or the
|
|
1171
|
+
// server multipart fallback on a local-storage box), then finalize with the
|
|
1172
|
+
// storage key. Anything that parses as http(s) keeps the classic URL ingest.
|
|
1173
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
1174
|
+
const localPath = isUrl ? null : path.resolve(process.cwd(), source);
|
|
1175
|
+
if (localPath) {
|
|
1176
|
+
if (!existsSync(localPath)) {
|
|
1177
|
+
throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
|
|
1178
|
+
}
|
|
1179
|
+
const fileName = path.basename(localPath);
|
|
1180
|
+
const buffer = readFileSync(localPath);
|
|
1181
|
+
const contentType = guessContentType(fileName);
|
|
1182
|
+
const presign = await apiRequest({
|
|
1183
|
+
method: "POST",
|
|
1184
|
+
host: ctx.host,
|
|
1185
|
+
path: "/discover/templates/upload/presign",
|
|
1186
|
+
auth: ctx.auth,
|
|
1187
|
+
body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
|
|
1188
|
+
});
|
|
1189
|
+
assertApiOk(presign, "inspiration-add (presign)");
|
|
1190
|
+
let storageKey = presign.json?.storage_key;
|
|
1191
|
+
let uploadedName = presign.json?.file_name || fileName;
|
|
1192
|
+
if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
|
|
1193
|
+
const upload = presign.json.upload;
|
|
1194
|
+
const put = await fetch(upload.url, {
|
|
1195
|
+
method: upload.method || "PUT",
|
|
1196
|
+
headers: upload.headers || {},
|
|
1197
|
+
body: new Uint8Array(buffer)
|
|
1198
|
+
});
|
|
1199
|
+
if (!put.ok)
|
|
1200
|
+
throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
const form = new FormData();
|
|
1204
|
+
form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
|
|
1205
|
+
const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
|
|
1206
|
+
method: "POST",
|
|
1207
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
1208
|
+
body: form
|
|
1209
|
+
});
|
|
1210
|
+
const json = await res.json().catch(() => null);
|
|
1211
|
+
if (!res.ok || !json?.storage_key) {
|
|
1212
|
+
throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
|
|
1213
|
+
}
|
|
1214
|
+
storageKey = json.storage_key;
|
|
1215
|
+
uploadedName = json.file_name || fileName;
|
|
1216
|
+
}
|
|
1217
|
+
if (!storageKey)
|
|
1218
|
+
throw new Error("inspiration-add upload did not return a storage key.");
|
|
1219
|
+
const result = await apiRequest({
|
|
1220
|
+
method: "POST",
|
|
1221
|
+
host: ctx.host,
|
|
1222
|
+
path: "/discover/templates",
|
|
1223
|
+
auth: ctx.auth,
|
|
1224
|
+
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
|
|
1225
|
+
});
|
|
1226
|
+
assertApiOk(result, "inspiration-add");
|
|
1227
|
+
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1139
1230
|
const result = await apiRequest({
|
|
1140
1231
|
method: "POST",
|
|
1141
1232
|
host: ctx.host,
|
|
1142
1233
|
path: "/discover/templates",
|
|
1143
1234
|
auth: ctx.auth,
|
|
1144
|
-
body: { source_url:
|
|
1235
|
+
body: { source_url: source, tagline: parsed.values.tagline, notes: parsed.values.notes }
|
|
1145
1236
|
});
|
|
1146
1237
|
assertApiOk(result, "inspiration-add");
|
|
1147
1238
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
@@ -1375,25 +1466,7 @@ async function pollPrimitiveJob(ctx, jobId) {
|
|
|
1375
1466
|
// Upload a local file to the ephemeral temp store, returning its durable URL.
|
|
1376
1467
|
// Used to turn `--ref @local.png` into a direct asset URL for reference images.
|
|
1377
1468
|
async function uploadTempFileForRef(ctx, absPath) {
|
|
1378
|
-
|
|
1379
|
-
throw new Error(`No such reference file: ${absPath}`);
|
|
1380
|
-
const form = new FormData();
|
|
1381
|
-
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1382
|
-
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1383
|
-
const text = await res.text();
|
|
1384
|
-
let json = null;
|
|
1385
|
-
try {
|
|
1386
|
-
json = text ? JSON.parse(text) : null;
|
|
1387
|
-
}
|
|
1388
|
-
catch {
|
|
1389
|
-
json = null;
|
|
1390
|
-
}
|
|
1391
|
-
if (!res.ok)
|
|
1392
|
-
throw new Error(`reference upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1393
|
-
const url = json?.file?.view_url ?? json?.file?.s3_url;
|
|
1394
|
-
if (!url)
|
|
1395
|
-
throw new Error("reference upload returned no durable URL.");
|
|
1396
|
-
return url;
|
|
1469
|
+
return uploadLocalToTempStore(ctx, absPath);
|
|
1397
1470
|
}
|
|
1398
1471
|
// Resolve --ref values: pass http(s) URLs through; upload @local files.
|
|
1399
1472
|
async function resolveReferenceUrls(ctx, refs) {
|
|
@@ -1431,6 +1504,100 @@ function resolveCompositionHtmlPath(target) {
|
|
|
1431
1504
|
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
1432
1505
|
return abs;
|
|
1433
1506
|
}
|
|
1507
|
+
// ── Local file paths as media (no S3 upload) ────────────────────────────────
|
|
1508
|
+
// Power users bulk-building compositions want to reference media that lives on
|
|
1509
|
+
// their own disk WITHOUT uploading every asset to Vidfarm storage. Two cases:
|
|
1510
|
+
//
|
|
1511
|
+
// 1. The composition is a `vidfarm serve` working copy (its path runs through
|
|
1512
|
+
// <data-dir>/storage/compositions/forks/<forkId>/working/). That box renders
|
|
1513
|
+
// IN-PROCESS for free and serves /storage/:key off local disk, so we just
|
|
1514
|
+
// copy the file into the same disk store and reference it by the box's own
|
|
1515
|
+
// localhost URL. Nothing ever touches S3.
|
|
1516
|
+
// 2. Any other composition dir (e.g. a `vidfarm pull` dir headed for cloud
|
|
1517
|
+
// render/publish). The cloud renderer can't reach the user's disk, so a
|
|
1518
|
+
// local file has to become a durable URL — we upload it to the EPHEMERAL
|
|
1519
|
+
// temp store, namescoped under a folder (default `temp/`) so it stays easy
|
|
1520
|
+
// to find and purge instead of bloating the durable library.
|
|
1521
|
+
//
|
|
1522
|
+
// A localhost /storage URL only resolves against the serve box that owns the
|
|
1523
|
+
// file, so case-1 compositions are for local render/preview; to render in the
|
|
1524
|
+
// cloud or share, upload the media (case 2) instead.
|
|
1525
|
+
// Read the durable URL out of a temporary-files upload response, tolerating both
|
|
1526
|
+
// the camelCase (viewUrl/s3Url) and snake_case (view_url/s3_url) shapes the API
|
|
1527
|
+
// has used across versions.
|
|
1528
|
+
function readUploadedFileUrl(json) {
|
|
1529
|
+
const file = json?.file ?? {};
|
|
1530
|
+
return file.viewUrl ?? file.view_url ?? file.s3Url ?? file.s3_url ?? null;
|
|
1531
|
+
}
|
|
1532
|
+
// If `htmlPath` sits inside a `vidfarm serve` disk store, return that store's
|
|
1533
|
+
// root (`<data-dir>/storage`); otherwise null. Detected structurally from the
|
|
1534
|
+
// canonical working-copy layout rather than any running server.
|
|
1535
|
+
function findServeStorageRoot(htmlPath) {
|
|
1536
|
+
const norm = path.resolve(htmlPath);
|
|
1537
|
+
const marker = `${path.sep}storage${path.sep}compositions${path.sep}forks${path.sep}`;
|
|
1538
|
+
const idx = norm.indexOf(marker);
|
|
1539
|
+
if (idx === -1)
|
|
1540
|
+
return null;
|
|
1541
|
+
return norm.slice(0, idx + `${path.sep}storage`.length);
|
|
1542
|
+
}
|
|
1543
|
+
// Copy a local media file into a serve box's disk store under a public-read key
|
|
1544
|
+
// and return that storage key. The key is stable-random so re-placing the same
|
|
1545
|
+
// file never collides.
|
|
1546
|
+
function importLocalMediaToServeStore(storageRoot, absFile) {
|
|
1547
|
+
const safeName = path.basename(absFile).replace(/[^\w.\-]+/g, "_") || "media.bin";
|
|
1548
|
+
const key = `users/local-media/${randomUUID()}/${safeName}`;
|
|
1549
|
+
const dest = path.join(storageRoot, ...key.split("/"));
|
|
1550
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
1551
|
+
copyFileSync(absFile, dest);
|
|
1552
|
+
return key;
|
|
1553
|
+
}
|
|
1554
|
+
function serveStorageUrl(baseUrl, key) {
|
|
1555
|
+
return `${trimTrailingSlash(baseUrl)}/storage/${encodeURIComponent(key)}`;
|
|
1556
|
+
}
|
|
1557
|
+
// Upload a local file to the EPHEMERAL temp store on ctx.host, optionally
|
|
1558
|
+
// namescoped under a folder, and return its durable URL.
|
|
1559
|
+
async function uploadLocalToTempStore(ctx, absPath, folder) {
|
|
1560
|
+
if (!existsSync(absPath))
|
|
1561
|
+
throw new Error(`No such local file: ${absPath}`);
|
|
1562
|
+
const form = new FormData();
|
|
1563
|
+
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1564
|
+
if (folder)
|
|
1565
|
+
form.append("folder_path", folder);
|
|
1566
|
+
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1567
|
+
const text = await res.text();
|
|
1568
|
+
let json = null;
|
|
1569
|
+
try {
|
|
1570
|
+
json = text ? JSON.parse(text) : null;
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
json = null;
|
|
1574
|
+
}
|
|
1575
|
+
if (!res.ok)
|
|
1576
|
+
throw new Error(`local media upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1577
|
+
const url = readUploadedFileUrl(json);
|
|
1578
|
+
if (!url)
|
|
1579
|
+
throw new Error("local media upload returned no durable URL.");
|
|
1580
|
+
return url;
|
|
1581
|
+
}
|
|
1582
|
+
// Turn a --src value (http(s) URL or local file path) into a fetchable media
|
|
1583
|
+
// URL, importing/uploading local files as described above.
|
|
1584
|
+
async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
|
|
1585
|
+
const value = rawSrc.trim();
|
|
1586
|
+
if (/^https?:\/\//i.test(value))
|
|
1587
|
+
return { src: value, origin: "url", detail: null };
|
|
1588
|
+
const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
|
|
1589
|
+
if (!existsSync(abs)) {
|
|
1590
|
+
throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
|
|
1591
|
+
}
|
|
1592
|
+
const storageRoot = findServeStorageRoot(opts.htmlPath);
|
|
1593
|
+
if (storageRoot) {
|
|
1594
|
+
const key = importLocalMediaToServeStore(storageRoot, abs);
|
|
1595
|
+
return { src: serveStorageUrl(opts.baseUrl, key), origin: "local-serve", detail: key };
|
|
1596
|
+
}
|
|
1597
|
+
const folder = opts.folder ?? "temp";
|
|
1598
|
+
const url = await uploadLocalToTempStore(ctx, abs, folder);
|
|
1599
|
+
return { src: url, origin: "cloud-temp", detail: folder };
|
|
1600
|
+
}
|
|
1434
1601
|
// Apply an insert/replace of a media URL onto a composition.html on disk.
|
|
1435
1602
|
function placeMediaOnDisk(input) {
|
|
1436
1603
|
const html = readFileSync(input.htmlPath, "utf8");
|
|
@@ -1446,6 +1613,8 @@ function placeMediaOnDisk(input) {
|
|
|
1446
1613
|
width: input.width,
|
|
1447
1614
|
height: input.height,
|
|
1448
1615
|
objectFit: input.objectFit,
|
|
1616
|
+
kenBurns: input.kenBurns,
|
|
1617
|
+
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
1449
1618
|
slug: input.slug
|
|
1450
1619
|
};
|
|
1451
1620
|
const result = input.replace
|
|
@@ -1475,6 +1644,8 @@ async function runGenerateCommand(argv) {
|
|
|
1475
1644
|
at: { type: "string" },
|
|
1476
1645
|
replace: { type: "string" },
|
|
1477
1646
|
track: { type: "string" },
|
|
1647
|
+
"ken-burns": { type: "string" },
|
|
1648
|
+
"ken-burns-intensity": { type: "string" },
|
|
1478
1649
|
"layer-key": { type: "string" }
|
|
1479
1650
|
}
|
|
1480
1651
|
});
|
|
@@ -1546,6 +1717,8 @@ async function runGenerateCommand(argv) {
|
|
|
1546
1717
|
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1547
1718
|
replace: parsed.values.replace,
|
|
1548
1719
|
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
1720
|
+
kenBurns: parsed.values["ken-burns"],
|
|
1721
|
+
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
1549
1722
|
layerKey: parsed.values["layer-key"]
|
|
1550
1723
|
});
|
|
1551
1724
|
if (!ctx.json)
|
|
@@ -1577,21 +1750,34 @@ async function runPlaceCommand(argv) {
|
|
|
1577
1750
|
width: { type: "string" },
|
|
1578
1751
|
height: { type: "string" },
|
|
1579
1752
|
"object-fit": { type: "string" },
|
|
1753
|
+
"ken-burns": { type: "string" },
|
|
1754
|
+
"ken-burns-intensity": { type: "string" },
|
|
1580
1755
|
"layer-key": { type: "string" },
|
|
1581
|
-
slug: { type: "string" }
|
|
1756
|
+
slug: { type: "string" },
|
|
1757
|
+
// Local file support: where a serve box serves /storage from, and which
|
|
1758
|
+
// temp folder cloud uploads land under.
|
|
1759
|
+
"base-url": { type: "string" },
|
|
1760
|
+
folder: { type: "string" }
|
|
1582
1761
|
}
|
|
1583
1762
|
});
|
|
1584
1763
|
const target = parsed.positionals[0];
|
|
1585
1764
|
if (!target)
|
|
1586
|
-
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
|
|
1587
|
-
const
|
|
1588
|
-
if (!
|
|
1589
|
-
throw new Error("place requires --src <
|
|
1765
|
+
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url|file> ...`.");
|
|
1766
|
+
const rawSrc = parsed.values.src;
|
|
1767
|
+
if (!rawSrc)
|
|
1768
|
+
throw new Error("place requires --src <media URL or local file path>.");
|
|
1590
1769
|
const ctx = commonContext(parsed.values);
|
|
1591
1770
|
const htmlPath = resolveCompositionHtmlPath(target);
|
|
1771
|
+
const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
|
|
1772
|
+
const resolvedSrc = await resolvePlaceableMediaSrc(ctx, rawSrc, {
|
|
1773
|
+
htmlPath,
|
|
1774
|
+
baseUrl,
|
|
1775
|
+
folder: parsed.values.folder
|
|
1776
|
+
});
|
|
1777
|
+
const src = resolvedSrc.src;
|
|
1592
1778
|
const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
|
|
1593
1779
|
? parsed.values.kind
|
|
1594
|
-
: inferMediaKindFromUrl(
|
|
1780
|
+
: inferMediaKindFromUrl(rawSrc);
|
|
1595
1781
|
const num = (v) => (v !== undefined ? Number(v) : undefined);
|
|
1596
1782
|
const placement = placeMediaOnDisk({
|
|
1597
1783
|
htmlPath,
|
|
@@ -1606,13 +1792,21 @@ async function runPlaceCommand(argv) {
|
|
|
1606
1792
|
width: num(parsed.values.width),
|
|
1607
1793
|
height: num(parsed.values.height),
|
|
1608
1794
|
objectFit: parsed.values["object-fit"],
|
|
1795
|
+
kenBurns: parsed.values["ken-burns"],
|
|
1796
|
+
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
1609
1797
|
layerKey: parsed.values["layer-key"],
|
|
1610
1798
|
slug: parsed.values.slug
|
|
1611
1799
|
});
|
|
1612
1800
|
if (ctx.json) {
|
|
1613
|
-
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
|
|
1801
|
+
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps });
|
|
1614
1802
|
}
|
|
1615
1803
|
else {
|
|
1804
|
+
if (resolvedSrc.origin === "local-serve") {
|
|
1805
|
+
console.log(`${DIM}Local file → serve disk store (${resolvedSrc.detail}); referenced as ${src}. No S3 upload. Renders on this serve box only.${RESET}`);
|
|
1806
|
+
}
|
|
1807
|
+
else if (resolvedSrc.origin === "cloud-temp") {
|
|
1808
|
+
console.log(`${DIM}Local file → uploaded to ephemeral temp store under "${resolvedSrc.detail}/" on ${ctx.host}; referenced as ${src}.${RESET}`);
|
|
1809
|
+
}
|
|
1616
1810
|
console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
|
|
1617
1811
|
console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
|
|
1618
1812
|
console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
|
|
@@ -1706,6 +1900,7 @@ async function runApproveCommand(argv) {
|
|
|
1706
1900
|
caption: { type: "string" },
|
|
1707
1901
|
title: { type: "string" },
|
|
1708
1902
|
pinned: { type: "string" },
|
|
1903
|
+
folder: { type: "string" },
|
|
1709
1904
|
tracer: { type: "string" }
|
|
1710
1905
|
}
|
|
1711
1906
|
});
|
|
@@ -1713,11 +1908,21 @@ async function runApproveCommand(argv) {
|
|
|
1713
1908
|
const caption = parsed.values.caption;
|
|
1714
1909
|
if (!caption)
|
|
1715
1910
|
throw new Error("approve requires --caption <text>.");
|
|
1911
|
+
// An approved post is a shareable cloud page, so any media must be a durable
|
|
1912
|
+
// URL the page can load. Pass an http(s) URL through; a local file is uploaded
|
|
1913
|
+
// to the ephemeral temp store (default folder `temp/`) first.
|
|
1914
|
+
const uploadFolder = parsed.values.folder ?? "temp";
|
|
1915
|
+
const resolveApproveMedia = async (value) => {
|
|
1916
|
+
if (/^https?:\/\//i.test(value.trim()))
|
|
1917
|
+
return value.trim();
|
|
1918
|
+
const abs = path.resolve(process.cwd(), value.trim());
|
|
1919
|
+
return uploadLocalToTempStore(ctx, abs, uploadFolder);
|
|
1920
|
+
};
|
|
1716
1921
|
const media = [];
|
|
1717
1922
|
if (parsed.values.video)
|
|
1718
|
-
media.push({ url: parsed.values.video, kind: "video", role: "primary" });
|
|
1923
|
+
media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
|
|
1719
1924
|
for (const url of parsed.values.media ?? [])
|
|
1720
|
-
media.push({ url });
|
|
1925
|
+
media.push({ url: await resolveApproveMedia(String(url)) });
|
|
1721
1926
|
if (media.length === 0) {
|
|
1722
1927
|
console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
|
|
1723
1928
|
}
|
|
@@ -1883,7 +2088,7 @@ async function runUploadCommand(argv) {
|
|
|
1883
2088
|
}
|
|
1884
2089
|
const result = { status: res.status, ok: res.ok, json, text };
|
|
1885
2090
|
assertApiOk(result, "upload");
|
|
1886
|
-
emitResult(result, ctx.json, [["Durable URL ", result.json
|
|
2091
|
+
emitResult(result, ctx.json, [["Durable URL ", readUploadedFileUrl(result.json)]]);
|
|
1887
2092
|
}
|
|
1888
2093
|
async function runDownloadCommand(argv) {
|
|
1889
2094
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
@@ -146,6 +146,31 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Ken Burns image clips carry a CSS transform animation (vf-kb-* keyframes)
|
|
150
|
+
// directly on the [data-kenburns] <img>. CSS animations run on the wall clock,
|
|
151
|
+
// not player time, so the runtime scrubs them: pause + set currentTime from
|
|
152
|
+
// normalized clip progress. Normalizing against data-duration (instead of
|
|
153
|
+
// trusting the inline --vf-kb-dur) keeps the preview correct even while a
|
|
154
|
+
// timeline resize has not yet re-synced the inline duration.
|
|
155
|
+
function syncKenBurnsClip(clip, timing) {
|
|
156
|
+
if (typeof clip.getAnimations !== "function") return;
|
|
157
|
+
const progress = timing.duration > 0
|
|
158
|
+
? Math.max(0, Math.min(1, (time - timing.start) / timing.duration))
|
|
159
|
+
: 0;
|
|
160
|
+
let anims = [];
|
|
161
|
+
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
162
|
+
for (const anim of anims) {
|
|
163
|
+
if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-kb-") !== 0) continue;
|
|
164
|
+
let total = 0;
|
|
165
|
+
try { total = Number(anim.effect?.getComputedTiming?.().duration) || 0; } catch {}
|
|
166
|
+
if (!total) continue;
|
|
167
|
+
try {
|
|
168
|
+
anim.pause();
|
|
169
|
+
anim.currentTime = progress * total;
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
149
174
|
function apply(nextTime, options = {}) {
|
|
150
175
|
const forceSeek = Boolean(options.forceSeek);
|
|
151
176
|
const fromClock = Boolean(options.fromClock);
|
|
@@ -160,6 +185,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
160
185
|
} else {
|
|
161
186
|
setVisibility(clip, active);
|
|
162
187
|
}
|
|
188
|
+
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
163
189
|
}
|
|
164
190
|
if (clip instanceof HTMLMediaElement) {
|
|
165
191
|
applyMediaState(clip);
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
|
|
7
7
|
// ffmpeg, no network.
|
|
8
8
|
import { parseHTML } from "linkedom";
|
|
9
|
+
const KEN_BURNS_PRESETS = new Set([
|
|
10
|
+
"zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
|
|
11
|
+
]);
|
|
9
12
|
const round3 = (v) => Number(v.toFixed(3));
|
|
10
13
|
const clampPercent = (v) => Math.min(100, Math.max(0, v));
|
|
11
14
|
function serialize(document) {
|
|
@@ -209,6 +212,15 @@ function buildMediaClip(document, opts, id, track, geom) {
|
|
|
209
212
|
node.setAttribute("data-playback-start", ps);
|
|
210
213
|
styles.push("background:#050604");
|
|
211
214
|
}
|
|
215
|
+
else if (opts.kenBurns && KEN_BURNS_PRESETS.has(opts.kenBurns)) {
|
|
216
|
+
// Ken Burns lives on the <img> itself (see src/hyperframes/composition.ts):
|
|
217
|
+
// data-kenburns picks the keyframes; the inline vars supply span + strength.
|
|
218
|
+
node.setAttribute("data-kenburns", opts.kenBurns);
|
|
219
|
+
styles.push(`--vf-kb-dur:${round3(geom.duration)}s`);
|
|
220
|
+
if (opts.kenBurnsIntensity !== undefined && Number.isFinite(opts.kenBurnsIntensity)) {
|
|
221
|
+
styles.push(`--vf-kb-amount:${Math.max(0.04, Math.min(0.5, opts.kenBurnsIntensity))}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
212
224
|
node.setAttribute("style", `${styles.join(";")};`);
|
|
213
225
|
return node;
|
|
214
226
|
}
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -77,6 +77,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
77
77
|
"If the composition brief's cast is absent or empty, or the referenced person is not in it, fall back to extracting a still yourself: call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source video, then POST /api/v1/primitives/videos/extract-frame with source_video_url set to that URL and at_ms at a moment the person is clearly on screen (use video_context scene timings), then feed the returned image URL as generate_layer input_references (video) or prompt_attachments (image) exactly as above. Prefer the cast reference_url when present because it is already isolated from other people and reusable.",
|
|
78
78
|
"When placing generated character media on the timeline, prefer generate_layer (it applies the placement rules for you): to drop the character into a blank moment use intent='fill_gap' with start/duration on a timeline_gaps span; to swap them into an existing scene use intent='replace_layer' with replace_layer_key (its timing and full-canvas geometry are copied automatically). Always set aspect_ratio to the canvas (e.g. 9:16 for vertical) so the clip fills the frame. Only when you already have a finished media URL should you fall back to add_layer with explicit collision-free start/duration and full-canvas geometry (x=0, y=0, width=100, height=100) for a timeline-proxy/full-canvas replacement.",
|
|
79
79
|
"Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
|
|
80
|
+
"KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
|
|
80
81
|
"Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
|
|
81
82
|
"Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
|
|
82
83
|
"Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
|
|
@@ -142,21 +142,76 @@ function HomepageClientApp({ boot }) {
|
|
|
142
142
|
}, [refreshFeed]);
|
|
143
143
|
const handleAddTemplate = useCallback(async (submission) => {
|
|
144
144
|
try {
|
|
145
|
+
// Upload path: presign → PUT the bytes (straight to S3, or the local
|
|
146
|
+
// multipart fallback) → finalize with the storage key. The URL path
|
|
147
|
+
// posts source_url directly, exactly as before.
|
|
148
|
+
let uploadRef = null;
|
|
149
|
+
if (submission.file) {
|
|
150
|
+
const file = submission.file;
|
|
151
|
+
const presignResponse = await fetch("/discover/templates/upload/presign", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
credentials: "same-origin",
|
|
154
|
+
headers: { "content-type": "application/json", Accept: "application/json" },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
file_name: file.name,
|
|
157
|
+
content_type: file.type || null,
|
|
158
|
+
size_bytes: file.size
|
|
159
|
+
})
|
|
160
|
+
});
|
|
161
|
+
const presign = await presignResponse.json().catch(() => ({}));
|
|
162
|
+
if (!presignResponse.ok) {
|
|
163
|
+
return { ok: false, error: presign.error || "Unable to start the video upload." };
|
|
164
|
+
}
|
|
165
|
+
if (presign.transport === "presigned" && presign.upload?.url && presign.storage_key) {
|
|
166
|
+
const put = await fetch(presign.upload.url, {
|
|
167
|
+
method: presign.upload.method || "PUT",
|
|
168
|
+
headers: presign.upload.headers || {},
|
|
169
|
+
body: file
|
|
170
|
+
});
|
|
171
|
+
if (!put.ok) {
|
|
172
|
+
return { ok: false, error: `Video upload failed (${put.status}). Try again.` };
|
|
173
|
+
}
|
|
174
|
+
uploadRef = { storage_key: presign.storage_key, file_name: presign.file_name || file.name };
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const form = new FormData();
|
|
178
|
+
form.append("file", file, presign.file_name || file.name);
|
|
179
|
+
const direct = await fetch(presign.upload?.url || "/discover/templates/upload", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
credentials: "same-origin",
|
|
182
|
+
body: form
|
|
183
|
+
});
|
|
184
|
+
const directPayload = await direct.json().catch(() => ({}));
|
|
185
|
+
if (!direct.ok || !directPayload.storage_key) {
|
|
186
|
+
return { ok: false, error: directPayload.error || "Video upload failed. Try again." };
|
|
187
|
+
}
|
|
188
|
+
uploadRef = { storage_key: directPayload.storage_key, file_name: directPayload.file_name || file.name };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
145
191
|
const response = await fetch("/discover/templates", {
|
|
146
192
|
method: "POST",
|
|
147
193
|
credentials: "same-origin",
|
|
148
194
|
headers: { "content-type": "application/json", Accept: "application/json" },
|
|
149
|
-
body: JSON.stringify(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
195
|
+
body: JSON.stringify(uploadRef
|
|
196
|
+
? {
|
|
197
|
+
upload: uploadRef,
|
|
198
|
+
title: submission.title || null,
|
|
199
|
+
tagline: submission.tagline || null,
|
|
200
|
+
notes: submission.notes || null
|
|
201
|
+
}
|
|
202
|
+
: {
|
|
203
|
+
source_url: submission.sourceUrl,
|
|
204
|
+
tagline: submission.tagline || null,
|
|
205
|
+
notes: submission.notes || null
|
|
206
|
+
})
|
|
154
207
|
});
|
|
155
208
|
const payload = await response.json().catch(() => ({}));
|
|
156
209
|
if (!response.ok) {
|
|
157
210
|
return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to add that video." };
|
|
158
211
|
}
|
|
159
|
-
debugLog("homepage.template_added",
|
|
212
|
+
debugLog("homepage.template_added", uploadRef
|
|
213
|
+
? { storage_key: uploadRef.storage_key }
|
|
214
|
+
: { source_url: submission.sourceUrl });
|
|
160
215
|
await refreshFeed();
|
|
161
216
|
return { ok: true };
|
|
162
217
|
}
|