Files
gstack/lib/motion-encode.ts
Garry Tanandcapy-ai[bot] c2e01e4ba7 v1.91.264.0 feat(design-motion): new /design-motion skill for showcase loops, adapted from Elaya's design-to-motion (GSTA-92 phase 2) (#3430)
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-motion): new /design-motion skill: showcase MP4 and GIF loops from a design, adapted from Elaya design-to-motion (GSTA-92 phase 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-motion): run design-motion-showcase in the marathon tier; the periodic census is at its 38-slice cap (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): review and QA fixes before ship

- encode: refuse unknown options and --formats tokens, validate --pad (#RRGGBB[AA])
  and --crop (compare too) so nothing but a colour or crop reaches ffmpeg's filter graph
- encode: export refuses a capture with missing window frames; check prints
  MOTION_SETTINGS for any threshold/phase override; contact sheet drops -fps_mode
- capture: portrait-aware scale, --cycle must be whole 60 fps frames, stills with
  CSS animations disabled, pauseAt tolerates slow loads, clears stale manifest/stills
- skill: <motion-dir> placeholder instead of a cross-block shell variable,
  GSTACK_FFMPEG-aware probe, stop points before the first capture, doctrine fixes
- tests: refusal rows, portrait scale, cycle guard

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-motion): capture-check-export pipeline on real Chromium and ffmpeg

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): second review cycle

- capture: refuse an --out that holds a still or frames but no capture.json
  instead of deleting them; --cycle must be a multiple of 0.05 s so the seam
  frame lands exactly one cycle later for every PRE (nearest value round-trips)
- encode: a capture without a mask ends 'MOTION_RESULT: clean (overflow not checked)'
- skill: Step 5 names window.__mark in the capture contract
- tests: foreign/previous --out handling on real Chromium, cycle guard and PRE sweep

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): third review cycle and adversarial findings

- capture: an ownership marker (.gstack-motion-capture) replaces the
  capture.json test, so the rerun after MOTION_CONTRACT_MISSING or a crash
  works and a foreign --out is still refused; offscreen lazy images are made
  eager and font/image loading is bounded at 30 s instead of hanging
- encode: refuse a capture.json whose fields are not what capture writes
  (they reach ffmpeg args and paths); a cards overflow with no story frames
  is a planning error, and a zero-frame job never passes
- tests: retry after a refused page, no-mask clean line, lazy image still,
  manifest validator, empty story span

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): mask coverage, manifest relations, Windows stop, Figma token off argv

- check fails overflow when the grown mask covers >90% of the frame (an app
  frame whose clip surfaces fill the stage passed vacuously); --mask-grow is
  0-32; --mask refuses braces and semicolons
- manifestError checks fps/windowFrames >= 1 and that the window fits the frames
- skill: native Windows stops before the first capture call; the Figma token
  reaches curl on stdin (-H @-) instead of argv
- tests: split the --out ownership scenario out of the contract test, full-frame
  mask, --still --scale 2 size and STILL_OK, manifest relations, coverage parse

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): bounded frames, atomic exports, full-bleed masks

- capture: each frame is bounded at 10 s (a __tick that never returns fails
  with MOTION_CAPTURE_FAILED instead of hanging); a --mask that matches no
  element stops with MOTION_MASK_EMPTY
- check: a grown mask covering >90% of the frame (full-bleed app frame)
  reports overflow as skipped and ends 'clean (overflow not checked)'
- export: writes .partial- files and renames on success, so a failed
  re-export keeps the last good file; a GIF fps that does not divide the
  cycle is refused; a missing GSTACK_FFMPEG override is named
- fidelity: URL source is untrusted data; full-bleed note
- tests: spinning __tick, empty mask, failed re-export, drifting gif fps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): app-only coverage skip, gif fps cap, early empty mask

- check: the >90% coverage skip applies only to an app frame's own surfaces
  (ungrown mask); in cards, or when --mask-grow pushes coverage over 90%,
  overflow fails with what to change (a cards capture with real overflow
  passed as 'clean (overflow not checked)' after a large grow)
