@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 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(/&amp;/g, "&")
460
+ .replace(/&quot;/g, '"')
461
+ .replace(/&#39;/g, "'")
462
+ .replace(/&lt;/g, "<")
463
+ .replace(/&gt;/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);
@@ -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
- "access-control-allow-headers": "content-type,authorization",
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
- res.writeHead(204, CORS_HEADERS);
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.4",
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": {