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