@officexapp/vidfarm-devcli 0.21.32 → 0.21.34
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/.agents/skills/editor-capabilities/SKILL.md +4 -3
- package/.agents/skills/vidfarm/SKILL.md +18 -3
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +15 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +46 -2
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +13 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +7 -3
- package/.agents/skills/vidfarm/references/reviewing-renders.md +139 -0
- package/.agents/skills/vidfarm/regimes/README.md +2 -0
- package/.agents/skills/vidfarm/regimes/explainer.QA_REGIME.md +20 -1
- package/.agents/skills/vidfarm/regimes/product-demo.QA_REGIME.md +18 -0
- package/.agents/skills/vidfarm/regimes/short-form.QA_REGIME.md +31 -0
- package/.agents/skills/vidfarm/regimes/ugc-testimonial.QA_REGIME.md +7 -0
- package/SKILL.director.md +241 -10
- package/SKILL.md +2 -1
- package/dist/src/cli.js +28 -3
- package/dist/src/devcli/qa-check.js +68 -0
- package/dist/src/devcli/stills.js +65 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +14 -14
package/dist/src/cli.js
CHANGED
|
@@ -736,6 +736,14 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
|
|
|
736
736
|
("did my edit look right") without a full render
|
|
737
737
|
--at 0,2.5,7 Timestamps (default: midpoint of each scene clip, cap 8)
|
|
738
738
|
--out <dir> Output dir (default <dir>/stills)
|
|
739
|
+
--sheet ALSO tile them into one contact sheet PNG — the
|
|
740
|
+
whole-video review pass. Read it as ONE image:
|
|
741
|
+
scene-by-scene building drifts (uneven margins,
|
|
742
|
+
3 type sizes, a wandering accent colour, N equal
|
|
743
|
+
beats, a jarring join) and only a side-by-side
|
|
744
|
+
sheet shows it
|
|
745
|
+
--sheet-out <file> Sheet path (default <out>/contact-sheet.png)
|
|
746
|
+
--sheet-width <px> Per-tile width in the sheet (default 320)
|
|
739
747
|
doctor Health-check the local toolchain: node, (local)
|
|
740
748
|
ffmpeg, hyperframes engines, Chrome, API key
|
|
741
749
|
(whoami), provider keys, agent CLI, poisoned
|
|
@@ -2387,9 +2395,10 @@ Rules:
|
|
|
2387
2395
|
- When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
|
|
2388
2396
|
- For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
|
|
2389
2397
|
- If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
|
|
2390
|
-
- NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). None of
|
|
2398
|
+
- NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
|
|
2391
2399
|
- STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption; never a DM funnel, "follow for part two", or ragebait. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm regime show hooks\`.
|
|
2392
2400
|
- THE FIRST FRAME IS THE THUMBNAIL. Frame 0 is one frame of ~30 in the first second, but every feed card, share link, and paused player freezes on it — more people see that frame than watch the video. It must never be black, empty, mid-fade, or mid-animation: a real visual at start:0 (\`vidfarm retime . --layer <key> --start 0\`), the hook words already on screen at t=0, and NO entrance transition on the FIRST clip (\`vidfarm transitions set . --layer <key> --in none\`; junction transitions between later clips are fine). Look at the actual pixels before you render: \`vidfarm stills . --at 0\`.
|
|
2401
|
+
- REVIEW THE WHOLE VIDEO AS ONE OBJECT — AND NEVER JUDGE IT BY ONE FRAME. Assume your own finished video has a defect you cannot see: across a 32-video batch, EVERY first-pass video had a real defect its own author had already reported as "verified, looks good". The cause is structural — you build scene by scene, each scene correct while it is the whole world, so every scene passes alone and the video fails as a SEQUENCE: margins shift between beats, headline sizes drift, the accent color wanders, one asset is flat vector and the next is photographic, every beat is the same length, a join lands like a slap. Nobody watches a scene; they watch the sequence. So before you call anything done, tile ~12 stills into ONE contact sheet and READ IT AS AN IMAGE — one command: \`vidfarm stills . --sheet\` (add \`--at 0,2,4,…\` to pick timestamps; writes stills/contact-sheet.png). Check: visual balance (no dead band under top-anchored content), consistent spacing/margins, ONE type scale, ONE accent color, ONE illustration style, deliberate pacing rather than N identical beats, nothing jarring at the joins, no frame where two elements compete for the eye — and the summary question, does it look like one person made it in one sitting? Fix drift by defining the SYSTEM (type scale, margin, palette, default beat) and applying it to every scene, not by patching the one scene that stood out. The defects that actually ship, in observed frequency order: large flat dead regions · a placeholder empty state that reads as a failed render · two contradictory numbers in one frame · a CTA still building at the last frame (settle it >=2s before the end) · two headlines superimposed at a scene handoff (exit at nextIn-0.18, duration 0.24, ease power2.out) · type colliding with a busy background exactly as it is spoken. If a frame looks empty, sample 0.2s apart to see whether it RESTS there — a transient wipe frame is fine, >0.5s is a hole. AND ALWAYS COMPARE TWO FRAMES FROM DIFFERENT SCENES: a frozen render (an overlay/watermark pass missing \`-loop 1\`, or assets outside the composition root so the timeline never runs) makes every frame identical while duration, frame count and audio hash all still pass, and frame 0 looks perfect. Verify audio by MEASUREMENT, never "it sounds fine" (you cannot hear it): ~12-15 dB speech-over-bed separation across the actual word spans, peak <0 dBFS. Never \`adelay\` the VO — whisper word timings and every caption built from them are relative to the raw vo.wav; use apad+atrim. Report what you MEASURED separately from what you JUDGED. Full method: the vidfarm skill's references/reviewing-renders.md.
|
|
2393
2402
|
- ONE-TIME OR BULK? Ask before you build. If the director wants volume (daily posting, N variants, hook tests), that's SCRIPTING MODE: pin this fork as the base, vary exactly ONE thing per variant, and install a QA_REGIME.md — \`vidfarm regime init short-form --out ./QA_REGIME.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo), then EDIT it with them. It is their own written quality standard, and it exists because nobody watches variant #37 as carefully as #1. \`vidfarm qa .\` picks up ./QA_REGIME.md automatically; \`--regime <name|path>\` adds more (they stack, and any file of theirs anywhere is valid). Its \`checks:\` are machine-settled; its \`- [ ]\` items come back for YOU to answer honestly in your report — never claim a pass on the half the CLI can't judge. When a batch teaches you something, write it back into the regime.
|
|
2394
2403
|
- DEDUPLICATE BEFORE YOU PUBLISH — AND ASK FIRST. Social platforms fingerprint every upload, so the same render posted twice (a second account, another platform, a re-post next month) gets the later copy suppressed as duplicate/reused content. BEFORE you render for publication, and before any bulk run, ASK the director: "do you want deduplicated copies for posting, and how many?" Ask THEN, not after — dedupe is a post-render ffmpeg pass, so the correct order is RENDER ONCE → DEDUPE N, and deciding late means paying for a second render. Run it on the EXPORTED file: \`vidfarm dedupe ./final.mp4\` (one copy) or \`vidfarm dedupe ./final.mp4 --variants N --seed <slug> --out-dir ./posts\` (N copies, one per account/slot). Free, offline, no wallet — it never re-renders the composition. The default \`standard\` preset is skew 2%, zoom 3%, rotate 2°, speed +2%, saturation +4%, plus contrast/brightness/hue/grain, a container-metadata strip and a per-variant CRF walk; invisible to a viewer, and each variant differs from the original AND from its siblings. Post each variant to a DIFFERENT account — two accounts posting the same variant defeats the point. A rotate forces a bigger centre-crop to hide the black corners (~6.7% on a tall frame at 2°) and the CLI says so; pass \`--rotate 0\` when framing matters more. Cloud twin: \`POST /api/v1/primitives/media/dedupe\`.
|
|
2395
2404
|
- QA EVERY VIDEO BEFORE YOU RENDER: run \`vidfarm qa .\` in this directory. It's free, instant, and local — a blocklist for the slop above plus the first frame, the font regime, and the safe zone, with a concrete fix per finding. It's feedback, not a gate (exits 0 even on findings, never runs automatically) and a blocklist, not an allowlist, so stylized or hand-made work passes untouched. Fix what's real, ignore what's a deliberate style call. \`--json\` for scripted batches.
|
|
@@ -10264,20 +10273,32 @@ async function runStillsCommand(argv) {
|
|
|
10264
10273
|
options: {
|
|
10265
10274
|
at: { type: "string" },
|
|
10266
10275
|
out: { type: "string" },
|
|
10276
|
+
sheet: { type: "boolean", default: false },
|
|
10277
|
+
"sheet-out": { type: "string" },
|
|
10278
|
+
"sheet-width": { type: "string" },
|
|
10267
10279
|
json: { type: "boolean", default: false }
|
|
10268
10280
|
}
|
|
10269
10281
|
});
|
|
10270
10282
|
const target = parsed.positionals[0];
|
|
10271
10283
|
if (!target)
|
|
10272
|
-
throw new Error("stills requires a composition path: `vidfarm stills <dir-or-composition.html> [--at 0,2.5,7] [--out <dir>]`.");
|
|
10284
|
+
throw new Error("stills requires a composition path: `vidfarm stills <dir-or-composition.html> [--at 0,2.5,7] [--sheet] [--out <dir>]`.");
|
|
10273
10285
|
const htmlPath = resolveCompositionHtmlPath(target);
|
|
10274
10286
|
const json = Boolean(parsed.values.json);
|
|
10275
10287
|
const at = parsed.values.at
|
|
10276
10288
|
? String(parsed.values.at).split(",").map((value) => parseTimeToSeconds(value.trim())).filter((value) => Number.isFinite(value))
|
|
10277
10289
|
: undefined;
|
|
10290
|
+
const sheetWidthRaw = parsed.values["sheet-width"] ? Number.parseInt(String(parsed.values["sheet-width"]), 10) : undefined;
|
|
10278
10291
|
if (!json)
|
|
10279
10292
|
console.log(`${DIM}Rendering stills in-process (free, local Chrome capture)…${RESET}`);
|
|
10280
|
-
const result = await renderCompositionStills({
|
|
10293
|
+
const result = await renderCompositionStills({
|
|
10294
|
+
htmlPath,
|
|
10295
|
+
at,
|
|
10296
|
+
outDir: parsed.values.out,
|
|
10297
|
+
quiet: json,
|
|
10298
|
+
sheet: Boolean(parsed.values.sheet),
|
|
10299
|
+
sheetPath: parsed.values["sheet-out"],
|
|
10300
|
+
...(Number.isFinite(sheetWidthRaw) ? { sheetTileWidth: sheetWidthRaw } : {})
|
|
10301
|
+
});
|
|
10281
10302
|
if (json) {
|
|
10282
10303
|
printJson(result);
|
|
10283
10304
|
return;
|
|
@@ -10286,6 +10307,10 @@ async function runStillsCommand(argv) {
|
|
|
10286
10307
|
console.log(` ${GREEN}${still.path}${RESET} ${DIM}(requested ${still.requested_sec}s → captured ${still.captured_sec}s)${RESET}`);
|
|
10287
10308
|
}
|
|
10288
10309
|
console.log(`${DIM}${result.stills.length} still(s) at ${result.grid_fps}fps grid accuracy — open them to verify the edit before rendering.${RESET}`);
|
|
10310
|
+
if (result.sheet) {
|
|
10311
|
+
console.log(` ${GREEN}${result.sheet}${RESET} ${DIM}(contact sheet)${RESET}`);
|
|
10312
|
+
console.log(`${DIM}READ the contact sheet as ONE image — that is how you catch what per-scene checks miss: uneven margins, a wandering type scale or accent colour, N identically-long beats, a jarring join, dead space under top-anchored content. Fix drift by defining the system, not by patching the one odd scene.${RESET}`);
|
|
10313
|
+
}
|
|
10289
10314
|
}
|
|
10290
10315
|
// ── Agent skill ───────────────────────────────────────────────────────────────
|
|
10291
10316
|
// Install the latest director skill onto disk as a Claude Code / agent skill so
|
|
@@ -103,6 +103,38 @@ function radiusPx(style) {
|
|
|
103
103
|
const num = Number((raw.match(/(-?[\d.]+)px/) ?? [])[1] ?? 0);
|
|
104
104
|
return Number.isFinite(num) ? num : 0;
|
|
105
105
|
}
|
|
106
|
+
/** Walk self + ancestors up to the composition root. */
|
|
107
|
+
function selfAndAncestors(node) {
|
|
108
|
+
const chain = [];
|
|
109
|
+
let cursor = node;
|
|
110
|
+
while (cursor && typeof cursor.getAttribute === "function") {
|
|
111
|
+
chain.push(cursor);
|
|
112
|
+
if (cursor.getAttribute("data-composition-id") != null)
|
|
113
|
+
break;
|
|
114
|
+
cursor = cursor.parentNode;
|
|
115
|
+
}
|
|
116
|
+
return chain;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Is this node part of an animated caption run? The active-word highlight IS a
|
|
120
|
+
* pill, and it is the one legitimate pill in a video — it tracks the spoken
|
|
121
|
+
* word instead of sitting there like a badge.
|
|
122
|
+
*/
|
|
123
|
+
function isAnimatedCaptionPart(node) {
|
|
124
|
+
return selfAndAncestors(node).some((n) => n.getAttribute("data-caption-animation") != null || n.getAttribute("data-cap-word") != null);
|
|
125
|
+
}
|
|
126
|
+
// Native platform artifacts that legitimately use rounded, filled bubbles:
|
|
127
|
+
// iMessage/DM threads, TikTok comment cards, fake chat UI. These are social
|
|
128
|
+
// furniture, not web furniture, so the badge rule must never fire on them.
|
|
129
|
+
const MOCK_UI_HINT = /(imessage|messenger|whatsapp|bubble|chat|sms|dm-|comment|reply|tweet|notification|caption-pill)/i;
|
|
130
|
+
function isMockSocialUi(node) {
|
|
131
|
+
return selfAndAncestors(node).some((n) => {
|
|
132
|
+
if (n.getAttribute("data-vf-mock-ui") != null)
|
|
133
|
+
return true;
|
|
134
|
+
const hay = `${n.getAttribute("class") ?? ""} ${n.getAttribute("data-label") ?? ""} ${n.getAttribute("data-slug") ?? ""} ${n.getAttribute("id") ?? ""}`;
|
|
135
|
+
return MOCK_UI_HINT.test(hay);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
106
138
|
function primaryFamily(raw) {
|
|
107
139
|
return String(raw).split(",")[0].replace(/['"]/g, "").trim().toLowerCase();
|
|
108
140
|
}
|
|
@@ -264,6 +296,42 @@ export function qaCompositionHtml(html) {
|
|
|
264
296
|
fix: "Say each benefit as its OWN timed caption line on the footage, one at a time, in the font regime. One idea per beat reads far better than a chip strip."
|
|
265
297
|
});
|
|
266
298
|
}
|
|
299
|
+
// ── Rule: standalone badge pill ────────────────────────────────────────────
|
|
300
|
+
// The lonely capsule: ONE rounded, padded, filled tag holding a static stat or
|
|
301
|
+
// label — "10 hrs / week", "STEP 2", "EP.01", "+40%". badge-chip-row needs two
|
|
302
|
+
// siblings and cta-button needs action copy, so a single stat pill used to slip
|
|
303
|
+
// through both — and it is the most common surviving web tell in practice.
|
|
304
|
+
// Two signals: a capsule shape (radius well past a caption band) AND a fill.
|
|
305
|
+
// Excluded on purpose: active-word caption highlights (they MOVE with the
|
|
306
|
+
// spoken word — the one legitimate pill) and mock social UI, which is native.
|
|
307
|
+
for (const node of all) {
|
|
308
|
+
const text = textOf(node);
|
|
309
|
+
if (!text || text.length > 45)
|
|
310
|
+
continue;
|
|
311
|
+
const style = styleString(node);
|
|
312
|
+
const radius = radiusPx(style);
|
|
313
|
+
const filled = /background(?:-color|-image)?\s*:/.test(style) && !/background[^;]*:\s*(none|transparent)/.test(style);
|
|
314
|
+
// A caption band tops out around 8px; 20px+ on a text-sized box is a capsule.
|
|
315
|
+
if (!filled || radius < 20)
|
|
316
|
+
continue;
|
|
317
|
+
// Padding is what turns a hugging band into a tag. Either axis counts.
|
|
318
|
+
const padded = hasDecl(style, "padding") || hasDecl(style, "padding-left") || hasDecl(style, "padding-inline") ||
|
|
319
|
+
hasDecl(style, "padding-top") || hasDecl(style, "padding-block");
|
|
320
|
+
if (!padded)
|
|
321
|
+
continue;
|
|
322
|
+
if (isAnimatedCaptionPart(node) || isMockSocialUi(node))
|
|
323
|
+
continue;
|
|
324
|
+
// A capsule wrapping several elements is a card — card-panel's job, not ours.
|
|
325
|
+
if (Array.from(node.children ?? []).filter((c) => textOf(c)).length >= 2)
|
|
326
|
+
continue;
|
|
327
|
+
push({
|
|
328
|
+
rule: "static-pill",
|
|
329
|
+
severity: "error",
|
|
330
|
+
message: `Badge pill ("${text.slice(0, 30)}") — a filled ${radius >= 9999 ? "fully-rounded" : `${Math.round(radius)}px`} capsule with padding around static text. One is still a badge.`,
|
|
331
|
+
where: label(node, "pill"),
|
|
332
|
+
fix: "Drop the capsule and set the words themselves: bigger, heavier, ALL-CAPS, or an accent color — or circle/underline them. The only legitimate pill tracks the spoken word (set_captions spotlight/karaoke). Mock social UI is exempt: mark it data-vf-mock-ui."
|
|
333
|
+
});
|
|
334
|
+
}
|
|
267
335
|
// ── Rule: card / panel / glassmorphism ─────────────────────────────────────
|
|
268
336
|
// A rounded box with a border, shadow, or frosted blur, holding more than one
|
|
269
337
|
// piece of content. A tight caption BAND is legal (small radius, one run) —
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
// to 2/1fps for long timelines) instead of the composition's native 30fps.
|
|
18
18
|
// Requested timestamps are snapped to that grid; the actually-captured time
|
|
19
19
|
// is reported next to each written PNG.
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
20
21
|
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
22
|
import os from "node:os";
|
|
22
23
|
import path from "node:path";
|
|
23
24
|
import { parseHTML } from "linkedom";
|
|
25
|
+
import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
24
26
|
import { inspectComposition } from "./composition-edit.js";
|
|
25
27
|
const MAX_DEFAULT_STILLS = 8;
|
|
26
28
|
const MAX_GRID_FRAMES = 600;
|
|
@@ -143,6 +145,62 @@ function stageCompositionProject(htmlPath, stagedHtml) {
|
|
|
143
145
|
}
|
|
144
146
|
return projectDir;
|
|
145
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Choose a near-square tile grid for N frames, biased to a wider-than-tall
|
|
150
|
+
* sheet (frames are usually portrait, so extra columns read better than extra
|
|
151
|
+
* rows on a screen).
|
|
152
|
+
*/
|
|
153
|
+
export function pickTileGrid(count) {
|
|
154
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
|
|
155
|
+
return { cols, rows: Math.max(1, Math.ceil(count / cols)) };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Tile the rendered stills into ONE contact sheet.
|
|
159
|
+
*
|
|
160
|
+
* This exists because reviewing a video frame-by-frame does not surface the
|
|
161
|
+
* defects that actually ship. Sequence-level drift — margins that shift scene
|
|
162
|
+
* to scene, three headline sizes, a wandering accent colour, N identical beat
|
|
163
|
+
* lengths, a jarring join — is only visible when the frames sit side by side,
|
|
164
|
+
* and an agent reading one tiled image sees it immediately while N separate
|
|
165
|
+
* image reads bury it. See the vidfarm skill's references/reviewing-renders.md.
|
|
166
|
+
*
|
|
167
|
+
* Frames are copied to sequential names first: globbing the output dir would
|
|
168
|
+
* pick up stills left over from a previous run at different timestamps.
|
|
169
|
+
*/
|
|
170
|
+
async function writeContactSheet(stills, outPath, tileWidth) {
|
|
171
|
+
const ffmpeg = await resolveFfmpeg();
|
|
172
|
+
const stageDir = mkdtempSync(path.join(os.tmpdir(), "vidfarm-sheet-"));
|
|
173
|
+
try {
|
|
174
|
+
stills.forEach((still, index) => {
|
|
175
|
+
copyFileSync(still.path, path.join(stageDir, `f${String(index + 1).padStart(4, "0")}.png`));
|
|
176
|
+
});
|
|
177
|
+
const { cols, rows } = pickTileGrid(stills.length);
|
|
178
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
179
|
+
const args = [
|
|
180
|
+
"-hide_banner", "-v", "error", "-y",
|
|
181
|
+
"-i", path.join(stageDir, "f%04d.png"),
|
|
182
|
+
"-vf", `scale=${tileWidth}:-2,tile=${cols}x${rows}:margin=8:padding=8:color=0x999999`,
|
|
183
|
+
"-frames:v", "1",
|
|
184
|
+
outPath
|
|
185
|
+
];
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
const child = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
188
|
+
let stderr = "";
|
|
189
|
+
child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
|
|
190
|
+
child.on("error", reject);
|
|
191
|
+
child.on("close", (code) => {
|
|
192
|
+
if (code === 0)
|
|
193
|
+
resolve();
|
|
194
|
+
else
|
|
195
|
+
reject(new Error(`ffmpeg tile failed (exit ${code})${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
return outPath;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
146
204
|
export async function renderCompositionStills(input) {
|
|
147
205
|
const html = readFileSync(input.htmlPath, "utf8");
|
|
148
206
|
if (!html.includes("data-composition-id=")) {
|
|
@@ -230,13 +288,19 @@ export async function renderCompositionStills(input) {
|
|
|
230
288
|
copyFileSync(path.join(framesDir, frameFiles[frameIndex]), dest);
|
|
231
289
|
stills.push({ requested_sec: Number(t.toFixed(3)), captured_sec: capturedSec, path: dest });
|
|
232
290
|
}
|
|
291
|
+
let sheet;
|
|
292
|
+
if (input.sheet && stills.length > 0) {
|
|
293
|
+
const sheetPath = path.resolve(input.sheetPath ?? path.join(outDir, "contact-sheet.png"));
|
|
294
|
+
sheet = await writeContactSheet(stills, sheetPath, Math.max(80, Math.round(input.sheetTileWidth ?? 320)));
|
|
295
|
+
}
|
|
233
296
|
return {
|
|
234
297
|
ok: true,
|
|
235
298
|
composition: input.htmlPath,
|
|
236
299
|
out_dir: outDir,
|
|
237
300
|
grid_fps: gridFps,
|
|
238
301
|
duration_seconds: durationSeconds,
|
|
239
|
-
stills
|
|
302
|
+
stills,
|
|
303
|
+
...(sheet ? { sheet } : {})
|
|
240
304
|
};
|
|
241
305
|
}
|
|
242
306
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@officexapp/vidfarm-devcli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.34",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|