@mevdragon/vidfarm-devcli 0.3.4 → 0.3.6
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 +49 -1
- package/dist/src/cli.js +397 -1
- package/dist/src/dev-serve.js +13 -2
- package/package.json +1 -1
package/SKILL.director.md
CHANGED
|
@@ -306,11 +306,23 @@ For iterating on a composition on disk (e.g. Claude Code editing HTML directly),
|
|
|
306
306
|
|
|
307
307
|
```bash
|
|
308
308
|
npx -y @mevdragon/vidfarm-devcli <template_id>
|
|
309
|
-
# → downloads composition.html/json + manifest.json into .vidfarm/<template_id>/
|
|
309
|
+
# → downloads composition.html/json + manifest.json + video-context.json into .vidfarm/<template_id>/
|
|
310
|
+
# → downloads the videos + per-scene frames for local analysis (see below)
|
|
310
311
|
# → starts dev-serve on http://localhost:4321
|
|
311
312
|
# → prints https://vidfarm.cc/editor/<template_id>?fork=<forkId>&dev=http%3A%2F%2Flocalhost%3A4321
|
|
312
313
|
```
|
|
313
314
|
|
|
315
|
+
### Local media for analysis (`sources.json`, videos, frames)
|
|
316
|
+
|
|
317
|
+
So a coding agent can *see* what it is editing — not just read URLs — `vidfarm edit` also drops the actual media next to the composition:
|
|
318
|
+
|
|
319
|
+
- **`original.mp4`** — the raw source video, before any processing.
|
|
320
|
+
- **`source.mp4`** — the video the composition timeline actually renders from. This differs from `original.mp4` only when the fork was ghostcut/subtitle-removed; otherwise `sources.json` marks them `same_as_decomposed: true` and only `source.mp4` is written.
|
|
321
|
+
- **`frames/<scene>.png`** — one still per timeline scene, sampled at the scene midpoint from `source.mp4`. Open these as images to analyze what each scene shows. Requires `ffmpeg` (bundled via `ffmpeg-static`); if unavailable the videos still download and frames are skipped.
|
|
322
|
+
- **`sources.json`** — the manifest tying it together: each video's role/url/local path/byte size, the total duration, and per-scene `{ slug, start, duration, label, description, transcript_excerpt, frame }`.
|
|
323
|
+
|
|
324
|
+
This is best-effort and never blocks the editor session. Re-run with `--refetch` to refresh the media after a new decompose or ghostcut. For deeper text grounding (full transcript, per-scene visual descriptions) read `video-context.json` — the media files complement it with the pixels.
|
|
325
|
+
|
|
314
326
|
Open the printed URL in your browser. An orange **LOCAL DEV MODE** banner pins to the top with an Exit button. All fork API fetches now route to your local dir instead of the deployed API; disk edits push an SSE reload event and the editor re-fetches automatically.
|
|
315
327
|
|
|
316
328
|
Flags: `--port`, `--dir`, `--host`, `--fork`, `--api-key` (or set `VIDFARM_API_KEY`), `--share`, `--refetch`. For a fork that already has composition files on disk (or that you've populated manually), use `vidfarm-devcli dev-serve --dir ./my-fork --port 4321` to skip the fetch step.
|
|
@@ -443,3 +455,39 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/media/dedupe" \
|
|
|
443
455
|
}
|
|
444
456
|
}'
|
|
445
457
|
```
|
|
458
|
+
|
|
459
|
+
## Brainstorm primitives
|
|
460
|
+
|
|
461
|
+
The `brainstorm/*` primitives are the strategy toolkit. They are reusable, billable AI reasoning steps — the same family the AI Copilot exposes as chip suggestions. Treat **product placement** as a first-class member of this family, right alongside angles and hooks:
|
|
462
|
+
|
|
463
|
+
- `POST /api/v1/primitives/brainstorm/coldstart` — `{ payload: { user_message } }` → foundational questionnaire for a customer starting from zero.
|
|
464
|
+
- `POST /api/v1/primitives/brainstorm/awareness_stages` — `{ payload: { offer_description } }` → which Eugene-Schwartz awareness stages to target first.
|
|
465
|
+
- `POST /api/v1/primitives/brainstorm/angles` — `{ payload: { offer_description, problem_awareness, solution_awareness } }` → persuasive angles.
|
|
466
|
+
- `POST /api/v1/primitives/brainstorm/hooks` — `{ payload: { offer_description } }` → many TikTok-native hooks.
|
|
467
|
+
- `POST /api/v1/primitives/brainstorm/product_placement` — `{ payload: { source_video_url, offer_description } }` → **watches the video** and returns concrete, timestamped opportunities to natively place the product.
|
|
468
|
+
|
|
469
|
+
### Primitive: brainstorm_product_placement
|
|
470
|
+
|
|
471
|
+
Analyze a source video and identify where a product can be organically placed so it feels native rather than bolted on. This is a **multimodal** primitive: it feeds the actual video to a vision-capable model, so prefer a saved **Gemini** key (native video understanding). OpenRouter multimodal models also work; plain OpenAI chat keys cannot watch video.
|
|
472
|
+
|
|
473
|
+
- `POST /api/v1/primitives/brainstorm/product_placement`
|
|
474
|
+
- Body: `{ "tracer": "...", "payload": { "source_video_url": "...", "offer_description": "...", "count"?: 8, "provider"?: "gemini" }, "webhook_url"?: "..." }`
|
|
475
|
+
- `source_video_url` (required, URL) — the exact durable/public video to analyze. Aliases accepted: `video_url`, `source_url`, `url`. For a fork's composition, call `GET /api/v1/compositions/:forkId/ghostcut` first and pass the correct raw-upload vs caption-free-mirror URL.
|
|
476
|
+
- `offer_description` (required) — the product/offer to place. Aliases: `offer`, `description`, `details`.
|
|
477
|
+
- `count` (optional, 3–30, default 8) — number of opportunities; only send when the user asks for a specific number.
|
|
478
|
+
- Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` to completion, then read `result.json` (also surfaced on the job output as `opportunities[]`), where each entry has `timestamp`, `scene`, `placement_type`, `placement_idea`, and `why_it_works`.
|
|
479
|
+
|
|
480
|
+
You can also satisfy a quick, conversational product-placement question by reasoning over an attached video directly instead of calling this primitive — use the primitive when you want a durable, structured, billable artifact the customer can save and reuse.
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
curl -X POST "$VIDFARM_BASE/api/v1/primitives/brainstorm/product_placement" \
|
|
484
|
+
-H "vidfarm-api-key: $VIDFARM_API_KEY" \
|
|
485
|
+
-H "content-type: application/json" \
|
|
486
|
+
-d '{
|
|
487
|
+
"tracer": "product-placement-01",
|
|
488
|
+
"payload": {
|
|
489
|
+
"source_video_url": "https://cdn.example.com/source.mp4",
|
|
490
|
+
"offer_description": "A $29/mo AI meal-planning app for busy parents."
|
|
491
|
+
}
|
|
492
|
+
}'
|
|
493
|
+
```
|
package/dist/src/cli.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
5
8
|
// vidfarm-devcli — thin CLI that pairs a local composition directory with the
|
|
6
9
|
// hosted Trackpad Editor at https://vidfarm.cc/editor/. Point it at a template
|
|
7
10
|
// id, and it: resolves the user's fork, downloads the current composition,
|
|
@@ -12,6 +15,7 @@ const HELP = `vidfarm-devcli — local editor bridge for the Vidfarm Trackpad Ed
|
|
|
12
15
|
|
|
13
16
|
Usage:
|
|
14
17
|
vidfarm-devcli <template_id> [options] Start local editor session
|
|
18
|
+
vidfarm-devcli publish <template_id> [opts] Push local composition to your fork
|
|
15
19
|
vidfarm-devcli dev-serve [options] Serve an existing local dir only
|
|
16
20
|
vidfarm-devcli --help Show this help
|
|
17
21
|
|
|
@@ -24,6 +28,14 @@ Options (edit mode):
|
|
|
24
28
|
--share <token> Share token for a shared fork (public read)
|
|
25
29
|
--refetch Overwrite local composition files with the latest remote copy
|
|
26
30
|
|
|
31
|
+
Options (publish mode):
|
|
32
|
+
--dir <path> Local composition directory (default: .vidfarm/<template_id>)
|
|
33
|
+
--host <url> Vidfarm host to publish to (default: ${DEFAULT_HOST})
|
|
34
|
+
--fork <id> Fork id to publish into (default: auto-resolve for the current user)
|
|
35
|
+
--api-key <key> Vidfarm API key (or set VIDFARM_API_KEY) — required to write
|
|
36
|
+
--message <text> Optional message attached to the published version snapshot
|
|
37
|
+
--no-snapshot Only update the working copy; skip the version snapshot
|
|
38
|
+
|
|
27
39
|
Options (dev-serve mode):
|
|
28
40
|
--dir <path> Directory to serve (default: current dir)
|
|
29
41
|
--port <n> Port to listen on (default: ${DEFAULT_PORT})
|
|
@@ -48,6 +60,10 @@ async function main() {
|
|
|
48
60
|
await runEditCommand(argv.slice(1));
|
|
49
61
|
return;
|
|
50
62
|
}
|
|
63
|
+
if (command === "publish") {
|
|
64
|
+
await runPublishCommand(argv.slice(1));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
51
67
|
if (!command.startsWith("-")) {
|
|
52
68
|
// Positional template id → default `edit` command.
|
|
53
69
|
await runEditCommand(argv);
|
|
@@ -94,12 +110,164 @@ async function runEditCommand(argv) {
|
|
|
94
110
|
shareToken,
|
|
95
111
|
refetch: parsed.values.refetch
|
|
96
112
|
});
|
|
113
|
+
// Give local coding agents (Claude Code / Codex / opencode) the actual media
|
|
114
|
+
// to look at: the original source video, the composition's decomposed source
|
|
115
|
+
// (which may differ after ghostcut subtitle-removal), one still frame per
|
|
116
|
+
// scene, and a sources.json manifest tying it all together. Best-effort —
|
|
117
|
+
// never blocks the editor session if a download or frame extraction fails.
|
|
118
|
+
try {
|
|
119
|
+
await fetchVideoAssets({ dir: rootDir, apiKey, shareToken, refetch: parsed.values.refetch });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.warn(`[vidfarm] video assets skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
123
|
+
}
|
|
97
124
|
const devSource = `http://localhost:${port}`;
|
|
98
125
|
const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
|
|
99
126
|
printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
|
|
100
127
|
const { runDevServeCommand } = await import("./dev-serve.js");
|
|
101
128
|
await runDevServeCommand(["--dir", rootDir, "--port", String(port)]);
|
|
102
129
|
}
|
|
130
|
+
async function runPublishCommand(argv) {
|
|
131
|
+
const parsed = parseArgs({
|
|
132
|
+
args: argv,
|
|
133
|
+
allowPositionals: true,
|
|
134
|
+
options: {
|
|
135
|
+
dir: { type: "string" },
|
|
136
|
+
host: { type: "string", default: DEFAULT_HOST },
|
|
137
|
+
fork: { type: "string" },
|
|
138
|
+
"api-key": { type: "string" },
|
|
139
|
+
share: { type: "string" },
|
|
140
|
+
message: { type: "string" },
|
|
141
|
+
"no-snapshot": { type: "boolean", default: false }
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
const templateId = parsed.positionals[0];
|
|
145
|
+
if (!templateId && !parsed.values.dir) {
|
|
146
|
+
throw new Error(`publish requires a template id (or --dir).\n\n${HELP}`);
|
|
147
|
+
}
|
|
148
|
+
const host = trimTrailingSlash(parsed.values.host);
|
|
149
|
+
const apiKey = parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY;
|
|
150
|
+
const shareToken = parsed.values.share;
|
|
151
|
+
if (!apiKey && !shareToken) {
|
|
152
|
+
throw new Error("publish needs credentials to write. Pass --api-key <key> or set VIDFARM_API_KEY.");
|
|
153
|
+
}
|
|
154
|
+
const rootDir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", templateId ?? ""));
|
|
155
|
+
const forkId = parsed.values.fork ?? (templateId
|
|
156
|
+
? await resolveForkId({ host, templateId, apiKey, shareToken })
|
|
157
|
+
: null);
|
|
158
|
+
if (!forkId) {
|
|
159
|
+
throw new Error(`Could not resolve a fork id. Pass --fork <id>${templateId ? "" : " (no template id was given to auto-resolve)"}.`);
|
|
160
|
+
}
|
|
161
|
+
const htmlPath = path.join(rootDir, "composition.html");
|
|
162
|
+
if (!existsSync(htmlPath)) {
|
|
163
|
+
throw new Error(`No composition.html at ${htmlPath}. Run \`vidfarm-devcli ${templateId ?? "<template>"} --fork ${forkId}\` first, or pass --dir.`);
|
|
164
|
+
}
|
|
165
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
166
|
+
if (!html.includes("data-composition-id=")) {
|
|
167
|
+
throw new Error("composition.html is missing data-composition-id — refusing to publish a malformed composition.");
|
|
168
|
+
}
|
|
169
|
+
const jsonPath = path.join(rootDir, "composition.json");
|
|
170
|
+
const json = existsSync(jsonPath) ? readFileSync(jsonPath, "utf8") : null;
|
|
171
|
+
if (json !== null) {
|
|
172
|
+
try {
|
|
173
|
+
JSON.parse(json || "{}");
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
throw new Error(`composition.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const auth = { apiKey, shareToken };
|
|
180
|
+
console.log(`[vidfarm] publishing ${forkId} → ${host}`);
|
|
181
|
+
// 1) Working copy: composition.html (required) then composition.json (if any).
|
|
182
|
+
await putComposition(`${host}/api/v1/compositions/${encodeURIComponent(forkId)}/composition.html`, "PUT", html, "text/html; charset=utf-8", auth);
|
|
183
|
+
console.log(`[vidfarm] pushed composition.html (${Buffer.byteLength(html)} bytes)`);
|
|
184
|
+
if (json !== null) {
|
|
185
|
+
await putComposition(`${host}/api/v1/compositions/${encodeURIComponent(forkId)}/composition.json`, "PATCH", json, "application/json", auth);
|
|
186
|
+
console.log(`[vidfarm] pushed composition.json (${Buffer.byteLength(json)} bytes)`);
|
|
187
|
+
}
|
|
188
|
+
// 2) Optional immutable version snapshot.
|
|
189
|
+
let publishedVersion = null;
|
|
190
|
+
if (!parsed.values["no-snapshot"]) {
|
|
191
|
+
const snapshot = await postJson(`${host}/api/v1/compositions/${encodeURIComponent(forkId)}/versions`, { message: parsed.values.message ?? null }, auth);
|
|
192
|
+
publishedVersion = typeof snapshot?.version === "number" ? snapshot.version : null;
|
|
193
|
+
console.log(`[vidfarm] snapshotted version ${publishedVersion ?? "(unknown)"}`);
|
|
194
|
+
}
|
|
195
|
+
printPublishBanner({ forkId, dir: rootDir, host, version: publishedVersion, snapshotted: !parsed.values["no-snapshot"] });
|
|
196
|
+
}
|
|
197
|
+
// Writes one composition file to the remote fork. Unlike the edit-mode
|
|
198
|
+
// fetchCompositionFiles (which tolerates misses), publish MUST fail loudly — a
|
|
199
|
+
// silent skip would make the user think their work is safely stored when it is
|
|
200
|
+
// not.
|
|
201
|
+
async function putComposition(url, method, body, contentType, auth) {
|
|
202
|
+
const res = await fetch(url, {
|
|
203
|
+
method,
|
|
204
|
+
headers: { ...buildAuthHeaders(auth), "content-type": contentType },
|
|
205
|
+
body
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
throw new Error(await describeHttpFailure(res, url));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function postJson(url, body, auth) {
|
|
212
|
+
const res = await fetch(url, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { ...buildAuthHeaders(auth), "content-type": "application/json" },
|
|
215
|
+
body: JSON.stringify(body)
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new Error(await describeHttpFailure(res, url));
|
|
219
|
+
}
|
|
220
|
+
return await res.json().catch(() => ({}));
|
|
221
|
+
}
|
|
222
|
+
async function describeHttpFailure(res, url) {
|
|
223
|
+
let detail = "";
|
|
224
|
+
try {
|
|
225
|
+
const text = await res.text();
|
|
226
|
+
if (text) {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(text);
|
|
229
|
+
detail = parsed.error ? ` — ${parsed.error}${parsed.type ? ` (${parsed.type})` : ""}` : ` — ${text.slice(0, 300)}`;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
detail = ` — ${text.slice(0, 300)}`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// ignore body read failure
|
|
238
|
+
}
|
|
239
|
+
const hint = res.status === 401 || res.status === 403
|
|
240
|
+
? " Check your --api-key and that you own (or have edit rights on) this fork."
|
|
241
|
+
: res.status === 413
|
|
242
|
+
? " The composition exceeds the server size limit."
|
|
243
|
+
: res.status === 429
|
|
244
|
+
? " You are being rate-limited; wait and retry."
|
|
245
|
+
: "";
|
|
246
|
+
return `Publish failed: ${res.status} ${res.statusText}${detail} (${url}).${hint}`;
|
|
247
|
+
}
|
|
248
|
+
function printPublishBanner(input) {
|
|
249
|
+
const bold = "\x1b[1m";
|
|
250
|
+
const green = "\x1b[32m";
|
|
251
|
+
const cyan = "\x1b[36m";
|
|
252
|
+
const dim = "\x1b[2m";
|
|
253
|
+
const reset = "\x1b[0m";
|
|
254
|
+
const line = `${dim}${"─".repeat(74)}${reset}`;
|
|
255
|
+
console.log("");
|
|
256
|
+
console.log(line);
|
|
257
|
+
console.log(`${bold}${green} Published to Vidfarm${reset}`);
|
|
258
|
+
console.log(line);
|
|
259
|
+
console.log(` fork ${input.forkId}`);
|
|
260
|
+
console.log(` local dir ${input.dir}`);
|
|
261
|
+
if (input.snapshotted) {
|
|
262
|
+
console.log(` version ${input.version ?? "(unknown)"}`);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
console.log(` version ${dim}skipped (--no-snapshot)${reset}`);
|
|
266
|
+
}
|
|
267
|
+
console.log(` live html ${cyan}${input.host}/api/v1/compositions/${input.forkId}/composition.html${reset}`);
|
|
268
|
+
console.log(line);
|
|
269
|
+
console.log("");
|
|
270
|
+
}
|
|
103
271
|
async function resolveForkId(input) {
|
|
104
272
|
// /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
|
|
105
273
|
// whenever the caller (or the template's default fork) has one. Follow the
|
|
@@ -148,6 +316,232 @@ async function fetchCompositionFiles(input) {
|
|
|
148
316
|
console.log(`[vidfarm] fetched ${filename} (${body.length} bytes)`);
|
|
149
317
|
}
|
|
150
318
|
}
|
|
319
|
+
async function fetchVideoAssets(input) {
|
|
320
|
+
const refs = readSourceRefs(input.dir);
|
|
321
|
+
if (!refs.compositionSourceUrl && !refs.originalSourceUrl) {
|
|
322
|
+
console.log("[vidfarm] no source video URL found in composition — skipping video assets");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// "decomposed video in composition" = the source the composition currently
|
|
326
|
+
// renders from (post-ghostcut if applicable). "original" = the raw source the
|
|
327
|
+
// decompose ran against, before any processing. They coincide when the fork
|
|
328
|
+
// was never ghostcut/subtitle-removed.
|
|
329
|
+
const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
|
|
330
|
+
const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
|
|
331
|
+
const sameSource = compositionUrl === originalUrl;
|
|
332
|
+
const headers = buildAuthHeaders(input);
|
|
333
|
+
const decomposedName = "source.mp4";
|
|
334
|
+
const originalName = "original.mp4";
|
|
335
|
+
const decomposed = await downloadVideo({
|
|
336
|
+
url: compositionUrl,
|
|
337
|
+
target: path.join(input.dir, decomposedName),
|
|
338
|
+
headers,
|
|
339
|
+
refetch: input.refetch,
|
|
340
|
+
label: "decomposed source"
|
|
341
|
+
});
|
|
342
|
+
let original = decomposed && sameSource ? { ...decomposed, path: decomposedName } : null;
|
|
343
|
+
if (!sameSource) {
|
|
344
|
+
original = await downloadVideo({
|
|
345
|
+
url: originalUrl,
|
|
346
|
+
target: path.join(input.dir, originalName),
|
|
347
|
+
headers,
|
|
348
|
+
refetch: input.refetch,
|
|
349
|
+
label: "original source"
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
// One representative still per scene, taken from the decomposed source (what
|
|
353
|
+
// the composition actually renders). Falls back to the original if that's the
|
|
354
|
+
// only video that came down.
|
|
355
|
+
const frameBase = decomposed ? decomposedName : original ? originalName : null;
|
|
356
|
+
const frames = frameBase
|
|
357
|
+
? await extractSceneFrames({
|
|
358
|
+
dir: input.dir,
|
|
359
|
+
videoName: frameBase,
|
|
360
|
+
scenes: refs.scenes,
|
|
361
|
+
durationSeconds: refs.durationSeconds,
|
|
362
|
+
refetch: input.refetch
|
|
363
|
+
})
|
|
364
|
+
: new Map();
|
|
365
|
+
const manifest = {
|
|
366
|
+
generated_by: "vidfarm-devcli",
|
|
367
|
+
note: "Local media references for coding agents. `original.mp4` is the raw source video; " +
|
|
368
|
+
"`source.mp4` is the video the composition timeline renders from (may differ from the " +
|
|
369
|
+
"original after ghostcut subtitle-removal). Scene entries map timeline segments to a still " +
|
|
370
|
+
"frame you can open as an image. Run `--refetch` to refresh.",
|
|
371
|
+
videos: {
|
|
372
|
+
decomposed: {
|
|
373
|
+
role: "video the composition timeline renders from (decomposed source)",
|
|
374
|
+
url: compositionUrl,
|
|
375
|
+
path: decomposed ? decomposedName : null,
|
|
376
|
+
bytes: decomposed?.bytes ?? null
|
|
377
|
+
},
|
|
378
|
+
original: {
|
|
379
|
+
role: "raw original source video (before any processing)",
|
|
380
|
+
url: originalUrl,
|
|
381
|
+
path: original ? original.path : null,
|
|
382
|
+
bytes: original?.bytes ?? null,
|
|
383
|
+
same_as_decomposed: sameSource
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
duration_seconds: refs.durationSeconds,
|
|
387
|
+
scenes: refs.scenes.map((scene, index) => ({
|
|
388
|
+
slug: scene.slug,
|
|
389
|
+
start: scene.start,
|
|
390
|
+
duration: scene.duration,
|
|
391
|
+
label: scene.label,
|
|
392
|
+
description: scene.description,
|
|
393
|
+
transcript_excerpt: scene.transcript_excerpt,
|
|
394
|
+
frame: frames.get(index) ?? null,
|
|
395
|
+
frame_source: frameBase
|
|
396
|
+
}))
|
|
397
|
+
};
|
|
398
|
+
writeFileSync(path.join(input.dir, "sources.json"), JSON.stringify(manifest, null, 2));
|
|
399
|
+
console.log(`[vidfarm] wrote sources.json (${refs.scenes.length} scenes, ${frames.size} frames)`);
|
|
400
|
+
}
|
|
401
|
+
// Derive the source URLs and scene list from the files already on disk
|
|
402
|
+
// (composition.html + video-context.json) — no extra network round-trips.
|
|
403
|
+
function readSourceRefs(dir) {
|
|
404
|
+
let compositionSourceUrl = null;
|
|
405
|
+
const htmlPath = path.join(dir, "composition.html");
|
|
406
|
+
if (existsSync(htmlPath)) {
|
|
407
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
408
|
+
compositionSourceUrl =
|
|
409
|
+
matchAttr(html, "data-source-video") ??
|
|
410
|
+
matchPlaybackSourceSrc(html) ??
|
|
411
|
+
null;
|
|
412
|
+
}
|
|
413
|
+
let originalSourceUrl = null;
|
|
414
|
+
let scenes = [];
|
|
415
|
+
let durationSeconds = null;
|
|
416
|
+
const ctxPath = path.join(dir, "video-context.json");
|
|
417
|
+
if (existsSync(ctxPath)) {
|
|
418
|
+
try {
|
|
419
|
+
const ctx = JSON.parse(readFileSync(ctxPath, "utf8"));
|
|
420
|
+
if (typeof ctx.source_url === "string" && ctx.source_url)
|
|
421
|
+
originalSourceUrl = ctx.source_url;
|
|
422
|
+
if (typeof ctx.duration_seconds === "number")
|
|
423
|
+
durationSeconds = ctx.duration_seconds;
|
|
424
|
+
if (Array.isArray(ctx.scenes)) {
|
|
425
|
+
scenes = ctx.scenes
|
|
426
|
+
.map((raw) => {
|
|
427
|
+
if (!raw || typeof raw !== "object")
|
|
428
|
+
return null;
|
|
429
|
+
const s = raw;
|
|
430
|
+
return {
|
|
431
|
+
slug: typeof s.slug === "string" ? s.slug : null,
|
|
432
|
+
start: Number(s.start ?? 0),
|
|
433
|
+
duration: Number(s.duration ?? 0),
|
|
434
|
+
label: typeof s.label === "string" ? s.label : null,
|
|
435
|
+
description: typeof s.description === "string" ? s.description : "",
|
|
436
|
+
transcript_excerpt: typeof s.transcript_excerpt === "string" ? s.transcript_excerpt : ""
|
|
437
|
+
};
|
|
438
|
+
})
|
|
439
|
+
.filter((s) => s !== null);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// video-context.json absent/status:none/malformed → fall back to composition source only
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { compositionSourceUrl, originalSourceUrl, scenes, durationSeconds };
|
|
447
|
+
}
|
|
448
|
+
function matchAttr(html, attr) {
|
|
449
|
+
const match = html.match(new RegExp(`${attr}="([^"]+)"`));
|
|
450
|
+
return match ? decodeHtmlEntities(match[1]) : null;
|
|
451
|
+
}
|
|
452
|
+
function matchPlaybackSourceSrc(html) {
|
|
453
|
+
const match = html.match(/data-vf-playback-source="true"[^>]*\ssrc="([^"]+)"/) ??
|
|
454
|
+
html.match(/<video[^>]*\ssrc="([^"]+)"/);
|
|
455
|
+
return match ? decodeHtmlEntities(match[1]) : null;
|
|
456
|
+
}
|
|
457
|
+
function decodeHtmlEntities(value) {
|
|
458
|
+
return value
|
|
459
|
+
.replace(/&/g, "&")
|
|
460
|
+
.replace(/"/g, '"')
|
|
461
|
+
.replace(/'/g, "'")
|
|
462
|
+
.replace(/</g, "<")
|
|
463
|
+
.replace(/>/g, ">");
|
|
464
|
+
}
|
|
465
|
+
async function downloadVideo(input) {
|
|
466
|
+
const name = path.basename(input.target);
|
|
467
|
+
if (existsSync(input.target) && !input.refetch) {
|
|
468
|
+
console.log(`[vidfarm] keep local ${name} (use --refetch to overwrite)`);
|
|
469
|
+
return { path: name, bytes: safeSize(input.target) };
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const res = await fetch(input.url, { headers: input.headers });
|
|
473
|
+
if (!res.ok || !res.body) {
|
|
474
|
+
console.warn(`[vidfarm] skip ${name} (${input.label}): ${res.status} ${res.statusText}`);
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(input.target));
|
|
478
|
+
const bytes = safeSize(input.target);
|
|
479
|
+
console.log(`[vidfarm] fetched ${name} (${input.label}, ${formatBytes(bytes)})`);
|
|
480
|
+
return { path: name, bytes };
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
console.warn(`[vidfarm] skip ${name} (${input.label}): ${error instanceof Error ? error.message : String(error)}`);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function extractSceneFrames(input) {
|
|
488
|
+
const out = new Map();
|
|
489
|
+
if (input.scenes.length === 0)
|
|
490
|
+
return out;
|
|
491
|
+
const ffmpeg = await resolveFfmpegPath();
|
|
492
|
+
if (!ffmpeg) {
|
|
493
|
+
console.log("[vidfarm] ffmpeg unavailable — skipping scene frames (videos still downloaded)");
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
const framesDir = path.join(input.dir, "frames");
|
|
497
|
+
mkdirSync(framesDir, { recursive: true });
|
|
498
|
+
const videoPath = path.join(input.dir, input.videoName);
|
|
499
|
+
input.scenes.forEach((scene, index) => {
|
|
500
|
+
const slug = scene.slug || `scene-${index + 1}`;
|
|
501
|
+
const rel = path.join("frames", `${slug}.png`);
|
|
502
|
+
const target = path.join(input.dir, rel);
|
|
503
|
+
if (existsSync(target) && !input.refetch) {
|
|
504
|
+
out.set(index, rel);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Sample the scene midpoint, clamped inside the clip.
|
|
508
|
+
const mid = scene.start + Math.max(0, scene.duration) / 2;
|
|
509
|
+
const seek = Number.isFinite(mid) && mid > 0 ? mid : Math.max(0, scene.start);
|
|
510
|
+
const result = spawnSync(ffmpeg, ["-y", "-ss", seek.toFixed(3), "-i", videoPath, "-frames:v", "1", "-q:v", "3", target], { stdio: "ignore" });
|
|
511
|
+
if (result.status === 0 && existsSync(target)) {
|
|
512
|
+
out.set(index, rel);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
console.log(`[vidfarm] extracted ${out.size}/${input.scenes.length} scene frames from ${input.videoName}`);
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
async function resolveFfmpegPath() {
|
|
519
|
+
// ffmpeg-static is already a dependency; import lazily so the common path
|
|
520
|
+
// (no frame extraction) never pays for it, and degrade gracefully if absent.
|
|
521
|
+
try {
|
|
522
|
+
const mod = (await import("ffmpeg-static"));
|
|
523
|
+
const resolved = typeof mod === "string" ? mod : mod.default;
|
|
524
|
+
return typeof resolved === "string" && resolved.length > 0 && existsSync(resolved) ? resolved : null;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function safeSize(target) {
|
|
531
|
+
try {
|
|
532
|
+
return statSync(target).size;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function formatBytes(bytes) {
|
|
539
|
+
if (bytes >= 1024 * 1024)
|
|
540
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
541
|
+
if (bytes >= 1024)
|
|
542
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
543
|
+
return `${bytes} bytes`;
|
|
544
|
+
}
|
|
151
545
|
function buildAuthHeaders(input) {
|
|
152
546
|
const headers = { accept: "text/html,application/json" };
|
|
153
547
|
// The server reads exactly these header names (see requireAuth and
|
|
@@ -174,6 +568,8 @@ function printEditorBanner(input) {
|
|
|
174
568
|
console.log(` local dir ${input.dir}`);
|
|
175
569
|
console.log(` dev-serve http://localhost:${input.port}`);
|
|
176
570
|
console.log("");
|
|
571
|
+
console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
|
|
572
|
+
console.log("");
|
|
177
573
|
console.log(` ${bold}Open in browser:${reset}`);
|
|
178
574
|
console.log(` ${cyan}${input.editorUrl}${reset}`);
|
|
179
575
|
console.log(line);
|
package/dist/src/dev-serve.js
CHANGED
|
@@ -11,7 +11,11 @@ import { parseArgs } from "node:util";
|
|
|
11
11
|
const CORS_HEADERS = {
|
|
12
12
|
"access-control-allow-origin": "*",
|
|
13
13
|
"access-control-allow-methods": "GET,PUT,PATCH,POST,OPTIONS",
|
|
14
|
-
|
|
14
|
+
// The editor page (vidfarm.cc) fetches us cross-origin and its Sentry
|
|
15
|
+
// instrumentation attaches distributed-tracing headers (sentry-trace,
|
|
16
|
+
// baggage). List them so the preflight passes; the OPTIONS handler also
|
|
17
|
+
// echoes whatever the browser actually asks for, which is the real guard.
|
|
18
|
+
"access-control-allow-headers": "content-type,authorization,vidfarm-api-key,vidfarm-share-token,sentry-trace,baggage",
|
|
15
19
|
"access-control-max-age": "600"
|
|
16
20
|
};
|
|
17
21
|
// Cap request bodies at 8MB so a runaway or malicious client can't OOM the
|
|
@@ -155,7 +159,14 @@ function handleRequest(req, res, ctx) {
|
|
|
155
159
|
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
156
160
|
const pathname = url.pathname;
|
|
157
161
|
if (method === "OPTIONS") {
|
|
158
|
-
|
|
162
|
+
// Echo the exact headers the browser requested so a preflight never fails
|
|
163
|
+
// over an unexpected header (Sentry tracing, etc.). This is a local dev
|
|
164
|
+
// tool, so permissive CORS is fine.
|
|
165
|
+
const requested = req.headers["access-control-request-headers"];
|
|
166
|
+
res.writeHead(204, {
|
|
167
|
+
...CORS_HEADERS,
|
|
168
|
+
"access-control-allow-headers": typeof requested === "string" && requested ? requested : CORS_HEADERS["access-control-allow-headers"]
|
|
169
|
+
});
|
|
159
170
|
res.end();
|
|
160
171
|
return;
|
|
161
172
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|