mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 01:38:10 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user