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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 01:22:39 +00:00
1 parent 72862439c3
commit c7ec40bc19
8 files changed
+39 -21

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
- **/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 `<n> failed`. `export` writes `<name>-x.mp4` (H.264, CRF 15, 60 fps), `<name>-loop.gif` (960 px, 20 fps) and `<name>-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 `#RRGGBB[AA]`, 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, and the contact sheet no longer needs ffmpeg 5.1's `-fps_mode`. `gstack-motion-capture.ts` refuses a `--cycle` that is not a whole number of 60 fps frames. 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 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.
### 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.
+5 -2
View File
@@ -79,9 +79,12 @@ 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 });
for (const stale of ['frames', 'capture.json', 'still.png', 'mask.png', 'contact-sheet.png']) {
fs.rmSync(path.join(out, stale), { recursive: true, force: true });
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`);
}
for (const f of present) fs.rmSync(path.join(out, f), { recursive: true, force: true });
fs.mkdirSync(path.join(out, 'frames'), { recursive: true });
const page = await openPage('capture', scale);
const contract = await page.evaluate(() => typeof (window as any).__start === 'function' && typeof (window as any).__tick === 'function');
+4 -4
View File
@@ -12,7 +12,8 @@
// the overflow check when the capture has a mask, through ffmpeg's psnr
// filter; it prints one "MOTION_CHECK: <id> pass|fail ..." line each, writes
// contact-sheet.png, prints "MOTION_SETTINGS: ..." naming any threshold or
// phase override, and ends with "MOTION_RESULT: clean" (exit 0) or
// phase override, and ends with "MOTION_RESULT: clean" (exit 0; "clean
// (overflow not checked)" for a capture without a mask) or
// "MOTION_RESULT: <n> failed" (exit 1). Defaults follow the 12 s cycle:
// dissolve at cycle - 1.0, empty beat from cycle - 0.4, intro landed at 2.4.
// export writes <name>-x.mp4, <name>-loop.gif and <name>-800x500.gif into the
@@ -127,13 +128,12 @@ if (cmd === 'check') {
console.log(s.status === 0 ? `CONTACT_SHEET ${sheet}` : `CONTACT_SHEET unavailable: ${String(s.stderr).trim().split('\n').pop()}`);
const overrides = KNOWN.check!.filter(f => f !== '--layout' && flags.has(f)).map(f => `${f} ${flags.get(f)}`);
if (overrides.length > 0) console.log(`MOTION_SETTINGS: ${overrides.join(', ')}; defaults otherwise`);
console.log(failed === 0 ? 'MOTION_RESULT: clean' : `MOTION_RESULT: ${failed} failed`);
console.log(failed > 0 ? `MOTION_RESULT: ${failed} failed` : manifest.mask === undefined ? 'MOTION_RESULT: clean (overflow not checked)' : 'MOTION_RESULT: clean');
process.exit(failed === 0 ? 0 : 1);
}
const name = flags.get('--name');
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) die(2, '--name <kebab-case-name> is required for export');
const crop = cropFlag;
const pad = flags.get('--pad');
if (pad) {
const err = padError(pad);
@@ -148,7 +148,7 @@ const jobs: Array<[string, string[]]> = [];
if (formats.includes('mp4')) jobs.push([`${name}-x.mp4`, mp4Args(manifest, dir, path.join(dir, `${name}-x.mp4`))]);
if (formats.includes('loop')) jobs.push([`${name}-loop.gif`, loopGifArgs(manifest, dir, path.join(dir, `${name}-loop.gif`), { fps: num('--gif-fps', 20) })]);
if (formats.includes('800')) {
jobs.push([`${name}-800x500.gif`, smallGifArgs(manifest, dir, path.join(dir, `${name}-800x500.gif`), { fps: num('--small-fps', 20), crop, pad })]);
jobs.push([`${name}-800x500.gif`, smallGifArgs(manifest, dir, path.join(dir, `${name}-800x500.gif`), { fps: num('--small-fps', 20), crop: cropFlag, pad })]);
}
let failed = 0;
for (const [file, args] of jobs) {
+3 -3
View File
@@ -462,7 +462,7 @@ Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text
## Step 5: Capture
The page must expose the capture contract (fidelity section): `?capture` turns off `requestAnimationFrame` and exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`.
The page must expose the capture contract (fidelity section): `?capture` stops `requestAnimationFrame`, exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`, and sets `window.__mark` on state changes.
```bash
~/.claude/skills/gstack/bin/gstack-motion-capture.ts "<motion-dir>/page.html" --size <frame-size> --cycle <cycle-seconds> --out "<motion-dir>/cap" --mask '[data-motion-clip]'
@@ -483,11 +483,11 @@ The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat fro
- **rest**: the last frame before the dissolve equals `still.png`: every story came back to the design. A story that leaves an inline `translate(0)` on an element fails this; clear the inline style at rest.
- **overflow**: outside the mask, nothing moves. App frame: every frame equals the empty first frame. Cards: every story frame equals the still. `--mask-grow <px>` covers shadows.
Read `cap/contact-sheet.png` and look for ghosting, pops, content moving ahead of its card, and stories too timid to see. Fix, recapture and recheck, at most three rounds; then report what still fails instead of exporting. `FFMPEG_MISSING`: relay its install line and stop.
Read `cap/contact-sheet.png` for ghosting, pops, content moving ahead of its card, and stories too timid to see. Fix, recapture and recheck, at most three rounds; then report what still fails, unexported. `FFMPEG_MISSING`: relay its install line and stop.
## Step 7: Export and report
On the first design of a session, show the contact sheet before exporting so the direction can be corrected cheaply.
On a session's first design, show the contact sheet before exporting so the direction is cheap to correct.
```bash
~/.claude/skills/gstack/bin/gstack-motion-encode export "<motion-dir>/cap" --name <name> --pad '<hex-color>'
+3 -3
View File
@@ -128,7 +128,7 @@ Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text
## Step 5: Capture
The page must expose the capture contract (fidelity section): `?capture` turns off `requestAnimationFrame` and exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`.
The page must expose the capture contract (fidelity section): `?capture` stops `requestAnimationFrame`, exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`, and sets `window.__mark` on state changes.
```bash
{{BIN_DIR}}/gstack-motion-capture.ts "<motion-dir>/page.html" --size <frame-size> --cycle <cycle-seconds> --out "<motion-dir>/cap" --mask '[data-motion-clip]'
@@ -149,11 +149,11 @@ The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat fro
- **rest**: the last frame before the dissolve equals `still.png`: every story came back to the design. A story that leaves an inline `translate(0)` on an element fails this; clear the inline style at rest.
- **overflow**: outside the mask, nothing moves. App frame: every frame equals the empty first frame. Cards: every story frame equals the still. `--mask-grow <px>` covers shadows.
Read `cap/contact-sheet.png` and look for ghosting, pops, content moving ahead of its card, and stories too timid to see. Fix, recapture and recheck, at most three rounds; then report what still fails instead of exporting. `FFMPEG_MISSING`: relay its install line and stop.
Read `cap/contact-sheet.png` for ghosting, pops, content moving ahead of its card, and stories too timid to see. Fix, recapture and recheck, at most three rounds; then report what still fails, unexported. `FFMPEG_MISSING`: relay its install line and stop.
## Step 7: Export and report
On the first design of a session, show the contact sheet before exporting so the direction can be corrected cheaply.
On a session's first design, show the contact sheet before exporting so the direction is cheap to correct.
```bash
{{BIN_DIR}}/gstack-motion-encode export "<motion-dir>/cap" --name <name> --pad '<hex-color>'
+2 -2
View File
@@ -116,8 +116,8 @@ export function parseCaptureArgs(argv: string[]): CaptureOptions | { error: stri
if (still && out) return { error: 'use --still or --out, not both' };
if (!still && !out) return { error: '--out <dir> is required for a capture (or --still <out.png> for one still frame)' };
if (out && nums['--cycle'] === undefined) return { error: '--cycle <seconds> is required for a capture' };
if (nums['--cycle'] !== undefined && Math.abs(nums['--cycle'] * FPS - Math.round(nums['--cycle'] * FPS)) > 1e-6) {
return { error: `--cycle must be a whole number of ${FPS} fps frames, got '${nums['--cycle']}' (nearest: ${Number((Math.round(nums['--cycle'] * FPS) / FPS).toFixed(4))})` };
if (nums['--cycle'] !== undefined && Math.abs(nums['--cycle'] * 20 - Math.round(nums['--cycle'] * 20)) > 1e-6) {
return { error: `--cycle must be a multiple of 0.05 s (three ${FPS} fps frames, so the seam lands exactly one cycle later), got '${nums['--cycle']}' (nearest: ${Math.max(1, Math.round(nums['--cycle'] * 20)) / 20})` };
}
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' };
+10 -5
View File
@@ -85,11 +85,16 @@ describe('capture arguments', () => {
expect(err(['p.html', '--size', '10x10', '--still', 's.png'])).toContain('--size 10x10 is too small');
expect(err(['p.html', '--size', '640x400', '--cycle', '0', '--out', 'c'])).toContain("--cycle must be a positive number, got '0'");
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--tail', '2.5'])).toContain('--tail must be a whole number');
// Value: protects=a cycle that is not a whole number of 60 fps frames is refused with the nearest valid cycle;
// fails_when=the guard is dropped and the seam frame lands off one cycle, failing the seam check spuriously; why_new=new guard;
// seam=none; no_claim=parser only
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('--cycle must be a whole number of 60 fps frames');
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('nearest: 6.0167');
// Value: protects=a cycle that is not a multiple of 0.05 s is refused with a nearest cycle the same guard accepts;
// fails_when=the guard is dropped or loosened to whole frames, where the seam frame's clock depends on PRE; why_new=new guard;
// seam=none; no_claim=parser and clock math only
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('--cycle must be a multiple of 0.05 s');
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('nearest: 6)');
expect(err(['p.html', '--size', '640x400', '--cycle', '0.5167', '--out', 'c'])).toContain('nearest: 0.5)');
expect(err(['p.html', '--size', '640x400', '--cycle', '0.01', '--out', 'c'])).toContain('nearest: 0.05)');
expect(parseCaptureArgs(['p.html', '--size', '640x400', '--cycle', '0.55', '--out', 'c'])).not.toHaveProperty('error');
expect(new Set([14, 15, 16, 17].map(pre => clockMs(pre - 1 + 31, pre)))).toEqual(new Set([516, 517]));
for (const pre of [14, 15, 16, 17]) expect(clockMs(pre - 1 + 33, pre)).toBe(550);
});
test('modes keep the existing query and fragment', () => {
+11 -1
View File
@@ -129,13 +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 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.
test('a page without the capture contract is refused', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
try {
const cap = path.join(dir, 'cap');
fs.mkdirSync(cap);
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(fs.readFileSync(path.join(cap, 'still.png'), 'utf-8')).toBe('not ours');
fs.writeFileSync(path.join(cap, 'capture.json'), '{}');
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);
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 });
}