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