- export: --gif-fps/--small-fps capped at 50 (GIF delays are 1/100 s)
- compare resolves its paths (a colon in a relative path read as a protocol)
- capture: MOTION_MASK_EMPTY is checked before the first frame
- tests: cards vs app full-frame mask, fps cap, no frames on an empty mask

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-motion): seek story transitions from their own frame

- capture: an animation first seen in a frame where window.__mark did not
  move (a story's class toggle) is seeked from that frame instead of the
  last mark, so it eases in the export instead of popping ~100 ms ahead
- encode: an empty numeric option is refused instead of read as 0
- figma: only fill a file key and node id that look like Figma's
- tests: class-toggled transition mid-fade at its midpoint, blank --min-db

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: PROJECT_STRUCTURE names Elaya's MIT material and licenses/MIT-elaya.txt

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 03:04:06 +00:00

269 lines
14 KiB
TypeScript

/**
* Encode and check arguments for /design-motion (bin/gstack-motion-encode).
* Pure: builds ffmpeg argument lists and parses ffmpeg's PSNR stats, so the
* recipes and verdicts are unit-tested without ffmpeg
* (test/motion-encode.test.ts).
*
* The three export recipes (MP4 for X, the website loop GIF, the 800x500 GIF)
* are Elaya's, with -y, -nostdin and quiet logging added so a non-interactive
* run never stops on an overwrite prompt. The checks compare frames with
* ffmpeg's psnr filter: identical frames report psnr_avg:inf.
*
* Adapted from Elaya's design-to-motion (MIT, (c) 2026 Elaya). See NOTICE.md.
*/
import * as path from 'path';
/** capture.json, written by bin/gstack-motion-capture.ts beside frames/. */
export interface CaptureManifest {
schema: 1;
target: string;
width: number;
height: number;
scale: number;
fps: number;
cycle: number;
pre: number;
windowStart: number;
windowFrames: number;
frames: number;
/** Page-clock milliseconds per frame (0 before __start). */
clockMs: number[];
/** window.__lt per frame, as the page reported it. */
loopTime: number[];
still: string;
mask?: string;
}
const QUIET = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y'];
/** capture.json fields reach ffmpeg arguments and file paths, so a manifest this tool did not write is refused. */
export function manifestError(m: unknown): string | null {
const o = (m ?? {}) as Record<string, unknown>;
if (o.schema !== 1) return 'capture.json is not a schema 1 capture manifest';
for (const k of ['fps', 'pre', 'windowStart', 'windowFrames', 'frames']) {
if (!Number.isInteger(o[k]) || (o[k] as number) < 0) return `capture.json field ${k} must be a non-negative integer`;
}
if ((o.fps as number) < 1 || (o.windowFrames as number) < 1) return 'capture.json fields fps and windowFrames must be at least 1';
if ((o.windowStart as number) + (o.windowFrames as number) > (o.frames as number)) return 'capture.json window runs past its frames (windowStart + windowFrames > frames)';
if (typeof o.cycle !== 'number' || !(o.cycle > 0)) return 'capture.json field cycle must be a positive number';
if (!Array.isArray(o.clockMs) || o.clockMs.length !== o.frames || !o.clockMs.every(t => Number.isFinite(t))) return 'capture.json field clockMs must hold one number per frame';
for (const k of ['still', 'mask']) {
const v = o[k];
if (v === undefined && k === 'mask') continue;
if (typeof v !== 'string' || !/^[\w-]+\.png$/.test(v)) return `capture.json field ${k} must be a .png file name inside the capture dir`;
}
return null;
}
export function framePattern(dir: string): string {
return path.join(dir, 'frames', '%05d.png');
}
export function framePath(dir: string, i: number): string {
return path.join(dir, 'frames', `${String(i).padStart(5, '0')}.png`);
}
/** <name>-x.mp4: H.264, yuv420p, 60 fps, CRF 15, preset slow, faststart, even dimensions. */
export function mp4Args(m: CaptureManifest, dir: string, out: string, crf = 15): string[] {
return [...QUIET, '-start_number', String(m.windowStart), '-framerate', String(m.fps), '-i', framePattern(dir),
'-frames:v', String(m.windowFrames), '-vf', 'crop=trunc(iw/2)*2:trunc(ih/2)*2',
'-c:v', 'libx264', '-preset', 'slow', '-crf', String(crf), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out];
}
export interface GifOptions {
fps?: number;
/** ffmpeg crop=w:h:x:y in capture pixels, applied before scaling (the 800x500 content crop). */
crop?: string;
/** Pad colour for the 800x500 fit, the page background. */
pad?: string;
}
const PALETTE = 'split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle';
function gifArgs(m: CaptureManifest, dir: string, out: string, chain: string[]): string[] {
const filter = [`trim=end_frame=${m.windowFrames}`, ...chain, PALETTE].join(',');
return [...QUIET, '-start_number', String(m.windowStart), '-framerate', String(m.fps), '-i', framePattern(dir),
'-filter_complex', filter, '-loop', '0', out];
}
/** <name>-loop.gif: 960 px wide, 20 fps by default, for the website. */
export function loopGifArgs(m: CaptureManifest, dir: string, out: string, o: GifOptions = {}): string[] {
return gifArgs(m, dir, out, [`fps=${o.fps ?? 20}`, ...(o.crop ? [`crop=${o.crop}`] : []), 'scale=960:-2:flags=lanczos']);
}
/** <name>-800x500.gif: cropped to content or fitted and padded with the page background. */
export function smallGifArgs(m: CaptureManifest, dir: string, out: string, o: GifOptions = {}): string[] {
const pad = (o.pad ?? '#ffffff').replace(/^#/, '0x');
return gifArgs(m, dir, out, [`fps=${o.fps ?? 20}`, ...(o.crop ? [`crop=${o.crop}`] : []),
'scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos', `pad=800:500:(ow-iw)/2:(oh-ih)/2:color=${pad}`]);
}
/** A crop must be w:h:x:y in whole pixels; for the 800x500 GIF (ratio) it must also be 1.6:1, one pixel of rounding allowed. */
export function cropError(crop: string, o: { ratio?: boolean } = { ratio: true }): string | null {
const m = /^(\d+):(\d+):(\d+):(\d+)$/.exec(crop);
if (!m) return `--crop must be w:h:x:y in capture pixels, got '${crop}'`;
if (!o.ratio) return null;
const w = Number(m[1]);
const h = Number(m[2]);
if (Math.abs(w - h * 1.6) > 1) return `--crop ${w}x${h} is not 1.6:1 (for height ${h} use width ${Math.round(h * 1.6)})`;
return null;
}
/** The 800x500 pad colour goes into the filter graph, so it must be a plain #RRGGBB[AA] hex colour (ffmpeg rejects #RGB). */
export function padError(pad: string): string | null {
return /^#?(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(pad) ? null : `--pad must be a hex colour such as '#f4f1ea', got '${pad}'`;
}
/**
* PSNR of a run of frames against one reference image. With a mask, pixels the
* mask (grown by maskGrow pixels) marks white are blacked out on both sides,
* so only the area outside the containers is compared. The reference and mask
* are single images; the psnr and blend filters repeat their last frame.
*/
export function psnrArgs(o: { first: string; start?: number; count: number; reference: string; mask?: string; maskGrow?: number }): string[] {
const seq = o.start !== undefined;
const inputs = [...(seq ? ['-start_number', String(o.start)] : []), '-i', o.first, '-i', o.reference, ...(o.mask ? ['-i', o.mask] : [])];
const head = `[0:v]trim=end_frame=${o.count},format=gbrp[f];[1:v]format=gbrp[r]`;
let graph = `${head};[f][r]psnr=stats_file=-`;
if (o.mask) {
const grow = Array.from({ length: Math.max(0, o.maskGrow ?? 2) }, () => 'dilation').join(',');
const blackout = "blend=all_expr='if(gt(B,127),0,A)'";
graph = `${head};[2:v]format=gray${grow ? `,${grow}` : ''},format=gbrp,split[m1][m2];`
+ `[f][m1]${blackout}[fa];[r][m2]${blackout}[ra];[fa][ra]psnr=stats_file=-`;
}
return [...QUIET, ...inputs, '-filter_complex', graph, '-f', 'null', '-'];
}
/** Mean brightness of the grown mask, printed as lavfi.signalstats.YAVG (0-255), so check can refuse a mask that covers the frame. */
export function maskCoverageArgs(mask: string, maskGrow = 2): string[] {
const grow = Array.from({ length: maskGrow }, () => 'dilation');
return [...QUIET, '-i', mask, '-vf', ['format=gray', ...grow, 'signalstats', 'metadata=print:key=lavfi.signalstats.YAVG:file=-'].join(','), '-f', 'null', '-'];
}
/** Fraction of the frame the mask covers, from maskCoverageArgs output; null when ffmpeg printed no YAVG. */
export function parseMaskCoverage(text: string): number | null {
const m = /lavfi\.signalstats\.YAVG=([\d.]+)/.exec(text);
return m ? Number(m[1]) / 255 : null;
}
/** PSNR of two same-size images, optionally cropped and lightly blurred first (the still-fidelity check). */
export function compareArgs(a: string, b: string, o: { blur?: number; crop?: string } = {}): string[] {
const chain = (i: number) => [`[${i}:v]format=gbrp`, ...(o.crop ? [`crop=${o.crop}`] : []), ...(o.blur ? [`gblur=sigma=${o.blur}`] : [])].join(',');
return [...QUIET, '-i', a, '-i', b, '-filter_complex', `${chain(0)}[a];${chain(1)}[b];[a][b]psnr=stats_file=-`, '-f', 'null', '-'];
}
export interface PsnrFrame { n: number; psnr: number }
/** Parse psnr=stats_file=- lines (n:1 mse_avg:... psnr_avg:inf ...). inf becomes Infinity. */
export function parsePsnrStats(text: string): PsnrFrame[] {
const frames: PsnrFrame[] = [];
for (const line of text.split('\n')) {
const n = /(?:^|\s)n:(\d+)/.exec(line);
const p = /psnr_avg:(inf|-?\d+(?:\.\d+)?)/.exec(line);
if (!n || !p) continue;
frames.push({ n: Number(n[1]), psnr: p[1] === 'inf' ? Infinity : Number(p[1]) });
}
return frames;
}
export type CheckId = 'seam' | 'empty-beat' | 'rest' | 'overflow';
export interface CheckJob {
id: CheckId;
/** What is compared, for the report line. */
what: string;
args: string[];
/** Frames the job must report; fewer means ffmpeg stopped early. */
expected: number;
}
export interface CheckOptions {
/** Loop time (s) the dissolve starts; the rest check uses the last frame before it. */
dissolveAt: number;
/** Loop time (s) the empty beat starts. */
emptyFrom: number;
/** Loop time (s) the intro has landed; the cards overflow check starts here. */
introEnd: number;
layout: 'app' | 'cards';
maskGrow: number;
}
/** Window frame indices whose page clock (mod cycle) lies in [fromMs, toMs). */
export function framesBetween(m: CaptureManifest, fromMs: number, toMs: number): number[] {
const cycleMs = Math.round(m.cycle * 1000);
const out: number[] = [];
for (let i = m.windowStart + 1; i < m.windowStart + m.windowFrames; i++) {
const t = m.clockMs[i]! % cycleMs;
if (t >= fromMs && t < toMs) out.push(i);
}
return out;
}
/** The ffmpeg jobs for the seam, empty-beat, rest and (with a mask) overflow checks. */
export function planChecks(m: CaptureManifest, dir: string, o: CheckOptions): CheckJob[] | { error: string } {
const cycleMs = Math.round(m.cycle * 1000);
const ms = (s: number) => Math.round(s * 1000);
if (!(o.introEnd < o.dissolveAt && o.dissolveAt <= o.emptyFrom && o.emptyFrom < m.cycle)) {
return { error: `expected intro-end < dissolve-at <= empty-from < cycle (got ${o.introEnd} / ${o.dissolveAt} / ${o.emptyFrom} / ${m.cycle})` };
}
const seamB = m.windowStart + m.windowFrames;
if (seamB >= m.frames) return { error: `the capture stops at frame ${m.frames - 1}; the seam check needs frame ${seamB} (recapture with --tail 10)` };
const empty = framesBetween(m, ms(o.emptyFrom), cycleMs);
const beforeDissolve = framesBetween(m, 0, ms(o.dissolveAt));
const rest = beforeDissolve[beforeDissolve.length - 1];
if (!empty.length || rest === undefined) return { error: 'the window holds no empty-beat or pre-dissolve frame; check --cycle against the page' };
const start = path.join(dir, 'frames', '%05d.png');
const empty0 = framePath(dir, m.windowStart);
const still = path.join(dir, m.still);
const jobs: CheckJob[] = [
{ id: 'seam', what: `frame ${m.windowStart} vs ${seamB}`, args: psnrArgs({ first: framePath(dir, m.windowStart), count: 1, reference: framePath(dir, seamB) }), expected: 1 },
{ id: 'empty-beat', what: `frames ${empty[0]}-${empty[empty.length - 1]} vs ${m.windowStart}`, args: psnrArgs({ first: start, start: empty[0], count: empty.length, reference: empty0 }), expected: empty.length },
{ id: 'rest', what: `frame ${rest} vs ${m.still}`, args: psnrArgs({ first: framePath(dir, rest), count: 1, reference: still }), expected: 1 },
];
if (m.mask) {
const mask = path.join(dir, m.mask);
const span = o.layout === 'app'
? { from: m.windowStart, count: m.windowFrames, reference: empty0, label: `every window frame vs ${m.windowStart}` }
: (() => {
const story = framesBetween(m, ms(o.introEnd), ms(o.dissolveAt));
if (story.length === 0) return null;
return { from: story[0]!, count: story.length, reference: still, label: `story frames ${story[0]}-${story[story.length - 1]} vs ${m.still}` };
})();
if (!span) return { error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' };
jobs.push({ id: 'overflow', what: `${span.label}, outside the mask`, args: psnrArgs({ first: start, start: span.from, count: span.count, reference: span.reference, mask, maskGrow: o.maskGrow }), expected: span.count });
}
return jobs;
}
export interface CheckVerdict { id: CheckId; pass: boolean; line: string }
/** One verdict per job: pass when ffmpeg reported every frame and each is at or above minDb (Infinity = identical). */
export function judgeCheck(job: CheckJob, frames: PsnrFrame[], minDb = Infinity): CheckVerdict {
if (job.expected === 0 || frames.length < job.expected) {
return { id: job.id, pass: false, line: `MOTION_CHECK: ${job.id} fail ffmpeg compared ${frames.length} of ${job.expected} frames (${job.what})` };
}
const bad = frames.filter(f => f.psnr < minDb);
if (bad.length === 0) return { id: job.id, pass: true, line: `MOTION_CHECK: ${job.id} pass (${job.what})` };
const worst = bad.reduce((a, b) => (b.psnr < a.psnr ? b : a));
return { id: job.id, pass: false, line: `MOTION_CHECK: ${job.id} fail ${bad.length} frame(s) differ, worst ${worst.psnr.toFixed(2)} dB at compared frame ${worst.n} (${job.what})` };
}
/** Contact sheet: twelve frames spread across the window, tiled 4x3 at 480 px. */
export function contactSheetArgs(m: CaptureManifest, dir: string, out: string): string[] {
const picks = Array.from({ length: 12 }, (_, k) => Math.round((k * (m.windowFrames - 1)) / 11));
const select = picks.map(p => `eq(n\\,${p})`).join('+');
return [...QUIET, '-start_number', String(m.windowStart), '-i', framePattern(dir),
'-vf', `select='${select}',scale=480:-2,tile=4x3:padding=8:color=0x808080`, '-frames:v', '1', out];
}
export const FFMPEG_MISSING = 'FFMPEG_MISSING: ffmpeg is not installed or not on PATH (set GSTACK_FFMPEG to its full path). '
+ 'Install it: macOS "brew install ffmpeg"; Debian/Ubuntu "sudo apt-get install -y ffmpeg"; Fedora "sudo dnf install ffmpeg"; '
+ 'Windows "winget install Gyan.FFmpeg". Then rerun.';
/** GSTACK_FFMPEG when it names an existing file, else ffmpeg on PATH, else null. */
export function resolveFfmpeg(env: Record<string, string | undefined>, which: (bin: string) => string | null, exists: (p: string) => boolean): string | null {
const override = env.GSTACK_FFMPEG?.trim();
if (override) return exists(override) ? override : null;
return which('ffmpeg');
}