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>
271 lines
17 KiB
TypeScript
271 lines
17 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { clockMs, planCapture } from '../lib/motion-capture';
|
|
import {
|
|
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, psnrArgs,
|
|
resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions,
|
|
} from '../lib/motion-encode';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-motion-encode');
|
|
const QUIET = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y'];
|
|
|
|
function manifest(cycle = 12, mask = true): CaptureManifest {
|
|
const plan = planCapture({ cycleSeconds: cycle });
|
|
return {
|
|
schema: 1, target: 'page.html', width: 1440, height: 990, scale: 1200 / 990, fps: 60, 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', ...(mask ? { mask: 'mask.png' } : {}),
|
|
};
|
|
}
|
|
|
|
const OPTS: CheckOptions = { dissolveAt: 11, emptyFrom: 11.6, introEnd: 2.4, layout: 'cards', maskGrow: 2 };
|
|
|
|
describe('export recipes', () => {
|
|
const m = manifest();
|
|
|
|
test('MP4 for X: H.264 yuv420p, 60 fps, CRF 15, preset slow, faststart, even crop, one cycle from the window start', () => {
|
|
expect(mp4Args(m, '/c', '/c/n-x.mp4')).toEqual([...QUIET,
|
|
'-start_number', '14', '-framerate', '60', '-i', '/c/frames/%05d.png', '-frames:v', '720',
|
|
'-vf', 'crop=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'slow', '-crf', '15',
|
|
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '/c/n-x.mp4']);
|
|
});
|
|
|
|
test('loop GIF: trim to the cycle, 20 fps, 960 wide lanczos, 192-colour diff palette, bayer 4, loop forever', () => {
|
|
const args = loopGifArgs(m, '/c', '/c/n-loop.gif');
|
|
expect(args.slice(args.indexOf('-filter_complex') + 1)).toEqual([
|
|
'trim=end_frame=720,fps=20,scale=960:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle',
|
|
'-loop', '0', '/c/n-loop.gif']);
|
|
});
|
|
|
|
test('800x500 GIF: optional content crop, fit, pad with the page background', () => {
|
|
const args = smallGifArgs(m, '/c', '/c/n.gif', { crop: '1600:1000:72:100', pad: '#f4f1ea', fps: 12 });
|
|
const filter = args[args.indexOf('-filter_complex') + 1]!;
|
|
expect(filter.startsWith('trim=end_frame=720,fps=12,crop=1600:1000:72:100,scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos,pad=800:500:(ow-iw)/2:(oh-ih)/2:color=0xf4f1ea,split[a][b];')).toBe(true);
|
|
expect(smallGifArgs(m, '/c', '/c/n.gif').join(' ')).toContain('fps=20,scale=800:500');
|
|
});
|
|
|
|
test('a crop must be 1.6:1', () => {
|
|
expect(cropError('1600:1000:0:0')).toBeNull();
|
|
expect(cropError('801:500:0:0')).toBeNull();
|
|
expect(cropError('1000:1000:0:0')).toContain('width 1600');
|
|
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]!;
|
|
expect(vf).toContain(String.raw`eq(n\,0)+`);
|
|
expect(vf).toContain(String.raw`eq(n\,719)'`);
|
|
expect(vf).toContain('tile=4x3');
|
|
});
|
|
});
|
|
|
|
describe('check planning', () => {
|
|
test('seam, empty beat, rest and overflow compare the right frames', () => {
|
|
const m = manifest();
|
|
const jobs = planChecks(m, '/c', OPTS) as CheckJob[];
|
|
expect(jobs.map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest', 'overflow']);
|
|
const [seam, empty, rest, overflow] = jobs;
|
|
expect(seam!.what).toBe('frame 14 vs 734');
|
|
expect(seam!.args).toContain('/c/frames/00014.png');
|
|
expect(seam!.args).toContain('/c/frames/00734.png');
|
|
// Empty beat: clock in [11.6 s, 12 s) inside the window, compared with the pre-start frame.
|
|
const emptyFrames = Array.from({ length: m.frames }, (_, i) => i)
|
|
.filter(i => i > 14 && i < 734 && m.clockMs[i]! >= 11_600);
|
|
expect(empty!.expected).toBe(emptyFrames.length);
|
|
expect(empty!.what).toBe(`frames ${emptyFrames[0]}-733 vs 14`);
|
|
expect(empty!.args.slice(empty!.args.indexOf('-start_number'), empty!.args.indexOf('-start_number') + 2)).toEqual(['-start_number', String(emptyFrames[0])]);
|
|
// Rest: the last frame whose clock is still before the dissolve.
|
|
const restFrame = Math.max(...Array.from({ length: 734 }, (_, i) => i).filter(i => i > 14 && m.clockMs[i]! < 11_000));
|
|
expect(rest!.what).toBe(`frame ${restFrame} vs still.png`);
|
|
expect(m.clockMs[restFrame + 1]).toBeGreaterThanOrEqual(11_000);
|
|
// Cards overflow: story frames (intro landed .. dissolve) against the still, through the mask.
|
|
expect(overflow!.what).toContain('story frames');
|
|
expect(overflow!.args.join(' ')).toContain('/c/mask.png');
|
|
expect(overflow!.args.join(' ')).toContain('/c/still.png');
|
|
});
|
|
|
|
test('an app frame compares every window frame with the empty first frame', () => {
|
|
const jobs = planChecks(manifest(), '/c', { ...OPTS, layout: 'app' }) as CheckJob[];
|
|
const overflow = jobs.find(j => j.id === 'overflow')!;
|
|
expect(overflow.expected).toBe(720);
|
|
expect(overflow.what).toBe('every window frame vs 14, outside the mask');
|
|
});
|
|
|
|
test('no mask, no overflow job; bad phases and a short tail are refused', () => {
|
|
expect((planChecks(manifest(12, false), '/c', OPTS) as CheckJob[]).map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest']);
|
|
expect((planChecks(manifest(), '/c', { ...OPTS, dissolveAt: 12.5 }) as { error: string }).error).toContain('intro-end < dissolve-at');
|
|
const short = { ...manifest(), frames: 734 };
|
|
expect((planChecks(short, '/c', OPTS) as { error: string }).error).toContain('--tail');
|
|
});
|
|
|
|
test('the mask is grown by one dilation per pixel and blacks out the containers on both sides', () => {
|
|
const args = psnrArgs({ first: '/c/frames/%05d.png', start: 20, count: 5, reference: '/c/still.png', mask: '/c/mask.png', maskGrow: 3 });
|
|
const graph = args[args.indexOf('-filter_complex') + 1]!;
|
|
expect(graph).toContain('format=gray,dilation,dilation,dilation,format=gbrp,split[m1][m2]');
|
|
expect(graph).toContain("[f][m1]blend=all_expr='if(gt(B,127),0,A)'[fa]");
|
|
expect(graph).toContain('[fa][ra]psnr=stats_file=-');
|
|
expect(graph).toContain('trim=end_frame=5');
|
|
});
|
|
|
|
test('compare crops and blurs both sides the same way', () => {
|
|
const args = compareArgs('a.png', 'b.png', { blur: 1.2, crop: '100:100:10:10' });
|
|
expect(args[args.indexOf('-filter_complex') + 1]).toBe('[0:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[a];[1:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[b];[a][b]psnr=stats_file=-');
|
|
});
|
|
});
|
|
|
|
describe('PSNR parsing and verdicts', () => {
|
|
const STATS = [
|
|
'n:1 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
|
|
'n:2 mse_avg:1.53 mse_r:1.40 mse_g:1.60 mse_b:1.59 psnr_avg:46.28 psnr_r:46.67 psnr_g:46.09 psnr_b:46.12',
|
|
'garbage line',
|
|
'n:3 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
|
|
].join('\n');
|
|
const job: CheckJob = { id: 'empty-beat', what: 'frames 711-733 vs 14', args: [], expected: 3 };
|
|
|
|
test('inf becomes Infinity and junk lines are skipped', () => {
|
|
expect(parsePsnrStats(STATS)).toEqual([{ n: 1, psnr: Infinity }, { n: 2, psnr: 46.28 }, { n: 3, psnr: Infinity }]);
|
|
expect(parsePsnrStats('')).toEqual([]);
|
|
});
|
|
|
|
test('identical frames pass; one differing frame fails with its worst value', () => {
|
|
const allInf = parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf'));
|
|
expect(judgeCheck(job, allInf)).toEqual({ id: 'empty-beat', pass: true, line: 'MOTION_CHECK: empty-beat pass (frames 711-733 vs 14)' });
|
|
const v = judgeCheck(job, parsePsnrStats(STATS));
|
|
expect(v.pass).toBe(false);
|
|
expect(v.line).toBe('MOTION_CHECK: empty-beat fail 1 frame(s) differ, worst 46.28 dB at compared frame 2 (frames 711-733 vs 14)');
|
|
expect(judgeCheck(job, parsePsnrStats(STATS), 45).pass).toBe(true);
|
|
});
|
|
|
|
test('fewer compared frames than planned is a failure, never a pass', () => {
|
|
const v = judgeCheck({ ...job, expected: 23 }, parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf')));
|
|
expect(v.pass).toBe(false);
|
|
expect(v.line).toContain('compared 3 of 23 frames');
|
|
expect(judgeCheck({ ...job, expected: 0 }, []).pass).toBe(false);
|
|
});
|
|
|
|
// Value: protects=phase overrides that leave no story frame refuse the check instead of passing overflow on zero frames;
|
|
// fails_when=the empty-span guard is dropped and overflow reports pass for 'story frames undefined-undefined'; why_new=new guard; seam=none;
|
|
// no_claim=planning only
|
|
test('a cards overflow with no story frames is a planning error', () => {
|
|
const r = planChecks(manifest(), '/c', { ...OPTS, introEnd: 10.99, dissolveAt: 11 });
|
|
expect(r).toEqual({ error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' });
|
|
});
|
|
|
|
// Value: protects=a capture.json this tool did not write cannot put text into ffmpeg filter graphs or paths outside the capture dir;
|
|
// fails_when=a field check is dropped and a string windowFrames or a ../ still reaches the encoder; why_new=new validator; seam=none;
|
|
// no_claim=validator only; the bin calls it before building any job
|
|
test('a capture manifest is checked before its fields reach ffmpeg', () => {
|
|
expect(manifestError(manifest())).toBeNull();
|
|
expect(manifestError(manifest(12, false))).toBeNull();
|
|
expect(manifestError({ ...manifest(), schema: 2 })).toContain('schema 1');
|
|
expect(manifestError({ ...manifest(), windowFrames: '60,metadata=mode=print' })).toContain('windowFrames must be a non-negative integer');
|
|
expect(manifestError({ ...manifest(), cycle: 0 })).toContain('cycle must be a positive number');
|
|
expect(manifestError({ ...manifest(), clockMs: [0] })).toContain('one number per frame');
|
|
expect(manifestError({ ...manifest(), still: '../still.png' })).toContain('still must be a .png file name');
|
|
expect(manifestError({ ...manifest(), mask: 'mask.png,hue=s=0' })).toContain('mask must be a .png file name');
|
|
expect(manifestError(null)).toContain('schema 1');
|
|
expect(manifestError({ ...manifest(), fps: 0 })).toContain('at least 1');
|
|
expect(manifestError({ ...manifest(), windowFrames: 1_000_000_000 })).toContain('runs past its frames');
|
|
});
|
|
|
|
test('mask coverage reads signalstats YAVG as a fraction of white', () => {
|
|
const args = maskCoverageArgs('/c/mask.png', 2);
|
|
expect(args[args.indexOf('-vf') + 1]).toBe('format=gray,dilation,dilation,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-');
|
|
expect(parseMaskCoverage('frame:0 pts:0 pts_time:0\nlavfi.signalstats.YAVG=127.5\n')).toBe(0.5);
|
|
expect(parseMaskCoverage('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ffmpeg is optional and named when missing', () => {
|
|
test('resolution prefers GSTACK_FFMPEG, refuses a missing override, else uses PATH', () => {
|
|
const which = (b: string) => (b === 'ffmpeg' ? '/usr/bin/ffmpeg' : null);
|
|
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, p => p === '/opt/ff')).toBe('/opt/ff');
|
|
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, () => false)).toBeNull();
|
|
expect(resolveFfmpeg({}, which, () => true)).toBe('/usr/bin/ffmpeg');
|
|
expect(resolveFfmpeg({}, () => null, () => true)).toBeNull();
|
|
});
|
|
|
|
test('the CLI prints FFMPEG_MISSING with install hints and exits 3', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-'));
|
|
try {
|
|
const r = Bun.spawnSync([BIN, 'check', dir], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: path.join(dir, 'no-ffmpeg') } });
|
|
expect(r.exitCode).toBe(3);
|
|
expect(r.stdout.toString().trim()).toBe(FFMPEG_MISSING);
|
|
expect(r.stderr.toString()).toContain(`GSTACK_FFMPEG=${path.join(dir, 'no-ffmpeg')} does not exist`);
|
|
expect(FFMPEG_MISSING).toContain('brew install ffmpeg');
|
|
expect(FFMPEG_MISSING).toContain('apt-get install -y ffmpeg');
|
|
expect(FFMPEG_MISSING).toContain('winget install');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a usage error exits 2 without touching ffmpeg', () => {
|
|
const r = Bun.spawnSync([BIN, 'encode'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: '/nonexistent/ffmpeg' } });
|
|
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 });
|
|
}
|
|
});
|
|
});
|