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>
207 lines
11 KiB
TypeScript
Executable File
207 lines
11 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
// gstack-motion-encode — checks and exports for a /design-motion capture.
|
|
//
|
|
// gstack-motion-encode check <capture-dir> [--dissolve-at <s>] [--empty-from <s>] [--intro-end <s>]
|
|
// [--layout app|cards] [--mask-grow <px>] [--min-db <dB>]
|
|
// gstack-motion-encode export <capture-dir> --name <name> [--formats mp4,loop,800]
|
|
// [--crop w:h:x:y] [--pad <#hex>] [--gif-fps <n>] [--small-fps <n>]
|
|
// gstack-motion-encode compare <a.png> <b.png> [--blur <sigma>] [--crop w:h:x:y]
|
|
//
|
|
// <capture-dir> is what gstack-motion-capture.ts wrote (capture.json, frames/,
|
|
// 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, prints "MOTION_SETTINGS: ..." naming any threshold or
|
|
// phase override, and ends with "MOTION_RESULT: clean" (exit 0; "clean
|
|
// (overflow not checked)" for a capture without a mask, or an app frame whose
|
|
// surfaces cover more than 90% of the frame; in cards, or after --mask-grow,
|
|
// such coverage fails overflow) 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
|
|
// capture dir and prints "EXPORT_OK <file> <bytes>" per file. compare prints
|
|
// "PSNR <dB|inf> dB (...)" for two same-size images (the still-fidelity check).
|
|
// Exit 2: usage or an unreadable capture. Exit 3: FFMPEG_MISSING (ffmpeg is an
|
|
// optional dependency; the line says how to install it).
|
|
//
|
|
// Adapted from Elaya's design-to-motion (MIT, (c) 2026 Elaya). See NOTICE.md.
|
|
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import {
|
|
compareArgs, contactSheetArgs, cropError, framePath, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, resolveFfmpeg,
|
|
smallGifArgs, FFMPEG_MISSING, type CaptureManifest,
|
|
} from '../lib/motion-encode';
|
|
|
|
const MAX_MASK_COVERAGE = 0.9;
|
|
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]';
|
|
|
|
function die(code: number, line: string): never {
|
|
console.error(line);
|
|
process.exit(code);
|
|
}
|
|
|
|
const [cmd, ...args] = process.argv.slice(2);
|
|
const firstFlag = args.findIndex(a => a.startsWith('--'));
|
|
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) {
|
|
const override = process.env.GSTACK_FFMPEG?.trim();
|
|
if (override) console.error(`gstack-motion-encode: GSTACK_FFMPEG=${override} does not exist; unset it or point it at an ffmpeg binary`);
|
|
console.log(FFMPEG_MISSING);
|
|
process.exit(3);
|
|
}
|
|
|
|
function run(args: string[]) {
|
|
return spawnSync(ffmpeg!, args, { encoding: 'utf-8', timeout: 600_000, maxBuffer: 64 * 1024 * 1024 });
|
|
}
|
|
|
|
function num(flag: string, fallback: number): number {
|
|
const raw = flags.get(flag);
|
|
if (raw === undefined) return fallback;
|
|
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;
|
|
}
|
|
|
|
if (cmd === 'compare') {
|
|
const [a, b] = positional.map(p => path.resolve(p)) 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)`);
|
|
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);
|
|
}
|
|
|
|
const dir = path.resolve(positional[0]!);
|
|
let manifest: CaptureManifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'capture.json'), 'utf-8'));
|
|
} catch (err) {
|
|
die(2, `gstack-motion-encode: cannot read ${path.join(dir, 'capture.json')}: ${(err as Error).message}; capture first with gstack-motion-capture.ts`);
|
|
}
|
|
const manifestProblem = manifestError(manifest);
|
|
if (manifestProblem) die(2, `gstack-motion-encode: ${manifestProblem}; capture again with gstack-motion-capture.ts`);
|
|
|
|
if (cmd === 'check') {
|
|
const layout = flags.get('--layout') ?? 'cards';
|
|
if (layout !== 'app' && layout !== 'cards') die(2, '--layout must be app or cards');
|
|
const maskGrow = num('--mask-grow', 2);
|
|
if (!Number.isInteger(maskGrow) || maskGrow > 32) die(2, `gstack-motion-encode: --mask-grow must be a whole number of pixels from 0 to 32, got '${flags.get('--mask-grow')}'`);
|
|
const jobs = planChecks(manifest, dir, {
|
|
dissolveAt: num('--dissolve-at', manifest.cycle - 1.0),
|
|
emptyFrom: num('--empty-from', manifest.cycle - 0.4),
|
|
introEnd: num('--intro-end', 2.4),
|
|
layout,
|
|
maskGrow,
|
|
});
|
|
if ('error' in jobs) die(2, `gstack-motion-encode: ${jobs.error}`);
|
|
const minDb = flags.has('--min-db') ? num('--min-db', 0) : Infinity;
|
|
let failed = 0;
|
|
let overflowChecked = manifest.mask !== undefined;
|
|
for (const job of jobs) {
|
|
if (job.id === 'overflow') {
|
|
const coverage = (grow: number) => parseMaskCoverage(String(run(maskCoverageArgs(path.join(dir, manifest.mask!), grow)).stdout));
|
|
const own = coverage(0);
|
|
const grown = coverage(maskGrow);
|
|
if (own === null || grown === null) {
|
|
failed++;
|
|
console.log('MOTION_CHECK: overflow fail the mask coverage could not be measured');
|
|
continue;
|
|
}
|
|
if (own > MAX_MASK_COVERAGE && layout === 'app') {
|
|
overflowChecked = false;
|
|
console.log(`MOTION_CHECK: overflow skipped (the app frame's surfaces cover ${Math.round(own * 100)}% of the frame; nothing outside them to compare)`);
|
|
continue;
|
|
}
|
|
if (grown > MAX_MASK_COVERAGE) {
|
|
failed++;
|
|
console.log(own > MAX_MASK_COVERAGE
|
|
? `MOTION_CHECK: overflow fail the mask covers ${Math.round(own * 100)}% of the frame; in a cards layout mark only the card containers with data-motion-clip`
|
|
: `MOTION_CHECK: overflow fail --mask-grow ${maskGrow} grows the mask over ${Math.round(grown * 100)}% of the frame, which hides overflow; lower it`);
|
|
continue;
|
|
}
|
|
}
|
|
const r = run(job.args);
|
|
if (r.status !== 0) {
|
|
failed++;
|
|
console.log(`MOTION_CHECK: ${job.id} fail ffmpeg exited ${r.status ?? r.signal}: ${String(r.stderr).trim().split('\n').pop()}`);
|
|
continue;
|
|
}
|
|
const verdict = judgeCheck(job, parsePsnrStats(String(r.stdout)), minDb);
|
|
if (!verdict.pass) failed++;
|
|
console.log(verdict.line);
|
|
}
|
|
if (manifest.mask === undefined) console.log('MOTION_CHECK: overflow skipped (no mask; recapture with --mask "<container selector>")');
|
|
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: ${failed} failed` : overflowChecked ? 'MOTION_RESULT: clean' : 'MOTION_RESULT: clean (overflow not checked)');
|
|
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 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`);
|
|
function gifFps(flag: string): number {
|
|
const fps = num(flag, 20);
|
|
const perCycle = manifest.cycle * fps;
|
|
if (!Number.isInteger(fps) || fps < 1 || fps > 50 || Math.abs(perCycle - Math.round(perCycle)) > 1e-6) {
|
|
die(2, `gstack-motion-encode: ${flag} ${flags.get(flag)} must be a whole fps from 1 to 50 (GIF delays are in 1/100 s) that gives a whole number of frames per ${manifest.cycle} s cycle; 20 always does`);
|
|
}
|
|
return fps;
|
|
}
|
|
const partial = (file: string) => path.join(dir, `.partial-${file}`);
|
|
const jobs: Array<[string, string[]]> = [];
|
|
if (formats.includes('mp4')) jobs.push([`${name}-x.mp4`, mp4Args(manifest, dir, partial(`${name}-x.mp4`))]);
|
|
if (formats.includes('loop')) jobs.push([`${name}-loop.gif`, loopGifArgs(manifest, dir, partial(`${name}-loop.gif`), { fps: gifFps('--gif-fps') })]);
|
|
if (formats.includes('800')) {
|
|
jobs.push([`${name}-800x500.gif`, smallGifArgs(manifest, dir, partial(`${name}-800x500.gif`), { fps: gifFps('--small-fps'), crop: cropFlag, pad })]);
|
|
}
|
|
let failed = 0;
|
|
for (const [file, args] of jobs) {
|
|
const r = run(args);
|
|
if (r.status !== 0) {
|
|
failed++;
|
|
fs.rmSync(partial(file), { force: true });
|
|
console.log(`EXPORT_FAILED ${file}: ${String(r.stderr).trim().split('\n').pop()}`);
|
|
continue;
|
|
}
|
|
fs.renameSync(partial(file), path.join(dir, file));
|
|
console.log(`EXPORT_OK ${path.join(dir, file)} ${fs.statSync(path.join(dir, file)).size}`);
|
|
}
|
|
process.exit(failed === 0 ? 0 : 1);
|