mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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>
This commit is contained in:
1 parent
1ec1a2d481
commit
3aa44719b6
4 files changed
+39
-22
No files matched your search
+2
-2
@@ -8,11 +8,11 @@
|
||||
- **/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 `<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 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. When the grown mask covers more than 90% of the frame (a full-bleed app frame), overflow is reported as skipped and the run ends `clean (overflow not checked)` instead of passing by blacking out everything; a `--mask` that matches no element stops the capture with `MOTION_MASK_EMPTY`; `--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, font or image loading is bounded at 30 s, and a frame whose `__tick` never returns fails after 10 s with `MOTION_CAPTURE_FAILED`. Exports write to a `.partial-` file and rename on success, so a failed re-export never truncates the last good file; a `--gif-fps` or `--small-fps` that does not divide the cycle is refused instead of drifting; a `GSTACK_FFMPEG` that names a missing file says so. A URL source is treated as untrusted data. 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. When an app frame's own surfaces cover more than 90% of the frame (full bleed), overflow is reported as skipped and the run ends `clean (overflow not checked)`; the same coverage in a cards layout, or reached only through `--mask-grow`, fails overflow instead of passing by blacking out everything; a `--mask` that matches no element stops the capture with `MOTION_MASK_EMPTY`; `--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, font or image loading is bounded at 30 s, and a frame whose `__tick` never returns fails after 10 s with `MOTION_CAPTURE_FAILED`. Exports write to a `.partial-` file and rename on success, so a failed re-export never truncates the last good file; a `--gif-fps` or `--small-fps` above 50 or not dividing the cycle is refused instead of drifting; `compare` resolves its paths; a `GSTACK_FFMPEG` that names a missing file says so. A URL source is treated as untrusted data. 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 at `--scale 2`; a full-frame mask reports overflow as not checked and an empty mask stops the capture; a spinning `__tick` fails after 10 s; a failed re-export keeps the last good GIF and a drifting `--gif-fps` is refused. 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 full-frame mask fails a cards check and is not checked for an app frame, and an empty mask stops the capture before any frame; a spinning `__tick` fails after 10 s; a failed re-export keeps the last good GIF and a drifting `--gif-fps` is refused. 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
|
||||
|
||||
@@ -94,6 +94,9 @@ try {
|
||||
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`);
|
||||
if (opts.mask && await page.evaluate(selector => document.querySelectorAll(selector).length, opts.mask) === 0) {
|
||||
die(1, `MOTION_MASK_EMPTY: --mask '${opts.mask}' matches no element on ${opts.target}; mark each clip container with data-motion-clip or fix the selector`);
|
||||
}
|
||||
const loopTime: number[] = [];
|
||||
for (let i = 0; i < plan.frames; i++) {
|
||||
const dt = stepMs(i);
|
||||
@@ -118,8 +121,6 @@ try {
|
||||
const still = await openPage('still', scale);
|
||||
await still.screenshot({ path: path.join(out, 'still.png'), animations: 'disabled' });
|
||||
if (opts.mask) {
|
||||
const matched = await still.evaluate(selector => document.querySelectorAll(selector).length, opts.mask);
|
||||
if (matched === 0) die(1, `MOTION_MASK_EMPTY: --mask '${opts.mask}' matches no element on ${opts.target}?still; mark each clip container with data-motion-clip or fix the selector`);
|
||||
await still.addStyleTag({ content: maskCss(opts.mask) });
|
||||
await still.screenshot({ path: path.join(out, 'mask.png'), animations: 'disabled' });
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
// 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; "clean
|
||||
// (overflow not checked)" for a capture without a mask, or one whose grown
|
||||
// mask covers more than 90% of the frame, as a full-bleed app frame does) or
|
||||
// (overflow not checked)" for a capture without a mask, or an app frame whose
|
||||
// surfaces cover more than 90% of the frame; in cards, or after --mask-grow,
|
||||
// such coverage fails overflow) 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
|
||||
@@ -86,7 +87,7 @@ function num(flag: string, fallback: number): number {
|
||||
}
|
||||
|
||||
if (cmd === 'compare') {
|
||||
const [a, b] = positional as [string, string];
|
||||
const [a, b] = positional.map(p => path.resolve(p)) as [string, string];
|
||||
const r = run(compareArgs(a, b, { blur: num('--blur', 0) || undefined, crop: flags.get('--crop') }));
|
||||
const frame = parsePsnrStats(String(r.stdout))[0];
|
||||
if (r.status !== 0 || !frame) die(1, `COMPARE_FAILED ${String(r.stderr).trim().split('\n').pop() || 'no PSNR line'} (same pixel size required)`);
|
||||
@@ -122,15 +123,24 @@ if (cmd === 'check') {
|
||||
let overflowChecked = manifest.mask !== undefined;
|
||||
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) {
|
||||
const coverage = (grow: number) => parseMaskCoverage(String(run(maskCoverageArgs(path.join(dir, manifest.mask!), grow)).stdout));
|
||||
const own = coverage(0);
|
||||
const grown = coverage(maskGrow);
|
||||
if (own === null || grown === null) {
|
||||
failed++;
|
||||
console.log('MOTION_CHECK: overflow fail the mask coverage could not be measured');
|
||||
continue;
|
||||
}
|
||||
if (coverage > MAX_MASK_COVERAGE) {
|
||||
if (own > MAX_MASK_COVERAGE && layout === 'app') {
|
||||
overflowChecked = false;
|
||||
console.log(`MOTION_CHECK: overflow skipped (the grown mask covers ${Math.round(coverage * 100)}% of the frame; nothing outside the containers to compare)`);
|
||||
console.log(`MOTION_CHECK: overflow skipped (the app frame's surfaces cover ${Math.round(own * 100)}% of the frame; nothing outside them to compare)`);
|
||||
continue;
|
||||
}
|
||||
if (grown > MAX_MASK_COVERAGE) {
|
||||
failed++;
|
||||
console.log(own > MAX_MASK_COVERAGE
|
||||
? `MOTION_CHECK: overflow fail the mask covers ${Math.round(own * 100)}% of the frame; in a cards layout mark only the card containers with data-motion-clip`
|
||||
: `MOTION_CHECK: overflow fail --mask-grow ${maskGrow} grows the mask over ${Math.round(grown * 100)}% of the frame, which hides overflow; lower it`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -169,8 +179,8 @@ if (missing) die(2, `gstack-motion-encode: ${missing} is missing; the capture is
|
||||
function gifFps(flag: string): number {
|
||||
const fps = num(flag, 20);
|
||||
const perCycle = manifest.cycle * fps;
|
||||
if (!Number.isInteger(fps) || fps < 1 || Math.abs(perCycle - Math.round(perCycle)) > 1e-6) {
|
||||
die(2, `gstack-motion-encode: ${flag} ${flags.get(flag)} does not give a whole number of frames per ${manifest.cycle} s cycle, so the GIF would drift; 20 always does`);
|
||||
if (!Number.isInteger(fps) || fps < 1 || fps > 50 || Math.abs(perCycle - Math.round(perCycle)) > 1e-6) {
|
||||
die(2, `gstack-motion-encode: ${flag} ${flags.get(flag)} must be a whole fps from 1 to 50 (GIF delays are in 1/100 s) that gives a whole number of frames per ${manifest.cycle} s cycle; 20 always does`);
|
||||
}
|
||||
return fps;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', ()
|
||||
expect(fs.readdirSync(cap).filter(f => f.startsWith('.partial-'))).toEqual([]);
|
||||
const drift = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', 'loop', '--gif-fps', '7']);
|
||||
expect(drift.code).toBe(2);
|
||||
expect(drift.err).toContain('does not give a whole number of frames per 0.5 s cycle');
|
||||
expect(drift.err).toContain('must be a whole fps from 1 to 50');
|
||||
expect(run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', '800', '--small-fps', '60']).code).toBe(2);
|
||||
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-x.mp4')}`);
|
||||
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-800x500.gif')}`);
|
||||
const probe = run([FFPROBE!, '-v', 'error', '-select_streams', 'v:0', '-count_frames', '-show_entries', 'stream=width,height,nb_read_frames', '-of', 'csv=p=0', path.join(cap, 'pipe-x.mp4')]);
|
||||
@@ -188,20 +189,25 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', ()
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a mask that paints the whole frame reports overflow as not checked instead of blacking out every pixel and passing,
|
||||
// and a --mask that matches nothing stops the capture instead of blaming the page; fails_when=the coverage guard or the empty-mask
|
||||
// check is dropped; why_new=adversarial review reproduced both; seam=none; no_claim=only a full-frame mask; the 90% line is not swept
|
||||
test('a mask that covers the frame is not checked, and an empty mask stops the capture', () => {
|
||||
// Value: protects=a mask that paints the whole frame fails overflow in a cards layout and is reported as not checked only for an app
|
||||
// frame, instead of blacking out every pixel and passing; a --mask that matches nothing stops the capture before any frame;
|
||||
// fails_when=the coverage guard, its app-only skip or the empty-mask check is dropped; why_new=adversarial review reproduced each;
|
||||
// seam=none; no_claim=only a full-frame mask; the 90% line and a grow-driven failure are not swept
|
||||
test('a mask that covers the frame fails cards and is not checked for an app frame; an empty mask stops the capture', () => {
|
||||
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, check.out + check.err).toBe(0);
|
||||
expect(check.out).toMatch(/MOTION_CHECK: overflow skipped \(the grown mask covers \d+% of the frame/);
|
||||
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
|
||||
const cards = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]);
|
||||
expect(cards.code).toBe(1);
|
||||
expect(cards.out).toMatch(/MOTION_CHECK: overflow fail the mask covers \d+% of the frame; in a cards layout mark only the card containers/);
|
||||
const app = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--layout', 'app']);
|
||||
expect(app.code, app.out + app.err).toBe(0);
|
||||
expect(app.out).toMatch(/MOTION_CHECK: overflow skipped \(the app frame's surfaces cover \d+% of the frame/);
|
||||
expect(app.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
|
||||
const empty = capture(dir, page({ loops: true }), { mask: '[data-nope]' });
|
||||
expect(empty.code).toBe(1);
|
||||
expect(empty.err).toContain("MOTION_MASK_EMPTY: --mask '[data-nope]' matches no element");
|
||||
expect(fs.readdirSync(path.join(dir, 'cap', 'frames'))).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user