Files
gstack/test/motion-capture.test.ts
Garry Tanandcapy-ai[bot] c2e01e4ba7 v1.91.264.0 feat(design-motion): new /design-motion skill for showcase loops, adapted from Elaya's design-to-motion (GSTA-92 phase 2) (#3430)
* 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>
2026-10-27 03:04:06 +00:00

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');
});
});