From 078fb60583da33d6a7352a1dbb4513f9e56a095b Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 02:02:24 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 6 ++-- bin/gstack-motion-encode | 14 ++++++-- design-motion/SKILL.md | 2 +- design-motion/SKILL.md.tmpl | 2 +- design-motion/sections/figma.md | 4 +-- design-motion/sections/figma.md.tmpl | 4 +-- lib/motion-capture.ts | 1 + lib/motion-encode.ts | 14 ++++++++ test/motion-capture.test.ts | 1 + test/motion-encode.test.ts | 11 +++++- test/motion-pipeline.test.ts | 50 ++++++++++++++++++++++++---- 11 files changed, 91 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9709f848..fc7414baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,14 @@ **/design-motion turns an approved design into a showcase loop for X and the website; adapted from Elaya's design-to-motion (GSTA-92 plan item 15, Garry 2026-10-25 "Do it").** gstack could design a screen and build it, but not show it moving: a screen recording drops frames, jumps at the loop seam, and nothing tells you a card drifted out of its container. /design-motion rebuilds the exact frame from a /design-shotgun PNG, a /design-html `finalized.html` or a URL (a Figma link when `FIGMA_TOKEN` is set), loads it from an empty state, gives each visual one story, records frames on a fake clock, and exports only after the loop is proven seamless. ### Added -- **/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. +- **/design-motion** (new skill; sections `fidelity`, `motion`, and `figma` read only with a Figma token, which reaches curl on stdin so it never appears in a process list). 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 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. +- 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. Overflow also fails when the grown mask covers more than 90% of the frame (an app frame whose clip surfaces fill the stage used to pass by blacking out everything), `--mask-grow` is a whole number from 0 to 32, and `--mask` refuses anything but a selector. 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. 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. +- `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 at `--scale 2`; a mask that covers the frame fails overflow. 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-encode b/bin/gstack-motion-encode index 00b1c7ce9..f34f0bb4a 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, manifestError, mp4Args, padError, parsePsnrStats, planChecks, resolveFfmpeg, + compareArgs, contactSheetArgs, cropError, framePath, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, } from '../lib/motion-encode'; @@ -103,17 +103,27 @@ if (manifestProblem) die(2, `gstack-motion-encode: ${manifestProblem}; capture a if (cmd === 'check') { const layout = flags.get('--layout') ?? 'cards'; if (layout !== 'app' && layout !== 'cards') die(2, '--layout must be app or cards'); + const maskGrow = num('--mask-grow', 2); + if (!Number.isInteger(maskGrow) || maskGrow > 32) die(2, `gstack-motion-encode: --mask-grow must be a whole number of pixels from 0 to 32, got '${flags.get('--mask-grow')}'`); const jobs = planChecks(manifest, dir, { dissolveAt: num('--dissolve-at', manifest.cycle - 1.0), emptyFrom: num('--empty-from', manifest.cycle - 0.4), introEnd: num('--intro-end', 2.4), layout, - maskGrow: num('--mask-grow', 2), + maskGrow, }); if ('error' in jobs) die(2, `gstack-motion-encode: ${jobs.error}`); const minDb = flags.has('--min-db') ? num('--min-db', 0) : Infinity; let failed = 0; for (const job of jobs) { + if (job.id === 'overflow') { + const coverage = parseMaskCoverage(String(run(maskCoverageArgs(path.join(dir, manifest.mask!), maskGrow)).stdout)); + if (coverage === null || coverage > 0.9) { + failed++; + console.log(`MOTION_CHECK: overflow fail ${coverage === null ? 'the mask coverage could not be measured' : `the mask covers ${Math.round(coverage * 100)}% of the frame, so movement outside it cannot be seen`}; mark only the clip containers with data-motion-clip`); + continue; + } + } const r = run(job.args); if (r.status !== 0) { failed++; diff --git a/design-motion/SKILL.md b/design-motion/SKILL.md index b2c430b3a..8e050e746 100644 --- a/design-motion/SKILL.md +++ b/design-motion/SKILL.md @@ -405,7 +405,7 @@ _FINALIZED=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/finalized.html Nothing found and nothing named: ask once which design to animate, offering /design-shotgun or /design-html to make one. -`BROWSER_UNAVAILABLE`: nothing can render; give its fix line, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page, render its Step 3 still for a visual check, and stop. +`BROWSER_UNAVAILABLE` or native Windows (capture needs WSL, macOS, Linux): nothing renders; give the fix, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`), build the page, render its Step 3 still for a visual check, and stop. Set up the run directory: diff --git a/design-motion/SKILL.md.tmpl b/design-motion/SKILL.md.tmpl index 0c7ba266f..818c38ddf 100644 --- a/design-motion/SKILL.md.tmpl +++ b/design-motion/SKILL.md.tmpl @@ -74,7 +74,7 @@ _FINALIZED=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/finalized.html Nothing found and nothing named: ask once which design to animate, offering /design-shotgun or /design-html to make one. -`BROWSER_UNAVAILABLE`: nothing can render; give its fix line, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page, render its Step 3 still for a visual check, and stop. +`BROWSER_UNAVAILABLE` or native Windows (capture needs WSL, macOS, Linux): nothing renders; give the fix, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`), build the page, render its Step 3 still for a visual check, and stop. Set up the run directory: diff --git a/design-motion/sections/figma.md b/design-motion/sections/figma.md index 012b4238a..5265666e3 100644 --- a/design-motion/sections/figma.md +++ b/design-motion/sections/figma.md @@ -6,8 +6,8 @@ Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the s ```bash _FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}" -curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files//nodes?ids=&geometry=paths" -o "/figma-nodes.json" -curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/?ids=&format=png&scale=2" -o "/figma-ref.json" +printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/files//nodes?ids=&geometry=paths" -o "/figma-nodes.json" +printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/images/?ids=&format=png&scale=2" -o "/figma-ref.json" ``` The nodes response gives the tree, bounds, fills, effects and radii; the images response gives a URL for the **reference frame**, saved as `reference.png`. Every layer position is its absolute bounds minus the frame's origin. Figma's image CDN sometimes resets connections: retry each download and check each PNG's size before using it. diff --git a/design-motion/sections/figma.md.tmpl b/design-motion/sections/figma.md.tmpl index 8fef33524..56aa71027 100644 --- a/design-motion/sections/figma.md.tmpl +++ b/design-motion/sections/figma.md.tmpl @@ -4,8 +4,8 @@ Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the s ```bash _FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}" -curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files//nodes?ids=&geometry=paths" -o "/figma-nodes.json" -curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/?ids=&format=png&scale=2" -o "/figma-ref.json" +printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/files//nodes?ids=&geometry=paths" -o "/figma-nodes.json" +printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/images/?ids=&format=png&scale=2" -o "/figma-ref.json" ``` The nodes response gives the tree, bounds, fills, effects and radii; the images response gives a URL for the **reference frame**, saved as `reference.png`. Every layer position is its absolute bounds minus the frame's origin. Figma's image CDN sometimes resets connections: retry each download and check each PNG's size before using it. diff --git a/lib/motion-capture.ts b/lib/motion-capture.ts index e4f040eb5..c7abff5f7 100644 --- a/lib/motion-capture.ts +++ b/lib/motion-capture.ts @@ -121,6 +121,7 @@ export function parseCaptureArgs(argv: string[]): CaptureOptions | { error: stri } if (still && !/\.png$/i.test(still)) return { error: '--still must name a .png file' }; if (still && flags.has('--mask')) return { error: '--mask belongs to a capture (--out), not a single still' }; + if (/[{};]/.test(flags.get('--mask') ?? '')) return { error: `--mask takes a CSS selector, got '${flags.get('--mask')}'` }; if (out && flags.has('--scale')) return { error: '--scale belongs to --still; a capture always uses the 1920x1200 scale' }; return { target, width, height, out, still, cycle: nums['--cycle'], scale: nums['--scale'], pre, tail, mask: flags.get('--mask') }; } diff --git a/lib/motion-encode.ts b/lib/motion-encode.ts index e75df64df..4f626d3af 100644 --- a/lib/motion-encode.ts +++ b/lib/motion-encode.ts @@ -43,6 +43,8 @@ export function manifestError(m: unknown): string | null { 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']) { @@ -132,6 +134,18 @@ export function psnrArgs(o: { first: string; start?: number; count: number; refe 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(','); diff --git a/test/motion-capture.test.ts b/test/motion-capture.test.ts index 386a240ea..42ad40910 100644 --- a/test/motion-capture.test.ts +++ b/test/motion-capture.test.ts @@ -79,6 +79,7 @@ describe('capture arguments', () => { expect(err(['p.html', '--size', '640x400', '--fps', '30'])).toContain("unknown option '--fps'"); expect(err(['p.html', 'q.html', '--size', '640x400'])).toContain("unexpected argument 'q.html'"); expect(err(['p.html', '--size'])).toContain('needs a value'); + expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--mask', '.card} body {'])).toContain('--mask takes a CSS selector'); // Value: protects=a degenerate frame, a zero cycle and a fractional tail are refused at parse time with exit-2 wording; // fails_when=the size floor, the >0 check or the tail integer check is dropped; why_new=no row hit these three guards; seam=none; // no_claim=parser only; Chromium's behaviour on such a frame is not exercised diff --git a/test/motion-encode.test.ts b/test/motion-encode.test.ts index 73c69cd84..8819d050e 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, manifestError, mp4Args, padError, parsePsnrStats, planChecks, psnrArgs, + compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, psnrArgs, resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions, } from '../lib/motion-encode'; @@ -181,6 +181,15 @@ describe('PSNR parsing and verdicts', () => { 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'); + expect(manifestError({ ...manifest(), fps: 0 })).toContain('at least 1'); + expect(manifestError({ ...manifest(), windowFrames: 1_000_000_000 })).toContain('runs past its frames'); + }); + + test('mask coverage reads signalstats YAVG as a fraction of white', () => { + const args = maskCoverageArgs('/c/mask.png', 2); + expect(args[args.indexOf('-vf') + 1]).toBe('format=gray,dilation,dilation,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-'); + expect(parseMaskCoverage('frame:0 pts:0 pts_time:0\nlavfi.signalstats.YAVG=127.5\n')).toBe(0.5); + expect(parseMaskCoverage('')).toBeNull(); }); }); diff --git a/test/motion-pipeline.test.ts b/test/motion-pipeline.test.ts index 7d8c62ea6..171dbcd2d 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, o: { mask?: boolean } = {}) { +function capture(dir: string, html: string, o: { mask?: string | false } = {}) { 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'), ...(o.mask === false ? [] : ['--mask', '.card'])]); + return run([CAPTURE, file, '--size', '640x100', '--cycle', '0.5', '--out', path.join(dir, 'cap'), ...(o.mask === false ? [] : ['--mask', o.mask ?? '.card'])]); } describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () => { @@ -129,9 +129,23 @@ 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 --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 { + const c = capture(dir, page({ loops: true, contract: false })); + expect(c.code).toBe(1); + expect(c.err).toContain('MOTION_CONTRACT_MISSING'); + expect(fs.existsSync(path.join(dir, 'cap', 'capture.json'))).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); + + // Value: protects=a capture reuses an --out only when its own marker is there: a foreign still.png is refused and kept, the retry after a + // refused page succeeds, and a later failed capture clears the previous manifest; the no-mask run ends 'clean (overflow not checked)' + // and an unbounded --mask-grow is refused; fails_when=the marker test, the cleanup, the no-mask result or the grow bound is dropped; + // why_new=review cycles found a refused retry and a deleted foreign dir; seam=none; no_claim=a crash mid-frame-loop is not simulated + test('an --out is reused only when this tool wrote it', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-')); try { const cap = path.join(dir, 'cap'); @@ -153,6 +167,9 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () 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)'); + const grow = run([ENCODE, 'check', cap, ...PHASES, '--mask-grow', '33']); + expect(grow.code).toBe(2); + expect(grow.err).toContain('--mask-grow must be a whole number of pixels from 0 to 32'); 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); @@ -161,7 +178,24 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () } }, 240_000); - // Value: protects=an offscreen loading=lazy image no longer hangs the still render (img.decode() never settles on it); + // Value: protects=a mask that paints the whole frame fails overflow instead of blacking out every pixel on both sides and passing; + // fails_when=the mask-coverage guard is dropped; why_new=adversarial review reproduced a vacuous overflow pass; seam=none; + // no_claim=only a full-frame mask; the 90% threshold itself is not swept + test('a mask that covers the frame fails overflow', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-')); + try { + expect(capture(dir, page({ loops: true }), { mask: 'html' }).code).toBe(0); + const check = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]); + expect(check.code).toBe(1); + expect(check.out).toMatch(/MOTION_CHECK: overflow fail the mask covers \d+% of the frame/); + expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: 1 failed'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 240_000); + + // Value: protects=an offscreen loading=lazy image no longer hangs the still render (img.decode() never settles on it), and --still writes + // one PNG at --scale (the Step 3 fidelity reference); // 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', () => { @@ -170,9 +204,13 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () 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')]); + const out = path.join(dir, 'still.png'); + const still = run([CAPTURE, file, '--size', '640x100', '--still', out, '--scale', '2']); expect(still.code, still.err).toBe(0); + expect(still.out.trim()).toBe(`STILL_OK ${out}`); expect(still.err).not.toContain('still loading'); + const png = fs.readFileSync(out); + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1280, 200]); } finally { fs.rmSync(dir, { recursive: true, force: true }); }