shots: app titles get the weights the templates use (variable axis, used range or static weights; font-weight warning), palette.bg as one colour or a CSS gradient string (validated; contrast guard in shots check), photo backgrounds (palette.image, shots.feature.background) with overlay and cover crop

This commit is contained in:
CRTkafa 2026-10-20 13:37:37 +07:00
parent 2ba8278ab7
commit 3a11682a37
21 changed files with 713 additions and 44 deletions

View file

@ -26,6 +26,9 @@ import { closeBrowser, launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette';
import { type WeightSample, weightIssues } from './weights';
import type { Palette } from '../lib/config';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
import * as rt from '../lib/rt';
@ -123,6 +126,19 @@ export async function run(ctx: Ctx): Promise<void> {
for (const lang of langs) for (const f of manifest.langs[lang]?.fallbacks ?? [])
(manifest.faces[f.face]?.chosen ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} from ${f.family}${manifest.faces[f.face]?.chosen ? '' : ' (automatic fallback: shots check will fail)'}`);
}
// palette backgrounds as CSS (colour, gradient string or stops) and photo backgrounds, served at /bgimg/<n>
const photos: string[] = [];
const look = (pl: Palette, angle: number, mid: number, featureBg?: NonNullable<typeof shots.feature>['background']): Palette & { bgCss: string; bgTone: string; photo?: { overlay: number } } => {
const ph = photoBackground(pl, featureBg);
let css = bgCss(pl.bg, angle, mid);
if (ph) {
const abs = resolve(ph.image);
if (!existsSync(abs)) throw new Error(`shots render: background image ${ph.image} not found (${abs})`);
if (!photos.includes(abs)) photos.push(abs);
css = photoCss(`/bgimg/${photos.indexOf(abs)}`, ph.overlay) + ', ' + css;
}
return { ...pl, bgCss: css, bgTone: ph && ph.overlay >= 0.35 ? '#000000' : bgTone(pl.bg), ...(ph ? { photo: { overlay: ph.overlay } } : {}) };
};
const server = await rt.serve({
port: a.num('port', 8792), hostname: '127.0.0.1',
async fetch(req) {
@ -138,14 +154,16 @@ export async function run(ctx: Ctx): Promise<void> {
fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } };
if (url.searchParams.has('feature'))
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src,
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), palette: palette(shots.feature?.palette ?? scenes[0]!.palette, style) });
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }),
palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) });
const sid = url.searchParams.get('s') ?? '';
const scene = scenes.find(s => s.id === sid)!;
const number = scenes.filter(s => !s.hero).indexOf(scene) + 1;
return Response.json({ ...base, scene, number, palette: palette(scene.palette, style), copy: copy[lang]?.[sid] ?? {} });
return Response.json({ ...base, scene, number, palette: look(palette(scene.palette, style), 180, style === 'app' ? 55 : 48), copy: copy[lang]?.[sid] ?? {} });
}
let file = '';
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? '';
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
@ -195,12 +213,20 @@ export async function run(ctx: Ctx): Promise<void> {
if (!out.endsWith('sheet.png')) {
const measured = await measureFonts(page, Object.keys(faces));
const usage = fontUsage(measured, faces);
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage };
const weights = await titleWeights(page, sf.title);
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage, ...(weights.length ? { weights } : {}) };
for (const i of weightIssues(weights)) warn('shots', `${relative(root, out)}: ${i.msg}`);
for (const f of usage.fallback) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with ${f.family} (automatic fallback)`);
for (const f of usage.missing) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with a system font`);
}
if (overflow.length) warn('shots', `${relative(root, out)}: ${overflow.join('; ')}`);
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
if (style === 'app' && !out.endsWith('sheet.png')) { // contrast guard: title ink vs. the pixels behind it (text hidden)
const contrast = await titleContrast(page, w, h);
const r = report[relative(outDir, out)];
if (r && contrast.length) r.contrast = contrast;
for (const i of contrastIssues(contrast)) warn('shots', `${relative(root, out)}: ${i.msg}`);
}
const ff = rt.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
const code = await ff.exited;
rmSync(tmp, { force: true });
@ -268,3 +294,52 @@ export async function run(ctx: Ctx): Promise<void> {
if (flagged) warn('shots', `${flagged} images with text overflow flags: storebound shots check / shots gallery`);
done('shots', `render done${warnings ? `, ${warnings} with warnings (missing frames or fonts?)` : ''}`);
}
/** Title elements' asked weight vs. the weights the loaded faces of the title family serve (report.json `weights`). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function titleWeights(page: any, family: string): Promise<WeightSample[]> {
return (await page.evaluate(`(() => {
const fam = ${JSON.stringify(family)};
const served = [...document.fonts].filter(f => f.family.replace(/["']/g, '') === fam && f.status === 'loaded').map(f => String(f.weight));
const seen = new Set(), out = [];
for (const e of document.querySelectorAll('h1 span, .name')) {
const want = Number(getComputedStyle(e).fontWeight), el = e.matches('.name') ? 'app name' : 'title';
if (!e.textContent.trim() || seen.has(el + want)) continue;
seen.add(el + want);
out.push({ el, family: fam, want, served: [...new Set(served)] });
}
return out;
})()`)) as WeightSample[];
}
/** Title ink vs. the background behind it: hides the title text, screenshots each title box, averages it into a
* small grid with ffmpeg and keeps the worst contrast (report.json `contrast`). Run after the real screenshot. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function titleContrast(page: any, W: number, H: number): Promise<ContrastSample[]> {
const els = (await page.evaluate(`(() => {
const out = [];
for (const e of document.querySelectorAll('h1 span, .name')) {
if (!e.textContent.trim()) continue;
const r = e.getBoundingClientRect();
out.push({ el: e.matches('.name') ? 'app name' : 'title', ink: getComputedStyle(e).color, x: r.x, y: r.y, w: r.width, h: r.height });
}
for (const e of document.querySelectorAll('h1, .name')) e.style.visibility = 'hidden';
return out;
})()`)) as { el: string; ink: string; x: number; y: number; w: number; h: number }[];
const out: ContrastSample[] = [];
for (const e of els) {
const ink = parseColour(e.ink);
const x = Math.max(0, Math.floor(e.x)), y = Math.max(0, Math.floor(e.y));
const cw = Math.min(W - x, Math.ceil(e.w)), ch = Math.min(H - y, Math.ceil(e.h));
if (!ink || cw < 2 || ch < 2) continue;
const png = await page.screenshot({ clip: { x, y, width: cw, height: ch } }) as Uint8Array;
const r = rt.spawnSync(['ffmpeg', '-v', 'error', '-i', 'pipe:0', '-vf', 'scale=8:3:flags=area', '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], { stdin: png, timeout: 15000 });
const raw = r.stdout;
if (r.exitCode !== 0 || raw.length < 3) continue;
const samples: RGB[] = [];
for (let i = 0; i + 2 < raw.length; i += 3) samples.push([raw[i]!, raw[i + 1]!, raw[i + 2]!]);
const wc = worstContrast(ink, samples);
out.push({ el: e.el, ink: e.ink, worst: wc.worst, ratio: Math.round(wc.ratio * 100) / 100 });
}
return out;
}