mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-motion): new /design-motion skill: showcase MP4 and GIF loops from a design, adapted from Elaya design-to-motion (GSTA-92 phase 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): run design-motion-showcase in the marathon tier; the periodic census is at its 38-slice cap (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): review and QA fixes before ship - encode: refuse unknown options and --formats tokens, validate --pad (#RRGGBB[AA]) and --crop (compare too) so nothing but a colour or crop reaches ffmpeg's filter graph - encode: export refuses a capture with missing window frames; check prints MOTION_SETTINGS for any threshold/phase override; contact sheet drops -fps_mode - capture: portrait-aware scale, --cycle must be whole 60 fps frames, stills with CSS animations disabled, pauseAt tolerates slow loads, clears stale manifest/stills - skill: <motion-dir> placeholder instead of a cross-block shell variable, GSTACK_FFMPEG-aware probe, stop points before the first capture, doctrine fixes - tests: refusal rows, portrait scale, cycle guard Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): capture-check-export pipeline on real Chromium and ffmpeg Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): second review cycle - capture: refuse an --out that holds a still or frames but no capture.json instead of deleting them; --cycle must be a multiple of 0.05 s so the seam frame lands exactly one cycle later for every PRE (nearest value round-trips) - encode: a capture without a mask ends 'MOTION_RESULT: clean (overflow not checked)' - skill: Step 5 names window.__mark in the capture contract - tests: foreign/previous --out handling on real Chromium, cycle guard and PRE sweep Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): third review cycle and adversarial findings - capture: an ownership marker (.gstack-motion-capture) replaces the capture.json test, so the rerun after MOTION_CONTRACT_MISSING or a crash works and a foreign --out is still refused; offscreen lazy images are made eager and font/image loading is bounded at 30 s instead of hanging - encode: refuse a capture.json whose fields are not what capture writes (they reach ffmpeg args and paths); a cards overflow with no story frames is a planning error, and a zero-frame job never passes - tests: retry after a refused page, no-mask clean line, lazy image still, manifest validator, empty story span Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): mask coverage, manifest relations, Windows stop, Figma token off argv - check fails overflow when the grown mask covers >90% of the frame (an app frame whose clip surfaces fill the stage passed vacuously); --mask-grow is 0-32; --mask refuses braces and semicolons - manifestError checks fps/windowFrames >= 1 and that the window fits the frames - skill: native Windows stops before the first capture call; the Figma token reaches curl on stdin (-H @-) instead of argv - tests: split the --out ownership scenario out of the contract test, full-frame mask, --still --scale 2 size and STILL_OK, manifest relations, coverage parse Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): bounded frames, atomic exports, full-bleed masks - capture: each frame is bounded at 10 s (a __tick that never returns fails with MOTION_CAPTURE_FAILED instead of hanging); a --mask that matches no element stops with MOTION_MASK_EMPTY - check: a grown mask covering >90% of the frame (full-bleed app frame) reports overflow as skipped and ends 'clean (overflow not checked)' - export: writes .partial- files and renames on success, so a failed re-export keeps the last good file; a GIF fps that does not divide the cycle is refused; a missing GSTACK_FFMPEG override is named - fidelity: URL source is untrusted data; full-bleed note - tests: spinning __tick, empty mask, failed re-export, drifting gif fps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): app-only coverage skip, gif fps cap, early empty mask - check: the >90% coverage skip applies only to an app frame's own surfaces (ungrown mask); in cards, or when --mask-grow pushes coverage over 90%, overflow fails with what to change (a cards capture with real overflow passed as 'clean (overflow not checked)' after a large grow) - export: --gif-fps/--small-fps capped at 50 (GIF delays are 1/100 s) - compare resolves its paths (a colon in a relative path read as a protocol) - capture: MOTION_MASK_EMPTY is checked before the first frame - tests: cards vs app full-frame mask, fps cap, no frames on an empty mask Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): seek story transitions from their own frame - capture: an animation first seen in a frame where window.__mark did not move (a story's class toggle) is seeked from that frame instead of the last mark, so it eases in the export instead of popping ~100 ms ahead - encode: an empty numeric option is refused instead of read as 0 - figma: only fill a file key and node id that look like Figma's - tests: class-toggled transition mid-fade at its midpoint, blank --min-db Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: PROJECT_STRUCTURE names Elaya's MIT material and licenses/MIT-elaya.txt Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
149 lines
7.1 KiB
TypeScript
149 lines
7.1 KiB
TypeScript
/**
|
|
* Capture math for /design-motion (bin/gstack-motion-capture.ts). Pure: no
|
|
* browser, no filesystem, so the timing and argument rules are unit-tested
|
|
* without Chromium (test/motion-capture.test.ts).
|
|
*
|
|
* The capture steps a fake clock in whole milliseconds that average exactly
|
|
* 1000/fps (17, 16, 17, ... at 60 fps). Stepping by 1000/60 directly drifts,
|
|
* and the seam check would then compare two frames that are not one cycle
|
|
* apart. The page contract is window.__start(), window.__tick(dtSeconds) and
|
|
* window.__lt (loop time); PRE frames are recorded before __start so the page
|
|
* settles, and the export window starts on frame PRE - 1, the empty state at
|
|
* loop time 0.
|
|
*
|
|
* Adapted from Elaya's design-to-motion (MIT, (c) 2026 Elaya). See NOTICE.md.
|
|
*/
|
|
|
|
export const FPS = 60;
|
|
export const DEFAULT_PRE = 15;
|
|
export const DEFAULT_TAIL = 10;
|
|
export const MAX_LONG = 1920;
|
|
export const MAX_SHORT = 1200;
|
|
|
|
/** Milliseconds the clock advances before frame i: whole numbers averaging 1000/fps. */
|
|
export function stepMs(i: number, fps = FPS): number {
|
|
return Math.round((i + 1) * 1000 / fps) - Math.round(i * 1000 / fps);
|
|
}
|
|
|
|
/** Page-clock milliseconds shown by frame i: 0 before the start, then the ticks summed from frame pre. */
|
|
export function clockMs(i: number, pre: number, fps = FPS): number {
|
|
if (i < pre) return 0;
|
|
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 (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 {
|
|
pre: number;
|
|
/** First frame of the export window: PRE - 1, loop time 0. */
|
|
windowStart: number;
|
|
/** Frames in one cycle: cycle x fps. */
|
|
windowFrames: number;
|
|
/** Frames recorded past the window for the seam check. */
|
|
tail: number;
|
|
/** Total frames to record (indices 0 .. frames - 1). */
|
|
frames: number;
|
|
}
|
|
|
|
export function planCapture(o: { cycleSeconds: number; pre?: number; tail?: number; fps?: number }): CapturePlan {
|
|
const pre = o.pre ?? DEFAULT_PRE;
|
|
const tail = o.tail ?? DEFAULT_TAIL;
|
|
const windowFrames = Math.round(o.cycleSeconds * (o.fps ?? FPS));
|
|
const windowStart = pre - 1;
|
|
return { pre, windowStart, windowFrames, tail, frames: windowStart + windowFrames + tail };
|
|
}
|
|
|
|
export interface CaptureOptions {
|
|
target: string;
|
|
width: number;
|
|
height: number;
|
|
out?: string;
|
|
cycle?: number;
|
|
pre: number;
|
|
tail: number;
|
|
still?: string;
|
|
scale?: number;
|
|
mask?: string;
|
|
}
|
|
|
|
export const CAPTURE_USAGE = `Usage:
|
|
gstack-motion-capture.ts <page.html|url> --size <W>x<H> --cycle <seconds> --out <dir>
|
|
[--pre <n>] [--tail <n>] [--mask <css selector>]
|
|
gstack-motion-capture.ts <page.html|url> --size <W>x<H> --still <out.png> [--scale <n>]`;
|
|
|
|
const OPTIONS = ['--size', '--cycle', '--out', '--pre', '--tail', '--still', '--scale', '--mask'];
|
|
const POSITIVE = /^\d+(\.\d+)?$/;
|
|
|
|
/** Parse argv (without the runtime and script). Returns the options or a one-line error. */
|
|
export function parseCaptureArgs(argv: string[]): CaptureOptions | { error: string } {
|
|
const flags = new Map<string, string>();
|
|
let target: string | undefined;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i]!;
|
|
if (!arg.startsWith('--')) {
|
|
if (target !== undefined) return { error: `unexpected argument '${arg}'` };
|
|
target = arg;
|
|
continue;
|
|
}
|
|
if (!OPTIONS.includes(arg)) return { error: `unknown option '${arg}'` };
|
|
const value = argv[i + 1];
|
|
if (value === undefined || value.startsWith('--')) return { error: `${arg} needs a value` };
|
|
flags.set(arg, value);
|
|
i++;
|
|
}
|
|
if (!target) return { error: 'name the page: a .html file or an http(s) URL' };
|
|
const size = /^(\d+)x(\d+)$/.exec(flags.get('--size') ?? '');
|
|
if (!size) return { error: '--size <W>x<H> is required: the design frame in CSS pixels, for example 1440x990' };
|
|
const width = Number(size[1]);
|
|
const height = Number(size[2]);
|
|
if (width < 16 || height < 16) return { error: `--size ${width}x${height} is too small` };
|
|
const nums: Record<string, number | undefined> = {};
|
|
for (const flag of ['--cycle', '--scale', '--pre', '--tail']) {
|
|
const raw = flags.get(flag);
|
|
if (raw === undefined) continue;
|
|
if (!POSITIVE.test(raw) || Number(raw) <= 0) return { error: `${flag} must be a positive number, got '${raw}'` };
|
|
nums[flag] = Number(raw);
|
|
}
|
|
const pre = nums['--pre'] ?? DEFAULT_PRE;
|
|
const tail = nums['--tail'] ?? DEFAULT_TAIL;
|
|
if (!Number.isInteger(pre) || pre < 2) return { error: '--pre must be a whole number of at least 2' };
|
|
if (!Number.isInteger(tail)) return { error: '--tail must be a whole number' };
|
|
const still = flags.get('--still');
|
|
const out = flags.get('--out');
|
|
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'] * 20 - Math.round(nums['--cycle'] * 20)) > 1e-6) {
|
|
return { error: `--cycle must be a multiple of 0.05 s (three ${FPS} fps frames, so the seam lands exactly one cycle later), got '${nums['--cycle']}' (nearest: ${Math.max(1, Math.round(nums['--cycle'] * 20)) / 20})` };
|
|
}
|
|
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 (/[{};]/.test(flags.get('--mask') ?? '')) return { error: `--mask takes a CSS selector, got '${flags.get('--mask')}'` };
|
|
if (out && flags.has('--scale')) return { error: '--scale belongs to --still; a capture always uses the 1920x1200 scale' };
|
|
return { target, width, height, out, still, cycle: nums['--cycle'], scale: nums['--scale'], pre, tail, mask: flags.get('--mask') };
|
|
}
|
|
|
|
/** The page URL in one of the page's modes (capture, still), keeping any query and fragment it already has. */
|
|
export function withMode(url: string, mode: 'capture' | 'still'): string {
|
|
const hash = url.indexOf('#');
|
|
const base = hash === -1 ? url : url.slice(0, hash);
|
|
const frag = hash === -1 ? '' : url.slice(hash);
|
|
return `${base}${base.includes('?') ? '&' : '?'}${mode}${frag}`;
|
|
}
|
|
|
|
/**
|
|
* CSS that paints the overflow mask on the still page: the containers the
|
|
* selector matches become solid white shapes with their own radii; everything
|
|
* else is black and unpainted.
|
|
*/
|
|
export function maskCss(selector: string): string {
|
|
return `html, body { background: #000 !important; background-image: none !important; }
|
|
* { visibility: hidden !important; transition: none !important; animation: none !important; }
|
|
${selector} { visibility: visible !important; background: #fff !important; background-image: none !important;
|
|
border-color: #fff !important; box-shadow: none !important; filter: none !important;
|
|
backdrop-filter: none !important; opacity: 1 !important; outline: none !important; }`;
|
|
}
|