Feature graphic fits the phone: shots.feature.fit 'contain' (default) sizes the device so its rotated box stays inside 1024x500 with a 24 px margin in both templates, 'bleed' keeps the crop, scale 0.5-1.5, config validated; shots render records measured device boxes (report.json devices) and shots check fails a feature graphic whose device leaves the canvas (warns for a screenshot's front device off a side or the top); tests/feature-fit.test.ts (rotated-box geometry, both templates rendered at 1024x500, bleeding fixture) (pitfall 55)

This commit is contained in:
CRTkafa 2026-10-13 09:49:53 +08:00
parent 6e2dbf03cc
commit 19d34286d5
15 changed files with 239 additions and 12 deletions

View file

@ -3,13 +3,31 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { PLAY_TABLET_SIZES, SIZES } from './lib';
import { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib';
import { fontIssues, type FontUsage } from './glyphs';
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
/** <renders>/report.json: path relative to the renders dir -> flags found while rendering. missing = 404s (frames);
* fonts = which face drew the text (glyphs.ts FontUsage): automatic fallbacks and system-font glyphs are errors. */
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage }>;
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage;
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
devices?: DeviceBox[]; bleed?: boolean }>;
/** Device frames that leave the image. Feature graphic: an error unless shots.feature.fit is 'bleed' (Play shows the
* phone cut off). Screenshots: the templates let the phone run off the bottom by design, so only top / left / right
* count, as a warning. */
export function deviceIssues(entry: RenderReport[string] | undefined, size: string, W: number, H: number): Issue[] {
if (!entry?.devices?.length || entry.bleed) return [];
const feature = !!SIZES[size]?.feature;
return entry.devices.flatMap((b, i) => {
const sides = boxOutside(b, W, H).filter(s => feature || !s.startsWith('bottom'));
if (!sides.length) return [];
const which = entry.devices!.length > 1 ? `device ${i + 1}` : 'the device';
return [feature
? { level: 'error' as const, code: 'device-crop', msg: `${which} is cut off by the canvas (${sides.join(', ')}): render again (shots.feature.fit 'contain' fits it) or set fit 'bleed' if the crop is intended` }
: { level: 'warn' as const, code: 'device-crop', msg: `${which} runs off the image (${sides.join(', ')}): check the scene's frame x / dy / scale / rotate` }];
});
}
export function readReport(dir: string): RenderReport {
try { return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf8')) as RenderReport; } catch { return {}; }
@ -54,6 +72,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
const entry = report[rel.replace(/\.jpe?g$/i, '.png')];
for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg });
issues.push(...fontIssues(entry?.fonts));
issues.push(...deviceIssues(entry, size, info.w, info.h));
if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' });
out.images.push({ rel, lang, variant, size, file, info, issues });
}