mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
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>
This commit is contained in:
1 parent
aaa1f989f3
commit
41a720dac4
16 files changed
+173
-64
No files matched your search
@@ -8,9 +8,11 @@
|
||||
- **/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.
|
||||
|
||||
### 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. 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
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
// loads <page>?capture, and for each frame advances the clock by whole
|
||||
// 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, and screenshots. Output in <dir>: frames/%05d.png,
|
||||
// still.png (<page>?still at the capture scale), mask.png with --mask, and
|
||||
// animation to the fake clock from window.__mark (the page sets it when its
|
||||
// state classes change; 0 when absent), and screenshots. Output in <dir>:
|
||||
// frames/%05d.png, still.png (<page>?still at the capture scale, CSS animations
|
||||
// finished), mask.png with --mask, and
|
||||
// capture.json (sizes, window, per-frame clock and loop time) for
|
||||
// gstack-motion-encode. --still renders <page>?still alone (--scale 2 for the
|
||||
// fidelity reference).
|
||||
@@ -63,7 +65,7 @@ async function openPage(mode: 'capture' | 'still', scale: number) {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.images].map(img => img.decode().catch(() => undefined)));
|
||||
});
|
||||
await page.clock.pauseAt(10_000);
|
||||
await page.clock.pauseAt(Math.max(10_000, await page.evaluate(() => Date.now()) + 1_000));
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -71,13 +73,15 @@ try {
|
||||
if (opts.still) {
|
||||
const page = await openPage('still', opts.scale ?? captureScale(opts.width, opts.height));
|
||||
fs.mkdirSync(path.dirname(path.resolve(opts.still)), { recursive: true });
|
||||
await page.screenshot({ path: opts.still });
|
||||
await page.screenshot({ path: opts.still, animations: 'disabled' });
|
||||
console.log(`STILL_OK ${opts.still}`);
|
||||
} else {
|
||||
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 });
|
||||
fs.rmSync(path.join(out, 'frames'), { recursive: true, force: true });
|
||||
for (const stale of ['frames', 'capture.json', 'still.png', 'mask.png', 'contact-sheet.png']) {
|
||||
fs.rmSync(path.join(out, stale), { 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');
|
||||
@@ -101,10 +105,10 @@ try {
|
||||
await page.screenshot({ path: path.join(out, 'frames', `${String(i).padStart(5, '0')}.png`) });
|
||||
}
|
||||
const still = await openPage('still', scale);
|
||||
await still.screenshot({ path: path.join(out, 'still.png') });
|
||||
await still.screenshot({ path: path.join(out, 'still.png'), animations: 'disabled' });
|
||||
if (opts.mask) {
|
||||
await still.addStyleTag({ content: maskCss(opts.mask) });
|
||||
await still.screenshot({ path: path.join(out, 'mask.png') });
|
||||
await still.screenshot({ path: path.join(out, 'mask.png'), animations: 'disabled' });
|
||||
}
|
||||
const manifest = {
|
||||
schema: 1, target: opts.target, width: opts.width, height: opts.height, scale, fps: FPS, cycle: opts.cycle!,
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// still.png, mask.png). check runs the seam, empty-beat and rest checks, and
|
||||
// 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, and ends with "MOTION_RESULT: clean" (exit 0) or
|
||||
// contact-sheet.png, prints "MOTION_SETTINGS: ..." naming any threshold or
|
||||
// phase override, and ends with "MOTION_RESULT: clean" (exit 0) 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
|
||||
@@ -26,8 +27,8 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, mp4Args, parsePsnrStats, planChecks, resolveFfmpeg, smallGifArgs,
|
||||
FFMPEG_MISSING, type CaptureManifest,
|
||||
compareArgs, contactSheetArgs, cropError, framePath, judgeCheck, loopGifArgs, mp4Args, padError, parsePsnrStats, planChecks, resolveFfmpeg,
|
||||
smallGifArgs, FFMPEG_MISSING, type CaptureManifest,
|
||||
} from '../lib/motion-encode';
|
||||
|
||||
const USAGE = 'Usage: gstack-motion-encode check <capture-dir> [options] | export <capture-dir> --name <name> [options] | compare <a.png> <b.png> [--blur <sigma>] [--crop w:h:x:y]';
|
||||
@@ -43,12 +44,23 @@ const positional = firstFlag === -1 ? args : args.slice(0, firstFlag);
|
||||
const rest = firstFlag === -1 ? [] : args.slice(firstFlag);
|
||||
const wanted = cmd === 'compare' ? 2 : 1;
|
||||
if (!['check', 'export', 'compare'].includes(cmd ?? '') || positional.length !== wanted) die(2, USAGE);
|
||||
const KNOWN: Record<string, string[]> = {
|
||||
check: ['--dissolve-at', '--empty-from', '--intro-end', '--layout', '--mask-grow', '--min-db'],
|
||||
export: ['--name', '--formats', '--crop', '--pad', '--gif-fps', '--small-fps'],
|
||||
compare: ['--blur', '--crop'],
|
||||
};
|
||||
const flags = new Map<string, string>();
|
||||
for (let i = 0; i < rest.length; i += 2) {
|
||||
const value = rest[i + 1];
|
||||
if (!rest[i]!.startsWith('--') || value === undefined) die(2, `gstack-motion-encode: bad option '${rest[i]}'\n${USAGE}`);
|
||||
if (!KNOWN[cmd!]!.includes(rest[i]!)) die(2, `gstack-motion-encode: unknown option '${rest[i]}' for ${cmd}\n${USAGE}`);
|
||||
flags.set(rest[i]!, value);
|
||||
}
|
||||
const cropFlag = flags.get('--crop');
|
||||
if (cropFlag) {
|
||||
const err = cropError(cropFlag, { ratio: cmd === 'export' });
|
||||
if (err) die(2, `gstack-motion-encode: ${err}`);
|
||||
}
|
||||
|
||||
const ffmpeg = resolveFfmpeg(process.env, bin => Bun.which(bin), p => fs.existsSync(p));
|
||||
if (!ffmpeg) {
|
||||
@@ -72,7 +84,7 @@ if (cmd === 'compare') {
|
||||
const [a, b] = positional 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)`);
|
||||
if (r.status !== 0 || !frame) die(1, `COMPARE_FAILED ${String(r.stderr).trim().split('\n').pop() || 'no PSNR line'} (same pixel size required)`);
|
||||
console.log(`PSNR ${frame.psnr === Infinity ? 'inf' : frame.psnr.toFixed(2)} dB (${a} vs ${b}${flags.has('--crop') ? `, crop ${flags.get('--crop')}` : ''}${flags.has('--blur') ? `, blur ${flags.get('--blur')}` : ''})`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -113,25 +125,31 @@ if (cmd === 'check') {
|
||||
const sheet = path.join(dir, 'contact-sheet.png');
|
||||
const s = run(contactSheetArgs(manifest, dir, sheet));
|
||||
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`);
|
||||
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 = flags.get('--crop');
|
||||
if (crop) {
|
||||
const err = cropError(crop);
|
||||
const crop = cropFlag;
|
||||
const pad = flags.get('--pad');
|
||||
if (pad) {
|
||||
const err = padError(pad);
|
||||
if (err) die(2, `gstack-motion-encode: ${err}`);
|
||||
}
|
||||
const formats = (flags.get('--formats') ?? 'mp4,loop,800').split(',');
|
||||
const unknownFormat = formats.find(f => !['mp4', 'loop', '800'].includes(f));
|
||||
if (unknownFormat !== undefined) die(2, `gstack-motion-encode: --formats takes mp4, loop and 800, comma separated; got '${unknownFormat}'`);
|
||||
const missing = Array.from({ length: manifest.windowFrames }, (_, k) => framePath(dir, manifest.windowStart + k)).find(f => !fs.existsSync(f));
|
||||
if (missing) die(2, `gstack-motion-encode: ${missing} is missing; the capture is incomplete, so capture and check again before exporting`);
|
||||
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: flags.get('--pad') })]);
|
||||
jobs.push([`${name}-800x500.gif`, smallGifArgs(manifest, dir, path.join(dir, `${name}-800x500.gif`), { fps: num('--small-fps', 20), crop, pad })]);
|
||||
}
|
||||
if (jobs.length === 0) die(2, '--formats takes mp4, loop and 800, comma separated');
|
||||
let failed = 0;
|
||||
for (const [file, args] of jobs) {
|
||||
const r = run(args);
|
||||
|
||||
+15
-15
@@ -350,7 +350,7 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI
|
||||
# /design-motion: Showcase Loops from an Approved Design
|
||||
|
||||
You turn a finished design into a loop that proves it is alive. The viewer must
|
||||
see the exact design, and see that it works: the frame loads from empty, each
|
||||
see the exact design working: the frame loads from empty, each
|
||||
visual acts out what it does, and the loop returns to the design and repeats
|
||||
without a seam.
|
||||
|
||||
@@ -375,10 +375,10 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
|
||||
- **Showcase or website decides the motion budget.** A showcase is an artifact: the motion is the content, so every visual gets its story. On a website gstack's rule holds: **one authored motion moment per page.** Animate the one section whose visual most needs explaining; ask before animating a second.
|
||||
- **Content never hides behind animation timing.** On a website the section renders complete with no script, under reduced motion, and before the intro fires. The intro is an enhancement that starts on an IntersectionObserver, never an opacity-0 block waiting for scroll.
|
||||
- **Only what the design has moves.** No added loading lines, glows, gradients or gradient text, no new copy. Helpers a story needs (a cursor, a duplicate row) match the design and are absent from the still.
|
||||
- **Only what the design has moves.** No added loading lines, glows, gradients or gradient text, no new copy (showcase accents excepted). Helpers a story needs (a cursor, a duplicate row) match the design and are absent from the still.
|
||||
- **No bounce.** Curves never pass 1 (the catalog's bounce-easing tell). Never `scale(0)` an object; only lines and bars grow from zero.
|
||||
- **Pulses, marquees and blinking carets are showcase-only accents** on elements that are live indicators, inputs or logo rows in the design. A shipped website keeps none of them running (catalog: pulsing-dot, marquee, blinking-cursor).
|
||||
- **Animate `transform`, `opacity`, `filter` and `clip-path` only.** Blur stays at 14 px or less. Website transitions stay in gstack's 50 to 700 ms range; showcase timing is slower on purpose.
|
||||
- **Animate `transform`, `opacity`, `filter` and `clip-path` only** (plus a mask angle or `stroke-dashoffset` for sweeps). Blur stays at 14 px or less. Website transitions stay in gstack's 50 to 700 ms range; showcase timing is slower on purpose.
|
||||
|
||||
## Step 0: Setup and source detection
|
||||
|
||||
@@ -392,7 +392,7 @@ _FINALIZED=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/finalized.html
|
||||
[ -n "$_FINALIZED" ] && echo "FINALIZED: $_FINALIZED" || echo "NO_FINALIZED"
|
||||
[ -f DESIGN.md ] && echo "DESIGN_MD: exists" || echo "NO_DESIGN_MD"
|
||||
[ -n "${FIGMA_TOKEN:-}${FIGMA_ACCESS_TOKEN:-}" ] && echo "FIGMA_TOKEN: set" || echo "NO_FIGMA_TOKEN"
|
||||
command -v ffmpeg >/dev/null 2>&1 && echo "FFMPEG: $(command -v ffmpeg)" || echo "NO_FFMPEG"
|
||||
[ -e "${GSTACK_FFMPEG:-$(command -v ffmpeg)}" ] && echo "FFMPEG: ready" || echo "NO_FFMPEG"
|
||||
~/.claude/skills/gstack/bin/gstack-browser-ensure --quiet
|
||||
```
|
||||
|
||||
@@ -405,7 +405,7 @@ command -v ffmpeg >/dev/null 2>&1 && echo "FFMPEG: $(command -v ffmpeg)" || echo
|
||||
|
||||
Nothing found and nothing named: ask once which design to animate, offering /design-shotgun or /design-html to make one.
|
||||
|
||||
`BROWSER_UNAVAILABLE` from the browser check: capture cannot run; tell the user its fix line and stop after Step 3. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page anyway, and stop before Step 6.
|
||||
`BROWSER_UNAVAILABLE`: nothing can render; give its fix line, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page, render its Step 3 still for a visual check, and stop.
|
||||
|
||||
Set up the run directory:
|
||||
|
||||
@@ -417,7 +417,7 @@ mkdir -p "$_MOTION_DIR"
|
||||
echo "MOTION_DIR: $_MOTION_DIR"
|
||||
```
|
||||
|
||||
Replace `<name>` with a kebab-case name for the design. Everything this run writes goes there: `page.html`, `reference.png`, the capture in `cap/`, and the exports.
|
||||
Replace `<name>` with a kebab-case name for the design. Later blocks write that printed path for `<motion-dir>`. Everything this run writes goes there: `page.html`, `reference.png`, the capture in `cap/`, and the exports.
|
||||
|
||||
## Step 1: Intake and the motion plan
|
||||
|
||||
@@ -449,11 +449,11 @@ With `FIGMA_TOKEN: set` and a Figma source:
|
||||
Render the rebuilt page's still and the reference at 2x, then compare:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-motion-capture.ts "$_MOTION_DIR/page.html" --size <frame-size> --still "$_MOTION_DIR/still-2x.png" --scale 2
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode compare "$_MOTION_DIR/reference.png" "$_MOTION_DIR/still-2x.png" --blur 1.2
|
||||
~/.claude/skills/gstack/bin/gstack-motion-capture.ts "<motion-dir>/page.html" --size <frame-size> --still "<motion-dir>/still-2x.png" --scale 2
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode compare "<motion-dir>/reference.png" "<motion-dir>/still-2x.png" --blur 1.2
|
||||
```
|
||||
|
||||
Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text-heavy containers retest at `--blur 2.5`). Read both images and look for structure: a glow too high, wrong stacking, a missing stroke, a shifted shadow. Fix structure, never sub-pixel offsets. A flat PNG source gets a visual comparison and a plainly stated limit instead of a dB target.
|
||||
Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text-heavy containers retest at `--blur 2.5`). Read both images for structure: a glow too high, wrong stacking, a missing stroke. Fix structure, never sub-pixel offsets. A flat PNG source gets a visual comparison and a plainly stated limit instead of a dB target.
|
||||
|
||||
## Step 4: Motion
|
||||
|
||||
@@ -465,18 +465,18 @@ Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text
|
||||
The page must expose the capture contract (fidelity section): `?capture` turns off `requestAnimationFrame` and exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`.
|
||||
|
||||
```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]'
|
||||
~/.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]'
|
||||
```
|
||||
|
||||
It steps a fake clock in whole milliseconds averaging 1000/60, records 15 PRE frames before `__start`, one full cycle from frame PRE − 1 (the empty state), and 10 frames past it. It writes `cap/frames/`, `cap/still.png` at the capture scale, `cap/mask.png` and `cap/capture.json`, and prints `CAPTURE_OK`. The mask paints every element marked `data-motion-clip` (each clip container, per the fidelity section). `MOTION_CONTRACT_MISSING`: add the capture mode and rerun.
|
||||
It steps a fake clock in whole milliseconds averaging 1000/60, records 15 PRE frames before `__start`, one full cycle from frame PRE − 1 (the empty state), and 10 frames past it. It writes `cap/frames/`, `cap/still.png`, `cap/mask.png` and `cap/capture.json`, and prints `CAPTURE_OK`. The mask paints every element marked `data-motion-clip` (each clip container, per the fidelity section). `MOTION_CONTRACT_MISSING`: add the capture mode and rerun.
|
||||
|
||||
## Step 6: Motion checks (before any export)
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode check "$_MOTION_DIR/cap" --layout <app|cards>
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode check "<motion-dir>/cap" --layout <app|cards>
|
||||
```
|
||||
|
||||
The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat from cycle − 0.4 s, intro landed at 2.4 s. When your plan's phases differ, add `--dissolve-at`, `--empty-from` and `--intro-end` in seconds. One `MOTION_CHECK:` line each, then `MOTION_RESULT: clean` or `MOTION_RESULT: <n> failed`:
|
||||
The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat from cycle − 0.4 s, intro landed at 2.4 s. When your plan's phases differ, add `--dissolve-at`, `--empty-from` and `--intro-end` in seconds. One `MOTION_CHECK:` line each, then `MOTION_RESULT: clean` or `<n> failed`:
|
||||
|
||||
- **seam**: the window's first frame equals the frame one cycle later.
|
||||
- **empty-beat**: every empty-beat frame equals the frame before `__start`; catches JS state the reset missed, which the seam cannot see.
|
||||
@@ -490,10 +490,10 @@ Read `cap/contact-sheet.png` and look for ghosting, pops, content moving ahead o
|
||||
On the first design of a session, show the contact sheet before exporting so the direction can be corrected cheaply.
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode export "$_MOTION_DIR/cap" --name <name> --pad '<hex-color>'
|
||||
~/.claude/skills/gstack/bin/gstack-motion-encode export "<motion-dir>/cap" --name <name> --pad '<hex-color>'
|
||||
```
|
||||
|
||||
It writes `<name>-x.mp4` (H.264, yuv420p, 60 fps, CRF 15, faststart, even dimensions, long side up to 1920), `<name>-loop.gif` (960 px wide, 20 fps) and `<name>-800x500.gif`. For the 800×500, frames with wide empty borders (most card and bento frames) crop to the content with about 40 px of margin at exactly 1.6:1 (add `--crop w:h:x:y` in capture pixels); a frame its content fills is fitted and padded with `--pad`; a photo background always crops. A heavy GIF lowers `--gif-fps` or `--small-fps` (12) before its size.
|
||||
It writes `<name>-x.mp4` (H.264, 60 fps, CRF 15, long side up to 1920), `<name>-loop.gif` (960 px wide, 20 fps) and `<name>-800x500.gif`. For the 800×500, frames with wide empty borders (most card and bento frames) crop to the content with about 40 px of margin at exactly 1.6:1 (add `--crop w:h:x:y` in capture pixels); a frame its content fills is fitted and padded with `--pad`; a photo background always crops. A heavy GIF lowers `--gif-fps` or `--small-fps` (12) before its size.
|
||||
|
||||
Report: still fidelity (frame and worst container PSNR), each check's result, the three files with size, dimensions, frame rate and duration, and anything not reproduced exactly, named plainly. Offer to copy the exports into the project.
|
||||
|
||||
|
||||
+15
-15
@@ -28,7 +28,7 @@ allowed-tools:
|
||||
# /design-motion: Showcase Loops from an Approved Design
|
||||
|
||||
You turn a finished design into a loop that proves it is alive. The viewer must
|
||||
see the exact design, and see that it works: the frame loads from empty, each
|
||||
see the exact design working: the frame loads from empty, each
|
||||
visual acts out what it does, and the loop returns to the design and repeats
|
||||
without a seam.
|
||||
|
||||
@@ -44,10 +44,10 @@ without a seam.
|
||||
|
||||
- **Showcase or website decides the motion budget.** A showcase is an artifact: the motion is the content, so every visual gets its story. On a website gstack's rule holds: **one authored motion moment per page.** Animate the one section whose visual most needs explaining; ask before animating a second.
|
||||
- **Content never hides behind animation timing.** On a website the section renders complete with no script, under reduced motion, and before the intro fires. The intro is an enhancement that starts on an IntersectionObserver, never an opacity-0 block waiting for scroll.
|
||||
- **Only what the design has moves.** No added loading lines, glows, gradients or gradient text, no new copy. Helpers a story needs (a cursor, a duplicate row) match the design and are absent from the still.
|
||||
- **Only what the design has moves.** No added loading lines, glows, gradients or gradient text, no new copy (showcase accents excepted). Helpers a story needs (a cursor, a duplicate row) match the design and are absent from the still.
|
||||
- **No bounce.** Curves never pass 1 (the catalog's bounce-easing tell). Never `scale(0)` an object; only lines and bars grow from zero.
|
||||
- **Pulses, marquees and blinking carets are showcase-only accents** on elements that are live indicators, inputs or logo rows in the design. A shipped website keeps none of them running (catalog: pulsing-dot, marquee, blinking-cursor).
|
||||
- **Animate `transform`, `opacity`, `filter` and `clip-path` only.** Blur stays at 14 px or less. Website transitions stay in gstack's 50 to 700 ms range; showcase timing is slower on purpose.
|
||||
- **Animate `transform`, `opacity`, `filter` and `clip-path` only** (plus a mask angle or `stroke-dashoffset` for sweeps). Blur stays at 14 px or less. Website transitions stay in gstack's 50 to 700 ms range; showcase timing is slower on purpose.
|
||||
|
||||
## Step 0: Setup and source detection
|
||||
|
||||
@@ -61,7 +61,7 @@ _FINALIZED=$(ls -t "$GSTACK_STATE_ROOT/projects/$SLUG/designs/"*/finalized.html
|
||||
[ -n "$_FINALIZED" ] && echo "FINALIZED: $_FINALIZED" || echo "NO_FINALIZED"
|
||||
[ -f DESIGN.md ] && echo "DESIGN_MD: exists" || echo "NO_DESIGN_MD"
|
||||
[ -n "${FIGMA_TOKEN:-}${FIGMA_ACCESS_TOKEN:-}" ] && echo "FIGMA_TOKEN: set" || echo "NO_FIGMA_TOKEN"
|
||||
command -v ffmpeg >/dev/null 2>&1 && echo "FFMPEG: $(command -v ffmpeg)" || echo "NO_FFMPEG"
|
||||
[ -e "${GSTACK_FFMPEG:-$(command -v ffmpeg)}" ] && echo "FFMPEG: ready" || echo "NO_FFMPEG"
|
||||
{{BIN_DIR}}/gstack-browser-ensure --quiet
|
||||
```
|
||||
|
||||
@@ -74,7 +74,7 @@ command -v ffmpeg >/dev/null 2>&1 && echo "FFMPEG: $(command -v ffmpeg)" || echo
|
||||
|
||||
Nothing found and nothing named: ask once which design to animate, offering /design-shotgun or /design-html to make one.
|
||||
|
||||
`BROWSER_UNAVAILABLE` from the browser check: capture cannot run; tell the user its fix line and stop after Step 3. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page anyway, and stop before Step 6.
|
||||
`BROWSER_UNAVAILABLE`: nothing can render; give its fix line, build `page.html` and stop before the first capture call. `NO_FFMPEG`: say ffmpeg is needed for the checks and exports (macOS `brew install ffmpeg`, Debian/Ubuntu `sudo apt-get install -y ffmpeg`, Windows `winget install Gyan.FFmpeg`), build the page, render its Step 3 still for a visual check, and stop.
|
||||
|
||||
Set up the run directory:
|
||||
|
||||
@@ -86,7 +86,7 @@ mkdir -p "$_MOTION_DIR"
|
||||
echo "MOTION_DIR: $_MOTION_DIR"
|
||||
```
|
||||
|
||||
Replace `<name>` with a kebab-case name for the design. Everything this run writes goes there: `page.html`, `reference.png`, the capture in `cap/`, and the exports.
|
||||
Replace `<name>` with a kebab-case name for the design. Later blocks write that printed path for `<motion-dir>`. Everything this run writes goes there: `page.html`, `reference.png`, the capture in `cap/`, and the exports.
|
||||
|
||||
## Step 1: Intake and the motion plan
|
||||
|
||||
@@ -116,11 +116,11 @@ With `FIGMA_TOKEN: set` and a Figma source:
|
||||
Render the rebuilt page's still and the reference at 2x, then compare:
|
||||
|
||||
```bash
|
||||
{{BIN_DIR}}/gstack-motion-capture.ts "$_MOTION_DIR/page.html" --size <frame-size> --still "$_MOTION_DIR/still-2x.png" --scale 2
|
||||
{{BIN_DIR}}/gstack-motion-encode compare "$_MOTION_DIR/reference.png" "$_MOTION_DIR/still-2x.png" --blur 1.2
|
||||
{{BIN_DIR}}/gstack-motion-capture.ts "<motion-dir>/page.html" --size <frame-size> --still "<motion-dir>/still-2x.png" --scale 2
|
||||
{{BIN_DIR}}/gstack-motion-encode compare "<motion-dir>/reference.png" "<motion-dir>/still-2x.png" --blur 1.2
|
||||
```
|
||||
|
||||
Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text-heavy containers retest at `--blur 2.5`). Read both images and look for structure: a glow too high, wrong stacking, a missing stroke, a shifted shadow. Fix structure, never sub-pixel offsets. A flat PNG source gets a visual comparison and a plainly stated limit instead of a dB target.
|
||||
Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text-heavy containers retest at `--blur 2.5`). Read both images for structure: a glow too high, wrong stacking, a missing stroke. Fix structure, never sub-pixel offsets. A flat PNG source gets a visual comparison and a plainly stated limit instead of a dB target.
|
||||
|
||||
## Step 4: Motion
|
||||
|
||||
@@ -131,18 +131,18 @@ Aim above 35 dB for the frame (`--crop w:h:x:y` per container: above 30 dB; text
|
||||
The page must expose the capture contract (fidelity section): `?capture` turns off `requestAnimationFrame` and exposes `window.__start()`, `window.__tick(dt)` and `window.__lt`.
|
||||
|
||||
```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]'
|
||||
{{BIN_DIR}}/gstack-motion-capture.ts "<motion-dir>/page.html" --size <frame-size> --cycle <cycle-seconds> --out "<motion-dir>/cap" --mask '[data-motion-clip]'
|
||||
```
|
||||
|
||||
It steps a fake clock in whole milliseconds averaging 1000/60, records 15 PRE frames before `__start`, one full cycle from frame PRE − 1 (the empty state), and 10 frames past it. It writes `cap/frames/`, `cap/still.png` at the capture scale, `cap/mask.png` and `cap/capture.json`, and prints `CAPTURE_OK`. The mask paints every element marked `data-motion-clip` (each clip container, per the fidelity section). `MOTION_CONTRACT_MISSING`: add the capture mode and rerun.
|
||||
It steps a fake clock in whole milliseconds averaging 1000/60, records 15 PRE frames before `__start`, one full cycle from frame PRE − 1 (the empty state), and 10 frames past it. It writes `cap/frames/`, `cap/still.png`, `cap/mask.png` and `cap/capture.json`, and prints `CAPTURE_OK`. The mask paints every element marked `data-motion-clip` (each clip container, per the fidelity section). `MOTION_CONTRACT_MISSING`: add the capture mode and rerun.
|
||||
|
||||
## Step 6: Motion checks (before any export)
|
||||
|
||||
```bash
|
||||
{{BIN_DIR}}/gstack-motion-encode check "$_MOTION_DIR/cap" --layout <app|cards>
|
||||
{{BIN_DIR}}/gstack-motion-encode check "<motion-dir>/cap" --layout <app|cards>
|
||||
```
|
||||
|
||||
The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat from cycle − 0.4 s, intro landed at 2.4 s. When your plan's phases differ, add `--dissolve-at`, `--empty-from` and `--intro-end` in seconds. One `MOTION_CHECK:` line each, then `MOTION_RESULT: clean` or `MOTION_RESULT: <n> failed`:
|
||||
The phase defaults follow the cycle: dissolve at cycle − 1.0 s, empty beat from cycle − 0.4 s, intro landed at 2.4 s. When your plan's phases differ, add `--dissolve-at`, `--empty-from` and `--intro-end` in seconds. One `MOTION_CHECK:` line each, then `MOTION_RESULT: clean` or `<n> failed`:
|
||||
|
||||
- **seam**: the window's first frame equals the frame one cycle later.
|
||||
- **empty-beat**: every empty-beat frame equals the frame before `__start`; catches JS state the reset missed, which the seam cannot see.
|
||||
@@ -156,10 +156,10 @@ Read `cap/contact-sheet.png` and look for ghosting, pops, content moving ahead o
|
||||
On the first design of a session, show the contact sheet before exporting so the direction can be corrected cheaply.
|
||||
|
||||
```bash
|
||||
{{BIN_DIR}}/gstack-motion-encode export "$_MOTION_DIR/cap" --name <name> --pad '<hex-color>'
|
||||
{{BIN_DIR}}/gstack-motion-encode export "<motion-dir>/cap" --name <name> --pad '<hex-color>'
|
||||
```
|
||||
|
||||
It writes `<name>-x.mp4` (H.264, yuv420p, 60 fps, CRF 15, faststart, even dimensions, long side up to 1920), `<name>-loop.gif` (960 px wide, 20 fps) and `<name>-800x500.gif`. For the 800×500, frames with wide empty borders (most card and bento frames) crop to the content with about 40 px of margin at exactly 1.6:1 (add `--crop w:h:x:y` in capture pixels); a frame its content fills is fitted and padded with `--pad`; a photo background always crops. A heavy GIF lowers `--gif-fps` or `--small-fps` (12) before its size.
|
||||
It writes `<name>-x.mp4` (H.264, 60 fps, CRF 15, long side up to 1920), `<name>-loop.gif` (960 px wide, 20 fps) and `<name>-800x500.gif`. For the 800×500, frames with wide empty borders (most card and bento frames) crop to the content with about 40 px of margin at exactly 1.6:1 (add `--crop w:h:x:y` in capture pixels); a frame its content fills is fitted and padded with `--pad`; a photo background always crops. A heavy GIF lowers `--gif-fps` or `--small-fps` (12) before its size.
|
||||
|
||||
Report: still fidelity (frame and worst container PSNR), each check's result, the three files with size, dimensions, frame rate and duration, and anything not reproduced exactly, named plainly. Offer to copy the exports into the project.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ A showcase is the design unchanged, made alive. Rebuild it first and touch no mo
|
||||
|
||||
### By source
|
||||
|
||||
- **finalized.html.** Copy it to `page.html` and keep its markup, fonts and tokens; wrap the frame in the stage below. Render the reference from the original: `~/.claude/skills/gstack/bin/gstack-motion-capture.ts <finalized.html> --size <frame-size> --still "$_MOTION_DIR/reference.png" --scale 2` (a page without the modes ignores `?still`).
|
||||
- **finalized.html.** Copy it to `page.html` and keep its markup, fonts and tokens; wrap the frame in the stage below. Render the reference from the original: `~/.claude/skills/gstack/bin/gstack-motion-capture.ts <finalized.html> --size <frame-size> --still "<motion-dir>/reference.png" --scale 2` (a page without the modes ignores `?still`).
|
||||
- **URL.** Render the reference the same way from the URL. Rebuild `page.html` from the live page's markup and styles with images inlined, never from a screenshot.
|
||||
- **PNG** (an approved /design-shotgun variant, or a screenshot the user gave). Copy it to `reference.png`; it is the reference. The frame size in CSS pixels is the design's intended frame (DESIGN.md, or the PNG's size when nothing says it is 2x); render the still at the scale that matches the PNG's pixels. Rebuild surfaces, text, bars and charts as real elements in DESIGN.md's fonts and tokens, and crop photos and illustrations out of the PNG as their own layers. Before animating, tell the user which parts cannot be separated (text baked into a photo, overlapping translucent layers). Never animate the screenshot as one block.
|
||||
- **Figma** with a token: the Figma section's export path.
|
||||
|
||||
@@ -4,7 +4,7 @@ A showcase is the design unchanged, made alive. Rebuild it first and touch no mo
|
||||
|
||||
### By source
|
||||
|
||||
- **finalized.html.** Copy it to `page.html` and keep its markup, fonts and tokens; wrap the frame in the stage below. Render the reference from the original: `{{BIN_DIR}}/gstack-motion-capture.ts <finalized.html> --size <frame-size> --still "$_MOTION_DIR/reference.png" --scale 2` (a page without the modes ignores `?still`).
|
||||
- **finalized.html.** Copy it to `page.html` and keep its markup, fonts and tokens; wrap the frame in the stage below. Render the reference from the original: `{{BIN_DIR}}/gstack-motion-capture.ts <finalized.html> --size <frame-size> --still "<motion-dir>/reference.png" --scale 2` (a page without the modes ignores `?still`).
|
||||
- **URL.** Render the reference the same way from the URL. Rebuild `page.html` from the live page's markup and styles with images inlined, never from a screenshot.
|
||||
- **PNG** (an approved /design-shotgun variant, or a screenshot the user gave). Copy it to `reference.png`; it is the reference. The frame size in CSS pixels is the design's intended frame (DESIGN.md, or the PNG's size when nothing says it is 2x); render the still at the scale that matches the PNG's pixels. Rebuild surfaces, text, bars and charts as real elements in DESIGN.md's fonts and tokens, and crop photos and illustrations out of the PNG as their own layers. Before animating, tell the user which parts cannot be separated (text baked into a photo, overlapping translucent layers). Never animate the screenshot as one block.
|
||||
- **Figma** with a token: the Figma section's export path.
|
||||
|
||||
@@ -6,8 +6,8 @@ Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the s
|
||||
|
||||
```bash
|
||||
_FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files/<figma-file-key>/nodes?ids=<figma-node-id>&geometry=paths" -o "$_MOTION_DIR/figma-nodes.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/<figma-file-key>?ids=<figma-node-id>&format=png&scale=2" -o "$_MOTION_DIR/figma-ref.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files/<figma-file-key>/nodes?ids=<figma-node-id>&geometry=paths" -o "<motion-dir>/figma-nodes.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/<figma-file-key>?ids=<figma-node-id>&format=png&scale=2" -o "<motion-dir>/figma-ref.json"
|
||||
```
|
||||
|
||||
The nodes response gives the tree, bounds, fills, effects and radii; the images response gives a URL for the **reference frame**, saved as `reference.png`. Every layer position is its absolute bounds minus the frame's origin. Figma's image CDN sometimes resets connections: retry each download and check each PNG's size before using it.
|
||||
|
||||
@@ -4,8 +4,8 @@ Use this path only when `FIGMA_TOKEN` (or `FIGMA_ACCESS_TOKEN`) is set and the s
|
||||
|
||||
```bash
|
||||
_FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files/<figma-file-key>/nodes?ids=<figma-node-id>&geometry=paths" -o "$_MOTION_DIR/figma-nodes.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/<figma-file-key>?ids=<figma-node-id>&format=png&scale=2" -o "$_MOTION_DIR/figma-ref.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/files/<figma-file-key>/nodes?ids=<figma-node-id>&geometry=paths" -o "<motion-dir>/figma-nodes.json"
|
||||
curl -fsS -H "X-Figma-Token: $_FT" "https://api.figma.com/v1/images/<figma-file-key>?ids=<figma-node-id>&format=png&scale=2" -o "<motion-dir>/figma-ref.json"
|
||||
```
|
||||
|
||||
The nodes response gives the tree, bounds, fills, effects and radii; the images response gives a URL for the **reference frame**, saved as `reference.png`. Every layer position is its absolute bounds minus the frame's origin. Figma's image CDN sometimes resets connections: retry each download and check each PNG's size before using it.
|
||||
|
||||
@@ -6,7 +6,7 @@ Every timing, distance and curve comes from the defaults table at the end. Never
|
||||
|
||||
### Start empty
|
||||
|
||||
The first frame of every page load and every export is the empty state: the background plus the static joined surfaces for an app frame, the background alone for separate cards. Empty becomes loaded; never open on the finished frame.
|
||||
The first frame of every showcase load and every export is the empty state (on a website the doctrine wins: the markup is the finished state until the intro fires): the background plus the static joined surfaces for an app frame, the background alone for separate cards. Empty becomes loaded; never open on the finished frame.
|
||||
|
||||
### Loading choreography
|
||||
|
||||
@@ -62,6 +62,7 @@ The intro only opens the show. The main act is each visual acting out what it do
|
||||
|
||||
```js
|
||||
// [[time, value], ...] eased per segment; values may be numbers or arrays
|
||||
const easeInOut = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); // --io, never past 1
|
||||
function track(keys, t, ease = easeInOut) {
|
||||
if (t <= keys[0][0]) return keys[0][1];
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
@@ -113,7 +114,7 @@ On a website the intro starts when about 30% of the section is visible (`Interse
|
||||
| Durations (showcase) | transforms 1.05 s `--e` (cards 0.9 s); opacity and blur 0.7-0.8 s `--e2` (cards 0.6 s); wipes 0.7 s, slow bars 1.1 s `--io` |
|
||||
| Durations (website) | 50-700 ms |
|
||||
| Stagger | containers 80-90 ms, items 30-80 ms |
|
||||
| Typing | one glyph per 45-70 ms, caret blink 1 s |
|
||||
| Typing | one glyph per 45-70 ms, caret blink 1 s (showcase only) |
|
||||
| Story | starts 2.4 s, 3-8 s per card, offset between cards |
|
||||
| Cycle | 12 s (dissolve 11.0, reset 11.6), longer when a story needs it |
|
||||
| Exports | MP4 60 fps CRF 15 up to 1920×1200; loop GIF 960 px 20 fps; small GIF 800×500 20 fps |
|
||||
|
||||
@@ -4,7 +4,7 @@ Every timing, distance and curve comes from the defaults table at the end. Never
|
||||
|
||||
### Start empty
|
||||
|
||||
The first frame of every page load and every export is the empty state: the background plus the static joined surfaces for an app frame, the background alone for separate cards. Empty becomes loaded; never open on the finished frame.
|
||||
The first frame of every showcase load and every export is the empty state (on a website the doctrine wins: the markup is the finished state until the intro fires): the background plus the static joined surfaces for an app frame, the background alone for separate cards. Empty becomes loaded; never open on the finished frame.
|
||||
|
||||
### Loading choreography
|
||||
|
||||
@@ -60,6 +60,7 @@ The intro only opens the show. The main act is each visual acting out what it do
|
||||
|
||||
```js
|
||||
// [[time, value], ...] eased per segment; values may be numbers or arrays
|
||||
const easeInOut = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); // --io, never past 1
|
||||
function track(keys, t, ease = easeInOut) {
|
||||
if (t <= keys[0][0]) return keys[0][1];
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
@@ -111,7 +112,7 @@ On a website the intro starts when about 30% of the section is visible (`Interse
|
||||
| Durations (showcase) | transforms 1.05 s `--e` (cards 0.9 s); opacity and blur 0.7-0.8 s `--e2` (cards 0.6 s); wipes 0.7 s, slow bars 1.1 s `--io` |
|
||||
| Durations (website) | 50-700 ms |
|
||||
| Stagger | containers 80-90 ms, items 30-80 ms |
|
||||
| Typing | one glyph per 45-70 ms, caret blink 1 s |
|
||||
| Typing | one glyph per 45-70 ms, caret blink 1 s (showcase only) |
|
||||
| Story | starts 2.4 s, 3-8 s per card, offset between cards |
|
||||
| Cycle | 12 s (dissolve 11.0, reset 11.6), longer when a story needs it |
|
||||
| Exports | MP4 60 fps CRF 15 up to 1920×1200; loop GIF 960 px 20 fps; small GIF 800×500 20 fps |
|
||||
|
||||
@@ -31,9 +31,9 @@ export function clockMs(i: number, pre: number, fps = FPS): number {
|
||||
return Math.round((i + 1) * 1000 / fps) - Math.round(pre * 1000 / fps);
|
||||
}
|
||||
|
||||
/** Device scale factor that puts the frame's long side on X's 1920x1200 limit. */
|
||||
export function captureScale(width: number, height: number, maxW = MAX_LONG, maxH = MAX_SHORT): number {
|
||||
return Math.min(maxW / width, maxH / height);
|
||||
/** Device scale factor that puts the frame's long side on X's 1920x1200 limit (1200x1920 for a portrait frame). */
|
||||
export function captureScale(width: number, height: number, maxLong = MAX_LONG, maxShort = MAX_SHORT): number {
|
||||
return Math.min(maxLong / Math.max(width, height), maxShort / Math.min(width, height));
|
||||
}
|
||||
|
||||
export interface CapturePlan {
|
||||
@@ -116,6 +116,9 @@ 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 (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' };
|
||||
if (out && flags.has('--scale')) return { error: '--scale belongs to --still; a capture always uses the 1920x1200 scale' };
|
||||
|
||||
@@ -79,16 +79,22 @@ export function smallGifArgs(m: CaptureManifest, dir: string, out: string, o: Gi
|
||||
'scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos', `pad=800:500:(ow-iw)/2:(oh-ih)/2:color=${pad}`]);
|
||||
}
|
||||
|
||||
/** A crop for the 800x500 GIF must be exactly 1.6:1 (one pixel of rounding allowed). */
|
||||
export function cropError(crop: string): string | null {
|
||||
/** A crop must be w:h:x:y in whole pixels; for the 800x500 GIF (ratio) it must also be 1.6:1, one pixel of rounding allowed. */
|
||||
export function cropError(crop: string, o: { ratio?: boolean } = { ratio: true }): string | null {
|
||||
const m = /^(\d+):(\d+):(\d+):(\d+)$/.exec(crop);
|
||||
if (!m) return `--crop must be w:h:x:y in capture pixels, got '${crop}'`;
|
||||
if (!o.ratio) return null;
|
||||
const w = Number(m[1]);
|
||||
const h = Number(m[2]);
|
||||
if (Math.abs(w - h * 1.6) > 1) return `--crop ${w}x${h} is not 1.6:1 (for height ${h} use width ${Math.round(h * 1.6)})`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The 800x500 pad colour goes into the filter graph, so it must be a plain #RRGGBB[AA] hex colour (ffmpeg rejects #RGB). */
|
||||
export function padError(pad: string): string | null {
|
||||
return /^#?(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(pad) ? null : `--pad must be a hex colour such as '#f4f1ea', got '${pad}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PSNR of a run of frames against one reference image. With a mask, pixels the
|
||||
* mask (grown by maskGrow pixels) marks white are blacked out on both sides,
|
||||
@@ -214,7 +220,7 @@ export function contactSheetArgs(m: CaptureManifest, dir: string, out: string):
|
||||
const picks = Array.from({ length: 12 }, (_, k) => Math.round((k * (m.windowFrames - 1)) / 11));
|
||||
const select = picks.map(p => `eq(n\\,${p})`).join('+');
|
||||
return [...QUIET, '-start_number', String(m.windowStart), '-i', framePattern(dir),
|
||||
'-vf', `select='${select}',scale=480:-2,tile=4x3:padding=8:color=0x808080`, '-frames:v', '1', '-fps_mode', 'passthrough', out];
|
||||
'-vf', `select='${select}',scale=480:-2,tile=4x3:padding=8:color=0x808080`, '-frames:v', '1', out];
|
||||
}
|
||||
|
||||
export const FFMPEG_MISSING = 'FFMPEG_MISSING: ffmpeg is not installed or not on PATH (set GSTACK_FFMPEG to its full path). '
|
||||
|
||||
@@ -77,7 +77,7 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
'<frame-size>': { grammar: /^[0-9]+x[0-9]+$/, what: 'design frame size in CSS pixels, WxH' },
|
||||
'<cycle-seconds>': { grammar: NUMBER, what: 'loop cycle length in seconds' },
|
||||
'<app|cards>': { grammar: oneOf('app', 'cards'), what: 'motion layout: joined app surfaces or separate cards' },
|
||||
'<hex-color>': { grammar: /^#[0-9A-Fa-f]{3,8}$/, what: 'page background colour as hex', quoted: true },
|
||||
'<hex-color>': { grammar: /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/, what: 'page background colour as hex', quoted: true },
|
||||
'<figma-file-key>': { grammar: ID, what: 'Figma file key from the link' },
|
||||
'<figma-node-id>': { grammar: /^[0-9]+[:-][0-9]+$/, what: 'Figma node id from the link' },
|
||||
'<new_value>': { grammar: NUMBER, what: 'profile dimension value' },
|
||||
@@ -147,6 +147,7 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
'<sketch-dir>': { grammar: PATH, what: 'sketch directory printed by an earlier block' },
|
||||
'<path-to-finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
|
||||
'<finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
|
||||
'<motion-dir>': { grammar: PATH, what: 'run directory printed by the /design-motion Step 0 MOTION_DIR line' },
|
||||
'<approved-variant.png>': { grammar: PATH, what: 'approved variant image path' },
|
||||
'<failing-test-file>': { grammar: PATH, what: 'test file path' },
|
||||
'<source-file-under-test>': { grammar: PATH, what: 'source file path' },
|
||||
|
||||
@@ -47,6 +47,11 @@ describe('capture timing', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +79,17 @@ describe('capture arguments', () => {
|
||||
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');
|
||||
// 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 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');
|
||||
});
|
||||
|
||||
test('modes keep the existing query and fragment', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { clockMs, planCapture } from '../lib/motion-capture';
|
||||
import {
|
||||
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, mp4Args, parsePsnrStats, planChecks, psnrArgs,
|
||||
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, mp4Args, padError, parsePsnrStats, planChecks, psnrArgs,
|
||||
resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions,
|
||||
} from '../lib/motion-encode';
|
||||
|
||||
@@ -55,6 +55,18 @@ describe('export recipes', () => {
|
||||
expect(cropError('1600x1000')).toContain('w:h:x:y');
|
||||
});
|
||||
|
||||
// Value: protects=values that reach the ffmpeg filter graph (--pad, compare's --crop) are plain colours and w:h:x:y;
|
||||
// fails_when=a value with ',' or ';' reaches the graph and adds filters or inputs; why_new=pad and compare's crop were unvalidated;
|
||||
// seam=none; no_claim=validators only; the CLI refusal rows are in the exit-2 test below
|
||||
test('filter-graph values are validated: a hex pad and a w:h:x:y crop', () => {
|
||||
expect(padError('#f4f1ea')).toBeNull();
|
||||
expect(padError('f4f1eaff')).toBeNull();
|
||||
expect(padError('#fff')).toContain('hex colour');
|
||||
expect(padError('#ffffff,hue=s=0')).toContain('hex colour');
|
||||
expect(cropError('1000:1000:0:0', { ratio: false })).toBeNull();
|
||||
expect(cropError('100:100:0:0,hue=s=0', { ratio: false })).toContain('w:h:x:y');
|
||||
});
|
||||
|
||||
test('the contact sheet tiles twelve frames from the first to the last of the window', () => {
|
||||
const args = contactSheetArgs(m, '/c', '/c/sheet.png');
|
||||
const vf = args[args.indexOf('-vf') + 1]!;
|
||||
@@ -176,4 +188,49 @@ describe('ffmpeg is optional and named when missing', () => {
|
||||
expect(r.exitCode).toBe(2);
|
||||
expect(r.stderr.toString()).toContain('Usage: gstack-motion-encode check');
|
||||
});
|
||||
|
||||
// Value: protects=an unreadable capture, a bad --layout, impossible phases, a missing --name and a non-1.6:1 crop exit 2 before any ffmpeg run;
|
||||
// fails_when=a guard is dropped and ffmpeg runs on bad input or the CLI exits 0/1; why_new=only the no-subcommand usage path was tested; seam=none;
|
||||
// no_claim=a stand-in file satisfies GSTACK_FFMPEG resolution and is never executed, so real ffmpeg is not exercised
|
||||
test('bad captures and bad options exit 2 once ffmpeg resolves, before it runs', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-usage-'));
|
||||
try {
|
||||
const fake = path.join(dir, 'fake-ffmpeg');
|
||||
fs.writeFileSync(fake, '');
|
||||
const cli = (...args: string[]) => {
|
||||
const r = Bun.spawnSync([BIN, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: fake } });
|
||||
return { code: r.exitCode, err: r.stderr.toString() };
|
||||
};
|
||||
const empty = path.join(dir, 'empty');
|
||||
fs.mkdirSync(empty);
|
||||
const missing = cli('check', empty);
|
||||
expect(missing.code).toBe(2);
|
||||
expect(missing.err).toContain('capture.json');
|
||||
expect(missing.err).toContain('capture first with gstack-motion-capture.ts');
|
||||
const cap = path.join(dir, 'cap');
|
||||
fs.mkdirSync(cap);
|
||||
fs.writeFileSync(path.join(cap, 'capture.json'), JSON.stringify(manifest()));
|
||||
expect(cli('check', cap, '--layout', 'grid')).toEqual({ code: 2, err: '--layout must be app or cards\n' });
|
||||
const phases = cli('check', cap, '--dissolve-at', '13');
|
||||
expect(phases.code).toBe(2);
|
||||
expect(phases.err).toContain('intro-end < dissolve-at <= empty-from < cycle');
|
||||
const name = cli('export', cap, '--name', 'Bad Name');
|
||||
expect(name.code).toBe(2);
|
||||
expect(name.err).toContain('--name <kebab-case-name> is required');
|
||||
const crop = cli('export', cap, '--name', 'ok', '--crop', '1000:1000:0:0');
|
||||
expect(crop.code).toBe(2);
|
||||
expect(crop.err).toContain('is not 1.6:1');
|
||||
// Value: protects=unknown flags, unknown formats, a non-hex pad, a malformed compare crop and missing frames exit 2 before ffmpeg;
|
||||
// fails_when=a typo'd threshold or format is ignored, or export encodes a truncated capture and prints EXPORT_OK;
|
||||
// why_new=these guards are new; seam=none; no_claim=the stand-in ffmpeg is never executed
|
||||
expect(cli('check', cap, '--dissolveat', '10').err).toContain("unknown option '--dissolveat' for check");
|
||||
expect(cli('check', cap, '--dissolveat', '10').code).toBe(2);
|
||||
expect(cli('export', cap, '--name', 'ok', '--formats', 'mp4,gif')).toMatchObject({ code: 2, err: expect.stringContaining("got 'gif'") });
|
||||
expect(cli('export', cap, '--name', 'ok', '--pad', '#ffffff,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--pad must be a hex colour') });
|
||||
expect(cli('compare', 'a.png', 'b.png', '--crop', '1:1:0:0,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--crop must be w:h:x:y') });
|
||||
expect(cli('export', cap, '--name', 'ok')).toMatchObject({ code: 2, err: expect.stringContaining('the capture is incomplete') });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user