@mevdragon/vidfarm-devcli 0.3.5 → 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.
package/dist/src/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, 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,
@@ -107,6 +110,17 @@ async function runEditCommand(argv) {
107
110
  shareToken,
108
111
  refetch: parsed.values.refetch
109
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
+ }
110
124
  const devSource = `http://localhost:${port}`;
111
125
  const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
112
126
  printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
@@ -302,6 +316,232 @@ async function fetchCompositionFiles(input) {
302
316
  console.log(`[vidfarm] fetched ${filename} (${body.length} bytes)`);
303
317
  }
304
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
+ }
305
545
  function buildAuthHeaders(input) {
306
546
  const headers = { accept: "text/html,application/json" };
307
547
  // The server reads exactly these header names (see requireAuth and
@@ -328,6 +568,8 @@ function printEditorBanner(input) {
328
568
  console.log(` local dir ${input.dir}`);
329
569
  console.log(` dev-serve http://localhost:${input.port}`);
330
570
  console.log("");
571
+ console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
572
+ console.log("");
331
573
  console.log(` ${bold}Open in browser:${reset}`);
332
574
  console.log(` ${cyan}${input.editorUrl}${reset}`);
333
575
  console.log(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.3.5",
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": {