mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
128 lines
7.8 KiB
TypeScript
128 lines
7.8 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import * as path from 'path';
|
|
import {
|
|
captureScale, clockMs, maskCss, parseCaptureArgs, planCapture, stepMs, withMode, type CaptureOptions,
|
|
} from '../lib/motion-capture';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-motion-capture.ts');
|
|
|
|
describe('capture timing', () => {
|
|
test('steps are whole milliseconds averaging 1000/60', () => {
|
|
expect(Array.from({ length: 6 }, (_, i) => stepMs(i))).toEqual([17, 16, 17, 17, 16, 17]);
|
|
const steps = Array.from({ length: 720 }, (_, i) => stepMs(i));
|
|
expect(steps.every(s => s === 16 || s === 17)).toBe(true);
|
|
expect(steps.slice(0, 60).reduce((a, b) => a + b, 0)).toBe(1000);
|
|
expect(steps.reduce((a, b) => a + b, 0)).toBe(12_000);
|
|
});
|
|
|
|
test('the page clock is 0 before the start and sums the steps from PRE on', () => {
|
|
expect(clockMs(0, 15)).toBe(0);
|
|
expect(clockMs(14, 15)).toBe(0);
|
|
expect(clockMs(15, 15)).toBe(stepMs(15));
|
|
let sum = 0;
|
|
for (let i = 15; i < 200; i++) {
|
|
sum += stepMs(i);
|
|
expect(clockMs(i, 15)).toBe(sum);
|
|
}
|
|
});
|
|
|
|
test('the seam frame lands exactly one cycle after the window start for any PRE and cycle', () => {
|
|
for (const cycle of [4, 6, 12, 12.5, 15]) {
|
|
for (let pre = 2; pre <= 30; pre++) {
|
|
const plan = planCapture({ cycleSeconds: cycle, pre });
|
|
expect(clockMs(plan.windowStart, pre)).toBe(0);
|
|
expect(clockMs(plan.windowStart + plan.windowFrames, pre)).toBe(cycle * 1000);
|
|
expect(plan.frames).toBeGreaterThan(plan.windowStart + plan.windowFrames);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a default 12 s plan records PRE, one cycle from PRE - 1, and 10 frames past it', () => {
|
|
expect(planCapture({ cycleSeconds: 12 })).toEqual({ pre: 15, windowStart: 14, windowFrames: 720, tail: 10, frames: 744 });
|
|
});
|
|
|
|
test('the scale puts the long side on 1920x1200', () => {
|
|
expect(captureScale(1440, 990)).toBeCloseTo(1200 / 990, 10);
|
|
expect(Math.floor(1440 * captureScale(1440, 990))).toBe(1745);
|
|
expect(captureScale(1920, 1080)).toBe(1);
|
|
expect(captureScale(600, 400)).toBe(3);
|
|
// Value: protects=a portrait frame scales its long side to 1920 and short side within 1200, like a landscape one;
|
|
// fails_when=the scale again treats width as the long side and under-scales phone frames; why_new=every case above is landscape;
|
|
// seam=none; no_claim=scale math only; the capture's screenshot size is covered by test/motion-pipeline.test.ts
|
|
expect(captureScale(390, 844)).toBeCloseTo(1920 / 844, 10);
|
|
expect(captureScale(1080, 1920)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('capture arguments', () => {
|
|
test('a capture and a still parse', () => {
|
|
expect(parseCaptureArgs(['page.html', '--size', '1440x990', '--cycle', '12', '--out', 'cap', '--mask', '.card'])).toEqual({
|
|
target: 'page.html', width: 1440, height: 990, out: 'cap', still: undefined, cycle: 12, scale: undefined, pre: 15, tail: 10, mask: '.card',
|
|
} satisfies CaptureOptions);
|
|
const still = parseCaptureArgs(['https://example.com/a', '--size', '640x400', '--still', 'ref.png', '--scale', '2']) as CaptureOptions;
|
|
expect([still.still, still.scale, still.out]).toEqual(['ref.png', 2, undefined]);
|
|
});
|
|
|
|
test('each bad invocation names its problem', () => {
|
|
const err = (argv: string[]) => (parseCaptureArgs(argv) as { error: string }).error;
|
|
expect(err(['--size', '10x10'])).toContain('name the page');
|
|
expect(err(['p.html', '--cycle', '12', '--out', 'c'])).toContain('--size');
|
|
expect(err(['p.html', '--size', '640x400', '--out', 'c'])).toContain('--cycle');
|
|
expect(err(['p.html', '--size', '640x400', '--cycle', '4'])).toContain('--out');
|
|
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--still', 's.png'])).toContain('not both');
|
|
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--pre', '1'])).toContain('--pre');
|
|
expect(err(['p.html', '--size', '640x400', '--cycle', 'abc', '--out', 'c'])).toContain('positive number');
|
|
expect(err(['p.html', '--size', '640x400', '--still', 's.jpg'])).toContain('.png');
|
|
expect(err(['p.html', '--size', '640x400', '--still', 's.png', '--mask', '.card'])).toContain('--mask');
|
|
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--scale', '2'])).toContain('--scale');
|
|
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
|
|
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 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', () => {
|
|
expect(withMode('file:///tmp/page.html', 'capture')).toBe('file:///tmp/page.html?capture');
|
|
expect(withMode('https://x.dev/y?a=1#top', 'still')).toBe('https://x.dev/y?a=1&still#top');
|
|
});
|
|
|
|
test('the mask paints only the matched containers white', () => {
|
|
const css = maskCss('.card, .panel');
|
|
expect(css).toContain('* { visibility: hidden !important;');
|
|
expect(css).toContain('.card, .panel { visibility: visible !important; background: #fff !important;');
|
|
expect(css).toContain('box-shadow: none !important');
|
|
});
|
|
});
|
|
|
|
describe('gstack-motion-capture CLI', () => {
|
|
test('a usage error exits 2 before any browser starts', () => {
|
|
const r = Bun.spawnSync([BIN, 'page.html', '--size', '640x400'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(r.exitCode).toBe(2);
|
|
expect(r.stderr.toString()).toContain('--out <dir> is required');
|
|
expect(r.stderr.toString()).toContain('Usage:');
|
|
});
|
|
|
|
test('a missing page file exits 2', () => {
|
|
const r = Bun.spawnSync([BIN, path.join(ROOT, 'no-such-page.html'), '--size', '640x400', '--still', '/tmp/x.png'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(r.exitCode).toBe(2);
|
|
expect(r.stderr.toString()).toContain('does not exist');
|
|
});
|
|
});
|