diff --git a/CHANGELOG.md b/CHANGELOG.md index ab58be912..47370c239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. 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. +- 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`. A CSS transition a story starts with a class toggle is seeked from the frame it appears rather than from the last `__mark`, so it eases in the export instead of popping. An empty numeric option (`--min-db ''`) is refused instead of read as 0, and the Figma section only fills a file key and node id that look like Figma's. 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 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. +- `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 class-toggled story transition is mid-fade at its midpoint frame; a blank `--min-db` is refused; 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 diff --git a/bin/gstack-motion-capture.ts b/bin/gstack-motion-capture.ts index 0e5a29707..c1307ebe8 100755 --- a/bin/gstack-motion-capture.ts +++ b/bin/gstack-motion-capture.ts @@ -9,7 +9,9 @@ // milliseconds averaging 1000/60, calls window.__start() on frame PRE and // window.__tick(dt) from then on inside the same evaluate, seeks every CSS // animation to the fake clock from window.__mark (the page sets it when its -// state classes change; 0 when absent), and screenshots. Output in : +// state classes change; 0 when absent) or, for an animation first seen in a +// frame where __mark did not move (a story's class toggle), from that frame, +// and screenshots. Output in : // frames/%05d.png, still.png (?still at the capture scale, CSS animations // finished), mask.png with --mask, and // capture.json (sizes, window, per-frame clock and loop time) for @@ -109,8 +111,11 @@ try { if (i === pre) w.__start(); if (i >= pre) w.__tick(dt / 1000); const now = performance.now(); + const mark = w.__mark ?? 0; + const marked = mark !== w.__seenMark; + w.__seenMark = mark; for (const a of document.getAnimations() as any[]) { - a.__m ??= w.__mark ?? 0; + a.__m ??= marked ? mark : now; a.pause(); a.currentTime = Math.max(0, now - a.__m); } diff --git a/bin/gstack-motion-encode b/bin/gstack-motion-encode index c9beeabc6..250efa323 100755 --- a/bin/gstack-motion-encode +++ b/bin/gstack-motion-encode @@ -81,7 +81,7 @@ function run(args: string[]) { function num(flag: string, fallback: number): number { const raw = flags.get(flag); if (raw === undefined) return fallback; - const v = Number(raw); + const v = raw.trim() === '' ? NaN : Number(raw); if (!Number.isFinite(v) || v < 0) die(2, `gstack-motion-encode: ${flag} must be a non-negative number, got '${raw}'`); return v; } diff --git a/design-motion/sections/fidelity.md b/design-motion/sections/fidelity.md index e40686b98..0862dc6ad 100644 --- a/design-motion/sections/fidelity.md +++ b/design-motion/sections/fidelity.md @@ -40,7 +40,7 @@ Both: each container clips its own contents to its exact shape, per-corner radii - One self-contained `page.html`: a fixed-size stage at the frame's exact CSS size, scaled to fit the window outside capture. Images inline as base64, deduplicated. Wait for `` decode before the intro. - **Modes.** `?still` renders the exact design with no motion (reduced motion shows it too). `?loop` cycles like the exports. `?capture` turns off `requestAnimationFrame` and hands the clock to the recorder. -- **Capture contract.** `window.__start()` adds the stage's `in` class. Every change of the stage's state classes (`in`, `out`, the reset) sets `window.__mark = performance.now()`, so the recorder seeks each CSS transition from the moment it began, delays included. `window.__tick(dt)` advances loop time by `dt` seconds, runs the cycle phases, draws every story and accent, and sets `window.__lt`. Endless motion (shimmer, marquee) runs on this clock, never as infinite CSS keyframes, which the seek would restart. +- **Capture contract.** `window.__start()` adds the stage's `in` class. Every change of the stage's state classes (`in`, `out`, the reset) sets `window.__mark = performance.now()`, so the recorder seeks each CSS transition from the moment it began, delays included; a transition a story starts later is seeked from the frame it appears. `window.__tick(dt)` advances loop time by `dt` seconds, runs the cycle phases, draws every story and accent, and sets `window.__lt`. Endless motion (shimmer, marquee) runs on this clock, never as infinite CSS keyframes, which the seek would restart. - **Units.** Each animated element sits in a full-stage layer `.u` carrying its delay `--d`; the intro is CSS transitions keyed on `.stage.in`. Percentages in `clip-path` or `transform-origin` on a unit refer to the stage, so use pixels from the element's rectangle. - **Static layers stay composited** (`transform: translateZ(0)` on clips and stroke overlays), or a 1 px edge flickers when nearby transitions start. - **One JS clock** drives masks, sweeps and stories from loop time, never wall time. The live page plays the intro once (on scroll into view when it sits on a longer page), then keeps its stories running. diff --git a/design-motion/sections/fidelity.md.tmpl b/design-motion/sections/fidelity.md.tmpl index b7d8dc3f6..405f93efc 100644 --- a/design-motion/sections/fidelity.md.tmpl +++ b/design-motion/sections/fidelity.md.tmpl @@ -38,7 +38,7 @@ Both: each container clips its own contents to its exact shape, per-corner radii - One self-contained `page.html`: a fixed-size stage at the frame's exact CSS size, scaled to fit the window outside capture. Images inline as base64, deduplicated. Wait for `` decode before the intro. - **Modes.** `?still` renders the exact design with no motion (reduced motion shows it too). `?loop` cycles like the exports. `?capture` turns off `requestAnimationFrame` and hands the clock to the recorder. -- **Capture contract.** `window.__start()` adds the stage's `in` class. Every change of the stage's state classes (`in`, `out`, the reset) sets `window.__mark = performance.now()`, so the recorder seeks each CSS transition from the moment it began, delays included. `window.__tick(dt)` advances loop time by `dt` seconds, runs the cycle phases, draws every story and accent, and sets `window.__lt`. Endless motion (shimmer, marquee) runs on this clock, never as infinite CSS keyframes, which the seek would restart. +- **Capture contract.** `window.__start()` adds the stage's `in` class. Every change of the stage's state classes (`in`, `out`, the reset) sets `window.__mark = performance.now()`, so the recorder seeks each CSS transition from the moment it began, delays included; a transition a story starts later is seeked from the frame it appears. `window.__tick(dt)` advances loop time by `dt` seconds, runs the cycle phases, draws every story and accent, and sets `window.__lt`. Endless motion (shimmer, marquee) runs on this clock, never as infinite CSS keyframes, which the seek would restart. - **Units.** Each animated element sits in a full-stage layer `.u` carrying its delay `--d`; the intro is CSS transitions keyed on `.stage.in`. Percentages in `clip-path` or `transform-origin` on a unit refer to the stage, so use pixels from the element's rectangle. - **Static layers stay composited** (`transform: translateZ(0)` on clips and stroke overlays), or a 1 px edge flickers when nearby transitions start. - **One JS clock** drives masks, sweeps and stories from loop time, never wall time. The live page plays the intro once (on scroll into view when it sits on a longer page), then keeps its stories running. diff --git a/design-motion/sections/figma.md b/design-motion/sections/figma.md index 5265666e3..9f45dc096 100644 --- a/design-motion/sections/figma.md +++ b/design-motion/sections/figma.md @@ -2,7 +2,7 @@ ## Figma source (optional, needs a token) -Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the source is a Figma link with a node id. The token is a secret: pass it only as a header from the environment, never print it, echo it, write it to a file, or put it in chat or a commit. +Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the source is a Figma link with a node id. Fill `` and `` only with values matching `^[A-Za-z0-9]+$` and `^[0-9]+[:-][0-9]+$`; anything else is not a Figma link. The token is a secret: pass it only as a header from the environment, never print it, echo it, write it to a file, or put it in chat or a commit. ```bash _FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}" diff --git a/design-motion/sections/figma.md.tmpl b/design-motion/sections/figma.md.tmpl index 56aa71027..c68b0a5bc 100644 --- a/design-motion/sections/figma.md.tmpl +++ b/design-motion/sections/figma.md.tmpl @@ -1,6 +1,6 @@ ## Figma source (optional, needs a token) -Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the source is a Figma link with a node id. The token is a secret: pass it only as a header from the environment, never print it, echo it, write it to a file, or put it in chat or a commit. +Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the source is a Figma link with a node id. Fill `` and `` only with values matching `^[A-Za-z0-9]+$` and `^[0-9]+[:-][0-9]+$`; anything else is not a Figma link. The token is a secret: pass it only as a header from the environment, never print it, echo it, write it to a file, or put it in chat or a commit. ```bash _FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}" diff --git a/test/motion-pipeline.test.ts b/test/motion-pipeline.test.ts index c741f4aa6..02461f1aa 100644 --- a/test/motion-pipeline.test.ts +++ b/test/motion-pipeline.test.ts @@ -132,6 +132,9 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () const relaxed = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '0']); expect(relaxed.code, relaxed.out + relaxed.err).toBe(0); expect(relaxed.out).toMatch(/^MOTION_SETTINGS: .*--min-db 0; defaults otherwise$/m); + const blank = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '']); + expect(blank.code).toBe(2); + expect(blank.err).toContain("--min-db must be a non-negative number, got ''"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -213,6 +216,28 @@ describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () } }, 240_000); + // Value: protects=a CSS transition that a story starts with a class toggle is seeked from the frame it appears, not from the last + // __mark, so it eases instead of popping; fails_when=new animations are seeded at __mark again; why_new=adversarial probe showed a + // story transition exported ~100 ms ahead while every check passed; seam=none; no_claim=one linear opacity transition + test('a story transition started by a class toggle eases from the frame it starts', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-')); + try { + const c = capture(dir, ` +
`, { mask: false }); + expect(c.code, c.err).toBe(0); + const probe = run([FFMPEG!, '-hide_banner', '-loglevel', 'error', '-i', path.join(dir, 'cap', 'frames', '00026.png'), '-vf', 'format=gray,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-', '-f', 'null', '-']); + const yavg = Number(/YAVG=([\d.]+)/.exec(probe.out)?.[1]); + expect(yavg).toBeGreaterThan(90); + expect(yavg).toBeLessThan(170); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); + // Value: protects=a page whose __tick never returns stops with MOTION_CAPTURE_FAILED instead of hanging the capture forever; // fails_when=the per-frame 10 s bound is dropped; why_new=adversarial probe hung at 100% renderer CPU; seam=none; // no_claim=only a spinning __tick; a hung screenshot is not bounded