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:
garrytanandcapy-ai[bot] committed 2026-10-26 01:15:58 +00:00
1 parent aaa1f989f3
commit 41a720dac4
16 files changed
+173 -64

No files matched your search

+6 -3
View File
@@ -31,9 +31,9 @@ export function clockMs(i: number, pre: number, fps = FPS): number {
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. */
export function captureScale(width: number, height: number, maxW = MAX_LONG, maxH = MAX_SHORT): number {
return Math.min(maxW / width, maxH / height);
/** 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 {
@@ -116,6 +116,9 @@ export function parseCaptureArgs(argv: string[]): CaptureOptions | { error: stri
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'] * FPS - Math.round(nums['--cycle'] * FPS)) > 1e-6) {
return { error: `--cycle must be a whole number of ${FPS} fps frames, got '${nums['--cycle']}' (nearest: ${Number((Math.round(nums['--cycle'] * FPS) / FPS).toFixed(4))})` };
}
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 (out && flags.has('--scale')) return { error: '--scale belongs to --still; a capture always uses the 1920x1200 scale' };
+9 -3
View File
@@ -79,16 +79,22 @@ export function smallGifArgs(m: CaptureManifest, dir: string, out: string, o: Gi
'scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos', `pad=800:500:(ow-iw)/2:(oh-ih)/2:color=${pad}`]);
}
/** A crop for the 800x500 GIF must be exactly 1.6:1 (one pixel of rounding allowed). */
export function cropError(crop: string): string | null {
/** A crop must be w:h:x:y in whole pixels; for the 800x500 GIF (ratio) it must also be 1.6:1, one pixel of rounding allowed. */
export function cropError(crop: string, o: { ratio?: boolean } = { ratio: true }): string | null {
const m = /^(\d+):(\d+):(\d+):(\d+)$/.exec(crop);
if (!m) return `--crop must be w:h:x:y in capture pixels, got '${crop}'`;
if (!o.ratio) return null;
const w = Number(m[1]);
const h = Number(m[2]);
if (Math.abs(w - h * 1.6) > 1) return `--crop ${w}x${h} is not 1.6:1 (for height ${h} use width ${Math.round(h * 1.6)})`;
return null;
}
/** The 800x500 pad colour goes into the filter graph, so it must be a plain #RRGGBB[AA] hex colour (ffmpeg rejects #RGB). */
export function padError(pad: string): string | null {
return /^#?(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(pad) ? null : `--pad must be a hex colour such as '#f4f1ea', got '${pad}'`;
}
/**
* PSNR of a run of frames against one reference image. With a mask, pixels the
* mask (grown by maskGrow pixels) marks white are blacked out on both sides,
@@ -214,7 +220,7 @@ export function contactSheetArgs(m: CaptureManifest, dir: string, out: string):
const picks = Array.from({ length: 12 }, (_, k) => Math.round((k * (m.windowFrames - 1)) / 11));
const select = picks.map(p => `eq(n\\,${p})`).join('+');
return [...QUIET, '-start_number', String(m.windowStart), '-i', framePattern(dir),
'-vf', `select='${select}',scale=480:-2,tile=4x3:padding=8:color=0x808080`, '-frames:v', '1', '-fps_mode', 'passthrough', out];
'-vf', `select='${select}',scale=480:-2,tile=4x3:padding=8:color=0x808080`, '-frames:v', '1', out];
}
export const FFMPEG_MISSING = 'FFMPEG_MISSING: ffmpeg is not installed or not on PATH (set GSTACK_FFMPEG to its full path). '