diff --git a/CHANGELOG.md b/CHANGELOG.md index d69235222..b9709f848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ - **/design-motion** (new skill; sections `fidelity`, `motion`, and `figma` read only with a Figma token). Showcase context: every visual gets a story, the cycle ends in a dissolve and an empty beat. Website context: gstack's rules win, so one authored motion moment per page, content complete without script and under reduced motion, an `IntersectionObserver` start, and no pulse, marquee or caret left running. - **`bin/gstack-motion-capture.ts`** (`lib/motion-capture.ts`): Playwright with its fake clock and gstack's own Chromium (`GSTACK_CHROMIUM_PATH` honoured). Steps are whole milliseconds averaging 1000/60, so the seam frame is exactly one cycle after the window start. The page contract is `window.__start()`, `window.__tick(dt)` and `window.__lt`, with 15 PRE frames before the start. Writes `frames/`, `still.png`, an optional container `mask.png` and `capture.json`. - **`bin/gstack-motion-encode`** (`lib/motion-encode.ts`): `check` prints `MOTION_CHECK:` lines for seam, empty beat, rest and overflow (ffmpeg PSNR, identical means `inf`), writes a contact sheet, and ends `MOTION_RESULT: clean` or ` failed`. `export` writes `-x.mp4` (H.264, CRF 15, 60 fps), `-loop.gif` (960 px, 20 fps) and `-800x500.gif` with Elaya's recipes. `compare` gives the still-fidelity PSNR. ffmpeg is optional: without it the bin prints `FFMPEG_MISSING` with install commands and exits 3. -- Both bins refuse bad input before any work: `gstack-motion-encode` exits 2 on an unknown option or `--formats` token, a `--pad` that is not a 6- or 8-digit hex colour, a `--crop` that is not `w:h:x:y` (`compare` included, so nothing but a colour or a crop reaches ffmpeg's filter graph) and a capture whose frames are missing. `check` prints a `MOTION_SETTINGS:` line naming any threshold or phase override, so a relaxed run never looks like a strict one, a capture without a mask ends `MOTION_RESULT: clean (overflow not checked)`, and the contact sheet no longer needs ffmpeg 5.1's `-fps_mode`. `gstack-motion-capture.ts` refuses a `--cycle` that is not a multiple of 0.05 s (three 60 fps frames; only then does the seam land exactly one cycle later for every PRE) and an `--out` that holds a still or frames but no `capture.json`. A capture now clears the previous run's manifest and stills along with its frames, scales a portrait frame to 1200×1920, renders stills with CSS animations finished, and tolerates a page that takes longer than 10 s to load. +- Both bins refuse bad input before any work: `gstack-motion-encode` exits 2 on an unknown option or `--formats` token, a `--pad` that is not a 6- or 8-digit hex colour, a `--crop` that is not `w:h:x:y` (`compare` included, so nothing but a colour or a crop reaches ffmpeg's filter graph) and a capture whose frames are missing. `check` prints a `MOTION_SETTINGS:` line naming any threshold or phase override, so a relaxed run never looks like a strict one, a capture without a mask ends `MOTION_RESULT: clean (overflow not checked)`, and the contact sheet no longer needs ffmpeg 5.1's `-fps_mode`. `gstack-motion-capture.ts` refuses a `--cycle` that is not a multiple of 0.05 s (three 60 fps frames; only then does the seam land exactly one cycle later for every PRE) and an `--out` that holds a still or frames but not its `.gstack-motion-capture` marker, so a rerun after a failed capture works and a foreign directory is never cleared. `check` and `export` refuse a `capture.json` whose fields are not what the capture writes (they reach ffmpeg arguments and paths), and a cards overflow check with no story frames is an error instead of a pass. Offscreen `loading="lazy"` images no longer hang a render, and font or image loading is bounded at 30 s. A capture now clears the previous run's manifest and stills along with its frames, scales a portrait frame to 1200×1920, renders stills with CSS animations finished, and tolerates a page that takes longer than 10 s to load. ### Tests - `test/motion-capture.test.ts`, `test/motion-encode.test.ts`: step and clock math (the seam frame lands exactly one cycle later for every PRE and cycle tried), argument errors, the exact export recipes, check frame selection for both layouts, PSNR parsing and verdicts (fewer compared frames than planned fails), and the `FFMPEG_MISSING` path, all without a browser, ffmpeg or network. -- `test/motion-pipeline.test.ts`: a 0.5 s fixture loop goes through capture, check, export and compare on real Chromium and ffmpeg, a page that does not loop and a page without the capture contract fail with their named reasons, and the same non-looping capture checks clean under `--min-db 0` only with that override printed. It skips itself when Chromium, ffmpeg or ffprobe is missing. The motion unit tests add the new refusals and a portrait scale. +- `test/motion-pipeline.test.ts`: a 0.5 s fixture loop goes through capture, check, export and compare on real Chromium and ffmpeg, a page that does not loop and a page without the capture contract fail with their named reasons, and the same non-looping capture checks clean under `--min-db 0` only with that override printed. The same `--out` is refused while it holds foreign files, accepts the retry after a refused page, and reports `clean (overflow not checked)` without a mask; a page with an offscreen lazy image renders its still. It skips itself when Chromium, ffmpeg or ffprobe is missing. The motion unit tests add the new refusals and a portrait scale. - New paid case `design-motion-showcase` (`test/skill-e2e-design.test.ts`): a finalized page goes through rebuild, capture, checks and export, and the agent must read the fidelity and motion sections and run `check` before `export`. The design-motion carve guard points at it. It runs in the marathon tier as a 3-trial behavior panel, because the periodic census is at its 38-slice cap and three 3-6 minute trials would add two slices; it needs ffmpeg and Chromium on the runner. Its section-read and ordering checks accept a full `cat` of a section and capture, check and export in one Bash call (a free replay of the first panel's three captures passes; a `grep` of a section, no `check`, and export before check still fail). ## [1.91.225.0] - 2026-10-26 diff --git a/bin/gstack-motion-capture.ts b/bin/gstack-motion-capture.ts index 6f571ce52..6ca32e129 100755 --- a/bin/gstack-motion-capture.ts +++ b/bin/gstack-motion-capture.ts @@ -61,10 +61,14 @@ async function openPage(mode: 'capture' | 'still', scale: number) { const page = await context.newPage(); await page.clock.install({ time: 0 }); await page.goto(withMode(url, mode), { waitUntil: 'load' }); - await page.evaluate(async () => { + const settled = page.evaluate(async () => { await document.fonts.ready; + for (const img of document.images) img.loading = 'eager'; await Promise.all([...document.images].map(img => img.decode().catch(() => undefined))); - }); + }).then(() => true); + if (!await Promise.race([settled, new Promise(resolve => setTimeout(resolve, 30_000, false).unref())])) { + console.error('gstack-motion-capture: fonts or images were still loading after 30 s; rendering anyway'); + } await page.clock.pauseAt(Math.max(10_000, await page.evaluate(() => Date.now()) + 1_000)); return page; } @@ -79,13 +83,14 @@ try { const out = path.resolve(opts.out!); const scale = captureScale(opts.width, opts.height); const plan = planCapture({ cycleSeconds: opts.cycle!, pre: opts.pre, tail: opts.tail }); - const stale = ['frames', 'capture.json', 'still.png', 'mask.png', 'contact-sheet.png']; - const present = stale.filter(f => fs.existsSync(path.join(out, f))); - if (present.length > 0 && !present.includes('capture.json')) { - die(2, `gstack-motion-capture: ${out} already holds ${present.join(', ')} but no capture.json, so it is not a previous capture; pick an empty --out`); + const marker = path.join(out, '.gstack-motion-capture'); + const present = ['frames', 'capture.json', 'still.png', 'mask.png', 'contact-sheet.png'].filter(f => fs.existsSync(path.join(out, f))); + if (present.length > 0 && !fs.existsSync(marker)) { + die(2, `gstack-motion-capture: ${out} already holds ${present.join(', ')} but no .gstack-motion-capture marker, so this tool did not write it; pick an empty --out`); } for (const f of present) fs.rmSync(path.join(out, f), { recursive: true, force: true }); fs.mkdirSync(path.join(out, 'frames'), { recursive: true }); + fs.writeFileSync(marker, ''); const page = await openPage('capture', scale); const contract = await page.evaluate(() => typeof (window as any).__start === 'function' && typeof (window as any).__tick === 'function'); if (!contract) die(1, `MOTION_CONTRACT_MISSING: ${opts.target}?capture does not expose window.__start() and window.__tick(dt); add the capture mode before recording`); diff --git a/bin/gstack-motion-encode b/bin/gstack-motion-encode index 112a749a3..00b1c7ce9 100755 --- a/bin/gstack-motion-encode +++ b/bin/gstack-motion-encode @@ -28,7 +28,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { - compareArgs, contactSheetArgs, cropError, framePath, judgeCheck, loopGifArgs, mp4Args, padError, parsePsnrStats, planChecks, resolveFfmpeg, + compareArgs, contactSheetArgs, cropError, framePath, judgeCheck, loopGifArgs, manifestError, mp4Args, padError, parsePsnrStats, planChecks, resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, } from '../lib/motion-encode'; @@ -97,6 +97,8 @@ try { } catch (err) { die(2, `gstack-motion-encode: cannot read ${path.join(dir, 'capture.json')}: ${(err as Error).message}; capture first with gstack-motion-capture.ts`); } +const manifestProblem = manifestError(manifest); +if (manifestProblem) die(2, `gstack-motion-encode: ${manifestProblem}; capture again with gstack-motion-capture.ts`); if (cmd === 'check') { const layout = flags.get('--layout') ?? 'cards'; diff --git a/lib/motion-encode.ts b/lib/motion-encode.ts index 2e7df3fd9..e75df64df 100644 --- a/lib/motion-encode.ts +++ b/lib/motion-encode.ts @@ -36,6 +36,23 @@ export interface CaptureManifest { 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; + 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 (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'); } @@ -195,8 +212,10 @@ export function planChecks(m: CaptureManifest, dir: string, o: CheckOptions): Ch ? { 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; @@ -206,7 +225,7 @@ 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 (frames.length < job.expected) { + 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); diff --git a/test/motion-encode.test.ts b/test/motion-encode.test.ts index e0a112b60..73c69cd84 100644 --- a/test/motion-encode.test.ts +++ b/test/motion-encode.test.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { clockMs, planCapture } from '../lib/motion-capture'; import { - compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, mp4Args, padError, parsePsnrStats, planChecks, psnrArgs, + compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, mp4Args, padError, parsePsnrStats, planChecks, psnrArgs, resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions, } from '../lib/motion-encode'; @@ -157,6 +157,30 @@ describe('PSNR parsing and verdicts', () => { const v = judgeCheck({ ...job, expected: 23 }, parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf'))); expect(v.pass).toBe(false); expect(v.line).toContain('compared 3 of 23 frames'); + expect(judgeCheck({ ...job, expected: 0 }, []).pass).toBe(false); + }); + + // Value: protects=phase overrides that leave no story frame refuse the check instead of passing overflow on zero frames; + // fails_when=the empty-span guard is dropped and overflow reports pass for 'story frames undefined-undefined'; why_new=new guard; seam=none; + // no_claim=planning only + test('a cards overflow with no story frames is a planning error', () => { + const r = planChecks(manifest(), '/c', { ...OPTS, introEnd: 10.99, dissolveAt: 11 }); + expect(r).toEqual({ error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' }); + }); + + // Value: protects=a capture.json this tool did not write cannot put text into ffmpeg filter graphs or paths outside the capture dir; + // fails_when=a field check is dropped and a string windowFrames or a ../ still reaches the encoder; why_new=new validator; seam=none; + // no_claim=validator only; the bin calls it before building any job + test('a capture manifest is checked before its fields reach ffmpeg', () => { + expect(manifestError(manifest())).toBeNull(); + expect(manifestError(manifest(12, false))).toBeNull(); + expect(manifestError({ ...manifest(), schema: 2 })).toContain('schema 1'); + expect(manifestError({ ...manifest(), windowFrames: '60,metadata=mode=print' })).toContain('windowFrames must be a non-negative integer'); + expect(manifestError({ ...manifest(), cycle: 0 })).toContain('cycle must be a positive number'); + expect(manifestError({ ...manifest(), clockMs: [0] })).toContain('one number per frame'); + expect(manifestError({ ...manifest(), still: '../still.png' })).toContain('still must be a .png file name'); + expect(manifestError({ ...manifest(), mask: 'mask.png,hue=s=0' })).toContain('mask must be a .png file name'); + expect(manifestError(null)).toContain('schema 1'); }); }); diff --git a/test/motion-pipeline.test.ts b/test/motion-pipeline.test.ts index 7a70dd3fc..7d8c62ea6 100644 --- a/test/motion-pipeline.test.ts +++ b/test/motion-pipeline.test.ts @@ -53,10 +53,10 @@ function run(argv: string[]) { return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; } -function capture(dir: string, html: string) { +function capture(dir: string, html: string, o: { mask?: boolean } = {}) { const file = path.join(dir, 'page.html'); fs.writeFileSync(file, html); - return run([CAPTURE, file, '--size', '640x100', '--cycle', '0.5', '--out', path.join(dir, 'cap'), '--mask', '.card']); + return run([CAPTURE, file, '--size', '640x100', '--cycle', '0.5', '--out', path.join(dir, 'cap'), ...(o.mask === false ? [] : ['--mask', '.card'])]); } describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () => { @@ -129,8 +129,8 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () // Value: protects=a page without window.__start/__tick stops with MOTION_CONTRACT_MISSING and exit 1 before writing capture.json; // fails_when=the contract probe is dropped and frames of an unanimated page are recorded as a capture; why_new=no test loads a page in the capture bin; seam=none; // no_claim=only the missing-contract case; a contract that throws mid-capture is not exercised - // The same capture dir, pre-filled first with a foreign still.png and then with a previous capture's files, checks that - // a capture refuses a dir that is not a previous capture and clears a previous capture's manifest before it can fail. + // The same --out also checks ownership: a foreign still.png is refused and kept, the retry after a refused page succeeds + // (here without a mask, so check ends 'clean (overflow not checked)'), and a later failed capture clears that manifest. test('a page without the capture contract is refused', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-')); try { @@ -139,15 +139,42 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () fs.writeFileSync(path.join(cap, 'still.png'), 'not ours'); const foreign = capture(dir, page({ loops: true, contract: false })); expect(foreign.code).toBe(2); - expect(foreign.err).toContain('already holds still.png but no capture.json'); + expect(foreign.err).toContain('already holds still.png but no .gstack-motion-capture marker'); expect(fs.readFileSync(path.join(cap, 'still.png'), 'utf-8')).toBe('not ours'); - fs.writeFileSync(path.join(cap, 'capture.json'), '{}'); + fs.rmSync(path.join(cap, 'still.png')); + const c = capture(dir, page({ loops: true, contract: false })); expect(c.code).toBe(1); expect(c.err).toContain('MOTION_CONTRACT_MISSING'); + const retry = capture(dir, page({ loops: true }), { mask: false }); + expect(retry.code, retry.err).toBe(0); + expect(retry.out).toContain('CAPTURE_OK frames=54'); + const check = run([ENCODE, 'check', cap, ...PHASES]); + expect(check.code, check.out + check.err).toBe(0); + expect(check.out).toContain('MOTION_CHECK: overflow skipped'); + expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)'); + + expect(capture(dir, page({ loops: true, contract: false })).code).toBe(1); expect(fs.existsSync(path.join(cap, 'capture.json')) || fs.existsSync(path.join(cap, 'still.png'))).toBe(false); } finally { fs.rmSync(dir, { recursive: true, force: true }); } - }, 120_000); + }, 240_000); + + // Value: protects=an offscreen loading=lazy image no longer hangs the still render (img.decode() never settles on it); + // fails_when=the eager switch and the 30 s bound are both dropped and --still waits forever; why_new=adversarial probe hung at 90 s; seam=none; + // no_claim=one lazy image on a local page; a slow network image is bounded only by the 30 s timer + test('a page with an offscreen lazy image renders its still', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-')); + try { + fs.writeFileSync(path.join(dir, 'dot.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64')); + const file = path.join(dir, 'lazy.html'); + fs.writeFileSync(file, '
'); + const still = run([CAPTURE, file, '--size', '640x100', '--still', path.join(dir, 'still.png')]); + expect(still.code, still.err).toBe(0); + expect(still.err).not.toContain('still loading'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 200_000); });