@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 +13 -1
- package/dist/src/cli.js +243 -1
- 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.
|
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(/&/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
|
+
}
|
|
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.
|
|
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": {
|