mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-motion): new /design-motion skill: showcase MP4 and GIF loops from a design, adapted from Elaya design-to-motion (GSTA-92 phase 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): run design-motion-showcase in the marathon tier; the periodic census is at its 38-slice cap (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): review and QA fixes before ship - encode: refuse unknown options and --formats tokens, validate --pad (#RRGGBB[AA]) and --crop (compare too) so nothing but a colour or crop reaches ffmpeg's filter graph - encode: export refuses a capture with missing window frames; check prints MOTION_SETTINGS for any threshold/phase override; contact sheet drops -fps_mode - capture: portrait-aware scale, --cycle must be whole 60 fps frames, stills with CSS animations disabled, pauseAt tolerates slow loads, clears stale manifest/stills - skill: <motion-dir> placeholder instead of a cross-block shell variable, GSTACK_FFMPEG-aware probe, stop points before the first capture, doctrine fixes - tests: refusal rows, portrait scale, cycle guard Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): capture-check-export pipeline on real Chromium and ffmpeg Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): second review cycle - capture: refuse an --out that holds a still or frames but no capture.json instead of deleting them; --cycle must be a multiple of 0.05 s so the seam frame lands exactly one cycle later for every PRE (nearest value round-trips) - encode: a capture without a mask ends 'MOTION_RESULT: clean (overflow not checked)' - skill: Step 5 names window.__mark in the capture contract - tests: foreign/previous --out handling on real Chromium, cycle guard and PRE sweep Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): third review cycle and adversarial findings - capture: an ownership marker (.gstack-motion-capture) replaces the capture.json test, so the rerun after MOTION_CONTRACT_MISSING or a crash works and a foreign --out is still refused; offscreen lazy images are made eager and font/image loading is bounded at 30 s instead of hanging - encode: refuse a capture.json whose fields are not what capture writes (they reach ffmpeg args and paths); a cards overflow with no story frames is a planning error, and a zero-frame job never passes - tests: retry after a refused page, no-mask clean line, lazy image still, manifest validator, empty story span Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): mask coverage, manifest relations, Windows stop, Figma token off argv - check fails overflow when the grown mask covers >90% of the frame (an app frame whose clip surfaces fill the stage passed vacuously); --mask-grow is 0-32; --mask refuses braces and semicolons - manifestError checks fps/windowFrames >= 1 and that the window fits the frames - skill: native Windows stops before the first capture call; the Figma token reaches curl on stdin (-H @-) instead of argv - tests: split the --out ownership scenario out of the contract test, full-frame mask, --still --scale 2 size and STILL_OK, manifest relations, coverage parse Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): bounded frames, atomic exports, full-bleed masks - capture: each frame is bounded at 10 s (a __tick that never returns fails with MOTION_CAPTURE_FAILED instead of hanging); a --mask that matches no element stops with MOTION_MASK_EMPTY - check: a grown mask covering >90% of the frame (full-bleed app frame) reports overflow as skipped and ends 'clean (overflow not checked)' - export: writes .partial- files and renames on success, so a failed re-export keeps the last good file; a GIF fps that does not divide the cycle is refused; a missing GSTACK_FFMPEG override is named - fidelity: URL source is untrusted data; full-bleed note - tests: spinning __tick, empty mask, failed re-export, drifting gif fps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): app-only coverage skip, gif fps cap, early empty mask - check: the >90% coverage skip applies only to an app frame's own surfaces (ungrown mask); in cards, or when --mask-grow pushes coverage over 90%, overflow fails with what to change (a cards capture with real overflow passed as 'clean (overflow not checked)' after a large grow) - export: --gif-fps/--small-fps capped at 50 (GIF delays are 1/100 s) - compare resolves its paths (a colon in a relative path read as a protocol) - capture: MOTION_MASK_EMPTY is checked before the first frame - tests: cards vs app full-frame mask, fps cap, no frames on an empty mask Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): seek story transitions from their own frame - capture: an animation first seen in a frame where window.__mark did not move (a story's class toggle) is seeked from that frame instead of the last mark, so it eases in the export instead of popping ~100 ms ahead - encode: an empty numeric option is refused instead of read as 0 - figma: only fill a file key and node id that look like Figma's - tests: class-toggled transition mid-fade at its midpoint, blank --min-db Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: PROJECT_STRUCTURE names Elaya's MIT material and licenses/MIT-elaya.txt Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
146 lines
7.9 KiB
TypeScript
Executable File
146 lines
7.9 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
// gstack-motion-capture — deterministic frame capture for /design-motion.
|
|
//
|
|
// gstack-motion-capture.ts <page.html|url> --size <W>x<H> --cycle <s> --out <dir> [--pre 15] [--tail 10] [--mask <css>]
|
|
// gstack-motion-capture.ts <page.html|url> --size <W>x<H> --still <out.png> [--scale <n>]
|
|
//
|
|
// A capture never records in real time. It installs Playwright's fake clock,
|
|
// 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 from window.__mark (the page sets it when its
|
|
// 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 <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).
|
|
//
|
|
// Chromium is gstack's own Playwright Chromium (bin/gstack-browser-ensure
|
|
// installs it); GSTACK_CHROMIUM_PATH overrides it and GSTACK_CHROMIUM_NO_SANDBOX=1
|
|
// adds --no-sandbox, as for the browse daemon. Last line: CAPTURE_OK ... (exit 0),
|
|
// or an error naming its code (exit 1 browser or page, exit 2 usage).
|
|
//
|
|
// Adapted from Elaya's design-to-motion (MIT, (c) 2026 Elaya). See NOTICE.md.
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { pathToFileURL } from 'url';
|
|
import {
|
|
CAPTURE_USAGE, captureScale, clockMs, maskCss, parseCaptureArgs, planCapture, stepMs, withMode, FPS,
|
|
} from '../lib/motion-capture';
|
|
|
|
function die(code: number, line: string): never {
|
|
console.error(line);
|
|
process.exit(code);
|
|
}
|
|
|
|
const parsed = parseCaptureArgs(process.argv.slice(2));
|
|
if ('error' in parsed) die(2, `gstack-motion-capture: ${parsed.error}\n${CAPTURE_USAGE}`);
|
|
const opts = parsed;
|
|
if (process.platform === 'win32') {
|
|
die(1, 'MOTION_CAPTURE_UNSUPPORTED: Bun cannot launch Chromium on native Windows; run the capture from WSL, macOS or Linux');
|
|
}
|
|
|
|
let url = opts.target;
|
|
if (!/^https?:\/\//i.test(url)) {
|
|
const file = path.resolve(url);
|
|
if (!fs.existsSync(file)) die(2, `gstack-motion-capture: ${url} does not exist`);
|
|
url = pathToFileURL(file).href;
|
|
}
|
|
|
|
const { chromium } = await import('playwright');
|
|
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
|
const browser = await chromium.launch({
|
|
...(executablePath ? { executablePath } : {}),
|
|
args: process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1' ? ['--no-sandbox'] : [],
|
|
}).catch((err: Error) => die(1, `BROWSER_UNAVAILABLE ${String(err.message).split('\n')[0]}\nfix: run ${path.join(import.meta.dir, 'gstack-browser-ensure')}, or point GSTACK_CHROMIUM_PATH at a working Chromium`));
|
|
|
|
async function openPage(mode: 'capture' | 'still', scale: number) {
|
|
const context = await browser.newContext({ viewport: { width: opts.width, height: opts.height }, deviceScaleFactor: scale, reducedMotion: 'no-preference' });
|
|
const page = await context.newPage();
|
|
await page.clock.install({ time: 0 });
|
|
await page.goto(withMode(url, mode), { waitUntil: 'load' });
|
|
const settled = page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
for (const img of document.images) img.loading = 'eager';
|
|
await Promise.all([...document.images].map(img => img.decode().catch(() => undefined)));
|
|
}).then(() => true);
|
|
if (!await Promise.race([settled, new Promise<false>(resolve => setTimeout(resolve, 30_000, false).unref())])) {
|
|
console.error('gstack-motion-capture: fonts or images were still loading after 30 s; rendering anyway');
|
|
}
|
|
await page.clock.pauseAt(Math.max(10_000, await page.evaluate(() => Date.now()) + 1_000));
|
|
return page;
|
|
}
|
|
|
|
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, 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 });
|
|
const marker = path.join(out, '.gstack-motion-capture');
|
|
const present = ['frames', 'capture.json', 'still.png', 'mask.png', 'contact-sheet.png'].filter(f => fs.existsSync(path.join(out, f)));
|
|
if (present.length > 0 && !fs.existsSync(marker)) {
|
|
die(2, `gstack-motion-capture: ${out} already holds ${present.join(', ')} but no .gstack-motion-capture marker, so this tool did not write it; pick an empty --out`);
|
|
}
|
|
for (const f of present) fs.rmSync(path.join(out, f), { recursive: true, force: true });
|
|
fs.mkdirSync(path.join(out, 'frames'), { recursive: true });
|
|
fs.writeFileSync(marker, '');
|
|
const page = await openPage('capture', scale);
|
|
const contract = await page.evaluate(() => typeof (window as any).__start === 'function' && typeof (window as any).__tick === 'function');
|
|
if (!contract) die(1, `MOTION_CONTRACT_MISSING: ${opts.target}?capture does not expose window.__start() and window.__tick(dt); add the capture mode before recording`);
|
|
if (opts.mask && await page.evaluate(selector => document.querySelectorAll(selector).length, opts.mask) === 0) {
|
|
die(1, `MOTION_MASK_EMPTY: --mask '${opts.mask}' matches no element on ${opts.target}; mark each clip container with data-motion-clip or fix the selector`);
|
|
}
|
|
const loopTime: number[] = [];
|
|
for (let i = 0; i < plan.frames; i++) {
|
|
const dt = stepMs(i);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const stuck = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`frame ${i} took longer than 10 s; a __tick, timer or animation callback never returned`)), 10_000);
|
|
});
|
|
loopTime.push(await Promise.race([stuck, page.clock.runFor(dt).then(() => page.evaluate(([i, pre, dt]) => {
|
|
const w = window as any;
|
|
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 ??= marked ? mark : now;
|
|
a.pause();
|
|
a.currentTime = Math.max(0, now - a.__m);
|
|
}
|
|
return i >= pre ? Number(w.__lt) || 0 : 0;
|
|
}, [i, plan.pre, dt] as const))]).finally(() => clearTimeout(timer)));
|
|
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'), animations: 'disabled' });
|
|
if (opts.mask) {
|
|
await still.addStyleTag({ content: maskCss(opts.mask) });
|
|
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!,
|
|
pre: plan.pre, windowStart: plan.windowStart, windowFrames: plan.windowFrames, frames: plan.frames,
|
|
clockMs: Array.from({ length: plan.frames }, (_, i) => clockMs(i, plan.pre)), loopTime,
|
|
still: 'still.png', ...(opts.mask ? { mask: 'mask.png' } : {}),
|
|
};
|
|
fs.writeFileSync(path.join(out, 'capture.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
console.log(`CAPTURE_OK frames=${plan.frames} window=${plan.windowStart}+${plan.windowFrames} scale=${scale.toFixed(4)} dir=${out}`);
|
|
}
|
|
} catch (err) {
|
|
die(1, `MOTION_CAPTURE_FAILED: ${String((err as Error).message).split('\n')[0]}`);
|
|
} finally {
|
|
await browser.close();
|
|
}
|