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:
parent
2ba8278ab7
commit
3a11682a37
21 changed files with 713 additions and 44 deletions
|
|
@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|||
import * as rt from './rt';
|
||||
import { PROFILE_RE, setConfigAccount } from './credentials';
|
||||
import { validateCapture, validateRecord } from '../shots/devices';
|
||||
import { featureBackgroundIssues, paletteIssues } from '../shots/palette';
|
||||
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
|
||||
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
||||
import { type IapOfferCodeDef, type PromotedConfig, validateIapOfferCodes, validatePromoted, validateWinBack, type WinBackDef } from './offers';
|
||||
|
|
@ -125,7 +126,9 @@ export interface DataSafety {
|
|||
|
||||
export interface ShotBadgeSlot { y: number; side: 'l' | 'r'; icon?: string; iconBg?: string; hot?: boolean; stars?: boolean; rot?: number }
|
||||
export interface ShotFrame { src: string; x?: number; dy?: number; scale?: number; rotate?: number }
|
||||
export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string }
|
||||
/** bg: gradient stops (2-3 colours), one colour (flat) or a CSS gradient string. image: a photo background (path
|
||||
* relative to the config, cropped to cover) under a dark overlay (0-0.9, default 0.45) that keeps white ink legible. */
|
||||
export interface Palette { bg: string | [string, string] | [string, string, string]; glow: string; ink: string; shadow: string; image?: string; overlay?: number }
|
||||
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number }
|
||||
|
||||
/** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`;
|
||||
|
|
@ -158,7 +161,10 @@ export interface ShotsConfig {
|
|||
* its rotated outline stays inside the canvas with a 24 px margin; 'bleed' keeps the larger phone cropped by the
|
||||
* bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width
|
||||
* (still clamped to the canvas with 'contain'). */
|
||||
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number };
|
||||
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number;
|
||||
/** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the
|
||||
* palette's image. Also usable for the App Store Header and Search Results art. */
|
||||
background?: string | { image: string; overlay?: number } };
|
||||
/** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar).
|
||||
* `bar` = status bar height the template draws above the frame (and that capture crops off). */
|
||||
phone?: { w: number; h: number; dpr?: number; bar?: number };
|
||||
|
|
@ -1209,7 +1215,11 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name');
|
||||
if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'");
|
||||
if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5');
|
||||
e.push(...featureBackgroundIssues(s.feature?.background));
|
||||
if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette'));
|
||||
}
|
||||
for (const sc of s.scenes ?? []) if (sc.palette !== undefined) e.push(...paletteIssues(sc.palette, `shots.scenes[${sc.id}].palette`));
|
||||
for (const [k, v] of Object.entries(s.variants ?? {})) for (const [id, p] of Object.entries(v.palettes ?? {})) e.push(...paletteIssues(p, `shots.variants.${k}.palettes.${id}`));
|
||||
const fontKeys: [string, unknown][] = [['title', s.fonts?.title], ['body', s.fonts?.body], ...(Array.isArray(s.fonts?.fallback) ? s.fonts!.fallback! : s.fonts?.fallback !== undefined ? [s.fonts.fallback] : []).map((f, i) => [`fallback[${i}]`, f] as [string, unknown])];
|
||||
if (s.fonts?.fallback !== undefined && !Array.isArray(s.fonts.fallback)) e.push('shots.fonts.fallback: a list');
|
||||
for (const [k, f] of fontKeys) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -21,6 +21,39 @@ import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders
|
|||
import { imageInfo } from './spec';
|
||||
import { fontsDirOf, readManifest } from './fonts';
|
||||
import { localFontIssues } from './local-fonts';
|
||||
import { photoBackground, photoIssues } from './palette';
|
||||
import { palette } from './lib';
|
||||
import { shotsStyle } from '../lib/config';
|
||||
import type { Issue } from './spec';
|
||||
|
||||
/** Photo backgrounds (palette.image, shots.feature.background): the file exists and is at least as big as every
|
||||
* output it covers (the feature graphic 1024 x 500; screenshots at the sizes this project renders). */
|
||||
export function backgroundIssues(config: ShipConfig, resolve: (p: string) => string, tablets?: boolean): { where: string; issue: Issue }[] {
|
||||
const shots = config.shots;
|
||||
if (!shots) return [];
|
||||
const style = shotsStyle(config), sizes = defaultSizes(config, tablets);
|
||||
const out: { where: string; issue: Issue }[] = [];
|
||||
const one = (file: string, where: string, targets: string[]): void => {
|
||||
const abs = resolve(file);
|
||||
let info: { w: number; h: number } | null = null;
|
||||
try { const i = imageInfo(readFileSync(abs)); info = i.format === 'unknown' ? null : i; } catch { /* missing */ }
|
||||
for (const t of targets) for (const x of photoIssues(info, file, SIZES[t]!.w, SIZES[t]!.h)) out.push({ where, issue: { level: x.level, code: 'background', msg: x.msg } });
|
||||
};
|
||||
const screens = sizes.filter(s => !SIZES[s]!.feature);
|
||||
// the biggest screenshot size decides; one message per file
|
||||
const biggest = screens.sort((a, b) => SIZES[b]!.w * SIZES[b]!.h - SIZES[a]!.w * SIZES[a]!.h).slice(0, 1);
|
||||
for (const sc of shots.scenes) {
|
||||
let p; try { p = palette(sc.palette, style); } catch (e) { out.push({ where: `shots.scenes[${sc.id}].palette`, issue: { level: 'error', code: 'background', msg: (e as Error).message } }); continue; }
|
||||
const ph = photoBackground(p);
|
||||
if (ph) one(ph.image, `shots.scenes[${sc.id}].palette.image`, biggest);
|
||||
}
|
||||
if (sizes.includes('feature')) {
|
||||
let p; try { p = palette(shots.feature?.palette ?? shots.scenes[0]?.palette, style); } catch { p = null; }
|
||||
const ph = p && photoBackground(p, shots.feature?.background);
|
||||
if (ph) one(ph.image, shots.feature?.background ? 'shots.feature.background' : 'shots.feature palette.image', ['feature']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Frame files the scenes (and the feature graphic) use, per language and device class. */
|
||||
export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
||||
|
|
@ -66,9 +99,10 @@ export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' |
|
|||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, resolve, root } = ctx.loaded;
|
||||
const scan = scanProject(ctx);
|
||||
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))];
|
||||
const cap = config.shots?.capture;
|
||||
const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
|
||||
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })),
|
||||
...backgroundIssues(config, resolve, tablets)];
|
||||
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : [];
|
||||
if (ctx.args.has('json')) {
|
||||
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues });
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const lang = q.get('lang'), V = q.get('v') ?? '';
|
|||
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1&v=${encodeURIComponent(V)}`)).json();
|
||||
const P = job.palette, R = document.documentElement.style, dev = job.phone;
|
||||
const rgba = (hex, a) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; };
|
||||
R.setProperty('--bg', `linear-gradient(135deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(135deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow); R.setProperty('--accentSoft', rgba(P.glow, .18));
|
||||
R.setProperty('--tf', (job.fonts.titleStack ?? [job.fonts.title]).map(f => `'${f}'`).join(', ') + ', sans-serif');
|
||||
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const q = new URLSearchParams(location.search);
|
|||
const lang = q.get('lang'), V = q.get('v') ?? '';
|
||||
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1&v=${encodeURIComponent(V)}`)).json();
|
||||
const P = job.palette, R = document.documentElement.style, dev = job.phone;
|
||||
R.setProperty('--bg', `linear-gradient(160deg, ${P.bg[0]} 0%, ${P.bg[1]} 50%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(160deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
R.setProperty('--glow', P.glow); R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--tf', (job.fonts.titleStack ?? [job.fonts.title]).map(f => `'${f}'`).join(', ') + ', sans-serif');
|
||||
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||
|
|
|
|||
|
|
@ -19,21 +19,19 @@ import { shotsStyle } from '../lib/config';
|
|||
import type { CopyFile } from './lib';
|
||||
import { localByFamily, resolveShotFonts } from './local-fonts';
|
||||
import { fontCodepoints } from './cmap';
|
||||
import { type FaceJob, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
import { SHOTS_PAGES } from '../lib/embedded';
|
||||
import { fetchFaces, localWeightGap, serves, styleWeights } from './weights';
|
||||
import { type FaceJob, type FaceResult, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·';
|
||||
|
||||
export async function fetchSubset(family: string, chars: string, axis: string | null): Promise<ArrayBuffer> {
|
||||
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
|
||||
let res = await fetch(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok && axis) return fetchSubset(family, chars, null);
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
const src = /url\((https:[^)]+)\)/.exec(await res.text())?.[1];
|
||||
if (!src) throw new Error(`${family}: no font url`);
|
||||
res = await fetch(src, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} for ${src}`);
|
||||
return res.arrayBuffer();
|
||||
return (await fetchFaces(family, chars, { axis }))[0]!.bin;
|
||||
}
|
||||
|
||||
/** Weights the style's templates (screenshot + feature graphic) ask for, per role. */
|
||||
export function weightsFor(style: 'game' | 'app'): { title: number[]; body: number[] } {
|
||||
return styleWeights(style === 'app' ? [SHOTS_PAGES['template-app.html']!, SHOTS_PAGES['feature-app.html']!] : [SHOTS_PAGES['template.html']!, SHOTS_PAGES['feature.html']!]);
|
||||
}
|
||||
|
||||
/** Per-language role texts for every language of the copy and the config. */
|
||||
|
|
@ -57,16 +55,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
mkdirSync(dir, { recursive: true });
|
||||
const style = shotsStyle(config);
|
||||
const sf = resolveShotFonts(shots, style, resolve, root);
|
||||
const { titleVariable } = sf;
|
||||
const W = weightsFor(style);
|
||||
const title = sf.title.family, body = sf.body.family;
|
||||
const local = localByFamily([sf.title, sf.body, ...sf.fallback]);
|
||||
const texts = langTexts(copy, config.languages.map(l => l.id), config.app.name);
|
||||
|
||||
const sizes: string[] = [];
|
||||
const manifest = await planFonts({
|
||||
langs: texts, title, body, titleAxis: titleVariable ? 'wght@100..900' : null, bodyAxis: 'wght@100..900',
|
||||
langs: texts, title, body, titleAxis: null, bodyAxis: null, titleWeights: W.title, bodyWeights: W.body,
|
||||
fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: sf.fallback.map(f => f.family), base: ASCII, local,
|
||||
}, async (job: FaceJob) => {
|
||||
}, async (job: FaceJob): Promise<FaceResult> => {
|
||||
if (job.local) { // read in place: the whole file's cmap
|
||||
const bin = new Uint8Array(readFileSync(join(root, job.local.path)));
|
||||
const cps = fontCodepoints(bin);
|
||||
|
|
@ -74,21 +72,39 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!sizes.includes(line)) sizes.push(line);
|
||||
return cps;
|
||||
}
|
||||
let bin: Uint8Array;
|
||||
try { bin = new Uint8Array(await fetchSubset(job.family, job.chars, job.axis)); } catch (e) {
|
||||
let got: { weight: string; bin: ArrayBuffer }[];
|
||||
try { got = await fetchFaces(job.family, job.chars, job.weights ? { weights: job.weights } : { axis: job.axis }); } catch (e) {
|
||||
if (job.kind !== 'fallback') throw e;
|
||||
log('shots', `${job.family} has none of ${[...job.chars].join(' ')} (${(e as Error).message.split(' for ')[0]}), trying the next fallback`);
|
||||
return new Set<number>();
|
||||
}
|
||||
writeFileSync(join(dir, job.file), bin);
|
||||
const bin = new Uint8Array(got[0]!.bin);
|
||||
writeFileSync(join(dir, job.file), bin); // the cmap / coverage file (every weight has the same subset)
|
||||
const cps = fontCodepoints(bin);
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(bin.byteLength / 1024).toFixed(1)} KB, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
const weights = got.map(g => g.weight).join(', ');
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(got.reduce((t, g) => t + g.bin.byteLength, 0) / 1024).toFixed(1)} KB, weight ${weights}, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
if (job.weights) {
|
||||
const files = got.length === 1 ? [{ file: job.file, weight: got[0]!.weight }] : got.map(g => {
|
||||
const file = job.file.replace(/\.woff2$/, `-${g.weight.replace(/ /g, '-')}.woff2`);
|
||||
writeFileSync(join(dir, file), new Uint8Array(g.bin));
|
||||
return { file, weight: g.weight };
|
||||
});
|
||||
const miss = job.weights.filter(w => !files.some(f => serves(f.weight, w)));
|
||||
if (miss.length) warn('shots', `${job.family} (${job.role ?? job.kind}) has no weight ${miss.join(', ')} (got ${weights}): the templates ask for it, so those lines are drawn at the nearest weight. Pick a family with these weights for a heavier headline`);
|
||||
return { cps, files };
|
||||
}
|
||||
return cps;
|
||||
});
|
||||
writeFileSync(join(dir, 'fonts.css'), fontsCss(manifest));
|
||||
writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n');
|
||||
if (ctx.args.has('json')) return printJson(manifest);
|
||||
for (const s of sizes) log('shots', s);
|
||||
for (const [role, ref] of [['title', sf.title], ['body', sf.body]] as const) {
|
||||
if (!ref.local) continue;
|
||||
const gap = localWeightGap(ref.local.weight, W[role]);
|
||||
if (gap.length) warn('shots', `local font ${ref.local.path} is declared as weight ${ref.local.weight}, but the templates ask the ${role} for ${gap.join(', ')}: those lines are drawn at ${ref.local.weight} (never faked bolder). Add a file with that weight or accept it`);
|
||||
else if (ref.local.weight === undefined && role === 'title') log('shots', `local font ${ref.local.path} has no weight: declared for 100-900, so a static file draws its own weight for the ${role} weights ${W[role].join(', ')}. Give { weight } for a static file`);
|
||||
}
|
||||
for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`);
|
||||
let auto = 0;
|
||||
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
|
||||
|
|
|
|||
|
|
@ -66,12 +66,19 @@ export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR',
|
|||
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
|
||||
/** One downloaded face: `face` is the @font-face family name the templates use. axis: css2 axis spec ("wght@800",
|
||||
* "wght@100..900") or null for the font's default instance. */
|
||||
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo }
|
||||
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo;
|
||||
/** Weights the templates use for this face's role (title / body): the getter fetches them (weights.ts fetchFaces). */
|
||||
weights?: number[] }
|
||||
/** What a getter returns: the covered code points, and (several weights downloaded) the files with their weight. */
|
||||
export type FaceResult = Set<number> | { cps: Set<number>; files: FaceFile[] };
|
||||
export interface FaceFile { file: string; weight: string }
|
||||
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
|
||||
export interface FontManifest {
|
||||
v: 1;
|
||||
/** local: a local font file (shots.fonts entry with a path), read in place, never copied; file = its served path. */
|
||||
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo }>;
|
||||
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo;
|
||||
/** Downloaded files per weight ("700", "200 800"); none = one file for every weight (older manifests). */
|
||||
files?: FaceFile[] }>;
|
||||
langs: Record<string, LangFonts>;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +87,8 @@ export interface PlanInput {
|
|||
title: string; body: string;
|
||||
/** css2 axis for the title / body family (null = default instance). */
|
||||
titleAxis: string | null; bodyAxis: string | null;
|
||||
/** Weights the templates use per role (weights.ts styleWeights); set = fetch these instead of titleAxis / bodyAxis. */
|
||||
titleWeights?: number[]; bodyWeights?: number[];
|
||||
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
|
||||
fallbackTitleAxis: string | null;
|
||||
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
|
||||
|
|
@ -102,7 +111,14 @@ export class GlyphError extends Error {
|
|||
/** Faces for every language's copy: title + body, one Noto face per non-Latin script (whole lines, as before), and an
|
||||
* automatic Noto fallback for each character the stack still lacks. `get` downloads a face (only the listed chars)
|
||||
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
|
||||
export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise<Set<number>>): Promise<FontManifest> {
|
||||
export async function planFonts(input: PlanInput, get0: (job: FaceJob) => Promise<FaceResult>): Promise<FontManifest> {
|
||||
const files = new Map<string, FaceFile[]>();
|
||||
const get = async (job: FaceJob): Promise<Set<number>> => {
|
||||
const r = await get0(job);
|
||||
if (r instanceof Set) return r;
|
||||
files.set(job.face, r.files);
|
||||
return r.cps;
|
||||
};
|
||||
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
|
||||
const local = input.local ?? {};
|
||||
const extra = [...new Set(input.chosen ?? [])].filter(f => !FALLBACK_ORDER.some(n => n.toLowerCase() === f.toLowerCase()));
|
||||
|
|
@ -130,13 +146,15 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
|
|||
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
|
||||
cps.set(job.face, await get(job));
|
||||
if (job.kind === 'fallback' && ![...job.chars].some(ch => cps.get(job.face)!.has(ch.codePointAt(0)!))) return; // covers none: not a face
|
||||
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}) };
|
||||
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}), ...(files.has(job.face) ? { files: files.get(job.face) } : {}) };
|
||||
};
|
||||
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title) }, true);
|
||||
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body) }, true);
|
||||
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title), ...(input.titleWeights ? { weights: input.titleWeights } : {}) }, true);
|
||||
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body), ...(input.bodyWeights ? { weights: input.bodyWeights } : {}) }, true);
|
||||
else { // same family: one download (title axis) with both roles' characters
|
||||
const t = faces[input.title]!;
|
||||
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title) }));
|
||||
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title),
|
||||
...(input.titleWeights ? { weights: [...new Set([...input.titleWeights, ...(input.bodyWeights ?? [])])].sort((a, b) => a - b) } : {}) }));
|
||||
if (files.has(input.title)) t.files = files.get(input.title);
|
||||
}
|
||||
for (const [s, v] of scripts) await add({ face: `Store ${s}`, family: v.family, file: `${s}.woff2`, kind: 'script', axis: 'wght@100..900', chars: sorted(v.chars) }, true);
|
||||
|
||||
|
|
@ -202,6 +220,10 @@ export function fontsCss(m: FontManifest): string {
|
|||
css += `@font-face { font-family: '${face}'; font-weight: ${l.weight ?? '100 900'}; font-style: ${l.style ?? 'normal'}; font-synthesis: none; src: url(${f.file}?v=${l.hash}) format('${l.format}'); }\n`;
|
||||
continue;
|
||||
}
|
||||
if (f.files?.length) { // one file per downloaded weight: a weight the family lacks is drawn at the nearest one (never faked)
|
||||
for (const x of f.files) css += `@font-face { font-family: '${face}'; font-weight: ${x.weight}; font-synthesis: none; src: url(${x.file}) format('woff2'); }\n`;
|
||||
continue;
|
||||
}
|
||||
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
|
||||
}
|
||||
return css;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
||||
import { specFamily } from './local-fonts';
|
||||
import { bgIssue } from './palette';
|
||||
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
||||
|
||||
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
|
||||
|
|
@ -94,7 +95,11 @@ export const APP_PALETTES: Record<string, Palette> = {
|
|||
export const palette = (p: string | Palette | undefined, style: 'game' | 'app' = 'game'): Palette => {
|
||||
const own = style === 'app' ? APP_PALETTES : PALETTES;
|
||||
if (!p) return style === 'app' ? APP_PALETTES.mist! : PALETTES.fire!;
|
||||
if (typeof p !== 'string') return p;
|
||||
if (typeof p !== 'string') {
|
||||
const bad = bgIssue(p.bg);
|
||||
if (bad) throw new Error(`${bad} (got ${JSON.stringify(p.bg)})`);
|
||||
return p;
|
||||
}
|
||||
const hit = own[p] ?? PALETTES[p] ?? APP_PALETTES[p];
|
||||
if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(own).join(', ')}) or pass { bg, glow, ink, shadow }`);
|
||||
return hit;
|
||||
|
|
|
|||
131
cli/shots/palette.ts
Normal file
131
cli/shots/palette.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Palette backgrounds, photo backgrounds and the contrast guard. Pure (no fs, no browser): config validation, render
|
||||
// (job.json) and `shots check` share it.
|
||||
// bg: ['#a', '#b', '#c'] | ['#a', '#b'] gradient stops (each template keeps its own angle)
|
||||
// '#15171C' | 'rgb(...)' | 'navy' one colour, flat
|
||||
// 'linear-gradient(...)' any CSS gradient, passed through as is
|
||||
// image: 'store/art/x.png', overlay: 0.45 photo under a dark overlay, cropped to cover (palette or shots.feature.background)
|
||||
import type { Palette } from '../lib/config';
|
||||
|
||||
const COLOUR_RE = /^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|(rgb|hsl)a?\([^()]*\)|[a-z]{3,30})$/i;
|
||||
const GRADIENT_RE = /^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/is;
|
||||
export const isColour = (s: unknown): s is string => typeof s === 'string' && COLOUR_RE.test(s.trim()) && !/^(none|inherit|initial|unset)$/i.test(s.trim());
|
||||
export const isGradient = (s: unknown): s is string => typeof s === 'string' && GRADIENT_RE.test(s.trim());
|
||||
|
||||
export const BG_ERROR = 'palette.bg must be a colour, a gradient or an array of 2-3 colours';
|
||||
export const DEFAULT_OVERLAY = 0.45;
|
||||
|
||||
/** Problem with a palette's bg (null = fine). */
|
||||
export function bgIssue(bg: unknown): string | null {
|
||||
if (isColour(bg) || isGradient(bg)) return null;
|
||||
if (Array.isArray(bg) && bg.length >= 2 && bg.length <= 3 && bg.every(isColour)) return null;
|
||||
return BG_ERROR;
|
||||
}
|
||||
|
||||
const overlayOk = (o: unknown): boolean => o === undefined || (typeof o === 'number' && o >= 0 && o <= 0.9);
|
||||
const imageOk = (i: unknown): boolean => typeof i === 'string' && /\.(png|jpe?g|webp)$/i.test(i.trim());
|
||||
|
||||
/** Config validation of one palette (a named palette string is checked when it is resolved). */
|
||||
export function paletteIssues(p: unknown, key: string): string[] {
|
||||
if (typeof p === 'string') return [];
|
||||
if (!p || typeof p !== 'object') return [`${key}: a palette name or { bg, glow, ink, shadow }`];
|
||||
const o = p as Partial<Palette>, e: string[] = [];
|
||||
const bi = bgIssue(o.bg);
|
||||
if (bi) e.push(`${key}.bg: ${bi} (got ${JSON.stringify(o.bg)})`);
|
||||
for (const k of ['glow', 'ink', 'shadow'] as const) if (!isColour(o[k])) e.push(`${key}.${k}: a colour`);
|
||||
if (o.image !== undefined && !imageOk(o.image)) e.push(`${key}.image: a PNG, JPEG or WebP path (relative to the config)`);
|
||||
if (!overlayOk(o.overlay)) e.push(`${key}.overlay: a number from 0 to 0.9 (dark overlay over the image)`);
|
||||
return e;
|
||||
}
|
||||
|
||||
export function featureBackgroundIssues(b: unknown): string[] {
|
||||
if (b === undefined) return [];
|
||||
const o = typeof b === 'string' ? { image: b } : b as { image?: unknown; overlay?: unknown } | null;
|
||||
if (!o || typeof o !== 'object' || !imageOk(o.image)) return ['shots.feature.background: a PNG, JPEG or WebP path or { image, overlay } (relative to the config)'];
|
||||
return overlayOk(o.overlay) ? [] : ['shots.feature.background.overlay: a number from 0 to 0.9'];
|
||||
}
|
||||
|
||||
/** The photo background in effect: shots.feature.background (feature graphic only), else the palette's image. */
|
||||
export function photoBackground(p: Palette, featureBg?: string | { image: string; overlay?: number }): { image: string; overlay: number } | null {
|
||||
const f = typeof featureBg === 'string' ? { image: featureBg } : featureBg;
|
||||
const image = f?.image ?? p.image;
|
||||
if (!image) return null;
|
||||
return { image, overlay: f?.overlay ?? p.overlay ?? DEFAULT_OVERLAY };
|
||||
}
|
||||
|
||||
/** CSS background for a palette bg at a template's angle (stops at 0 %, mid %, 100 %). */
|
||||
export function bgCss(bg: Palette['bg'], angle = 180, mid = 55): string {
|
||||
if (typeof bg === 'string') return bg.trim();
|
||||
if (bg.length === 2) return bg[0] === bg[1] ? bg[0] : `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} 100%)`;
|
||||
if (bg[0] === bg[1] && bg[1] === bg[2]) return bg[0];
|
||||
return `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} ${mid}%, ${bg[2]} 100%)`;
|
||||
}
|
||||
|
||||
/** A photo under a flat dark overlay, cropped to cover (CSS cover = coverCrop below). */
|
||||
export function photoCss(url: string, overlay: number): string {
|
||||
return `linear-gradient(rgba(0,0,0,${overlay}), rgba(0,0,0,${overlay})), url('${url}') center / cover no-repeat`;
|
||||
}
|
||||
|
||||
/** One colour that stands for the background (the middle stop, the colour, the first colour of a gradient), for
|
||||
* light / dark decisions in the templates. A photo counts as dark from an overlay of 0.35 up. */
|
||||
export function bgTone(bg: Palette['bg']): string {
|
||||
if (Array.isArray(bg)) return bg[Math.floor(bg.length / 2)]!;
|
||||
const m = /#[0-9a-f]{3,8}\b|(rgb|hsl)a?\([^()]*\)/i.exec(bg);
|
||||
return m ? m[0] : bg;
|
||||
}
|
||||
|
||||
/** Source rectangle a cover crop takes from a srcW x srcH image for an outW x outH box (centred), and the scale
|
||||
* (> 1 = the image is upscaled: too small for the output). */
|
||||
export function coverCrop(srcW: number, srcH: number, outW: number, outH: number): { x: number; y: number; w: number; h: number; scale: number } {
|
||||
const scale = Math.max(outW / srcW, outH / srcH);
|
||||
const w = outW / scale, h = outH / scale;
|
||||
return { x: (srcW - w) / 2, y: (srcH - h) / 2, w, h, scale };
|
||||
}
|
||||
|
||||
/** Problems of a photo background for one output size: missing file, smaller than the output (upscaled = blurry). */
|
||||
export function photoIssues(img: { w: number; h: number } | null, file: string, outW: number, outH: number): { level: 'error' | 'warn'; msg: string }[] {
|
||||
if (!img) return [{ level: 'error', msg: `background image ${file} not found or not a PNG / JPEG / WebP` }];
|
||||
const c = coverCrop(img.w, img.h, outW, outH);
|
||||
return c.scale > 1.001 ? [{ level: 'warn', msg: `background image ${file} is ${img.w}x${img.h}, smaller than the ${outW}x${outH} output (upscaled ${c.scale.toFixed(2)}x, blurry): use one at least ${outW}x${outH}` }] : [];
|
||||
}
|
||||
|
||||
// ---- contrast -------------------------------------------------------------------------------------------------
|
||||
|
||||
export type RGB = [number, number, number];
|
||||
|
||||
/** #rgb / #rrggbb / rgb(a)() to RGB (alpha ignored); null for anything else. */
|
||||
export function parseColour(s: string): RGB | null {
|
||||
const t = s.trim();
|
||||
let m = /^#([0-9a-f]{3})$/i.exec(t);
|
||||
if (m) return [...m[1]!].map(c => parseInt(c + c, 16)) as RGB;
|
||||
m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(t);
|
||||
if (m) { const n = parseInt(m[1]!, 16); return [n >> 16, (n >> 8) & 255, n & 255]; }
|
||||
m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(t);
|
||||
if (m) return [+m[1]!, +m[2]!, +m[3]!];
|
||||
return null;
|
||||
}
|
||||
|
||||
const lin = (c: number): number => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
|
||||
export const luminance = ([r, g, b]: RGB): number => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
/** WCAG contrast ratio, 1..21. */
|
||||
export function contrastRatio(a: RGB, b: RGB): number {
|
||||
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number];
|
||||
return (x + 0.05) / (y + 0.05);
|
||||
}
|
||||
|
||||
/** One title element's contrast as `shots render` records it: ink colour, the background samples behind the text
|
||||
* (text hidden), and the worst ratio. */
|
||||
export interface ContrastSample { el: string; ink: string; worst: RGB; ratio: number }
|
||||
|
||||
/** Worst contrast of an ink colour over background pixel samples. */
|
||||
export function worstContrast(ink: RGB, samples: RGB[]): { worst: RGB; ratio: number } {
|
||||
let best = { worst: samples[0] ?? ink, ratio: 21 };
|
||||
for (const s of samples) { const r = contrastRatio(ink, s); if (r < best.ratio) best = { worst: s, ratio: r }; }
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Large headline text: WCAG AA large text asks 3:1; under 2:1 it is close to invisible (white on white). */
|
||||
export const CONTRAST_MIN = 3;
|
||||
export function contrastIssues(samples: ContrastSample[] | undefined): { level: 'warn'; code: 'contrast'; msg: string }[] {
|
||||
return (samples ?? []).filter(s => s.ratio < CONTRAST_MIN).map(s => ({ level: 'warn' as const, code: 'contrast' as const,
|
||||
msg: `${s.el} ink ${s.ink} has low contrast against the background behind it (${s.ratio.toFixed(2)}:1, rgb(${s.worst.join(',')}); want >= ${CONTRAST_MIN}:1)${s.ratio < 1.5 ? ': the text is close to invisible' : ''}. Check palette.bg / ink (a string bg needs a colour or a CSS gradient) or raise the photo overlay` }));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { join } from 'node:path';
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { boxOutside, type DeviceBox, marginViolations, PLAY_TABLET_SIZES, SIZES, type TextBox } from './lib';
|
||||
import { fontIssues, type FontUsage } from './glyphs';
|
||||
import { type ContrastSample, contrastIssues } from './palette';
|
||||
import { type WeightSample, weightIssues } from './weights';
|
||||
import { pulledEntry, readPulled } from '../lib/art-pull';
|
||||
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
|
||||
|
||||
|
|
@ -14,7 +16,9 @@ export type RenderReport = Record<string, { overflow: string[]; missing?: string
|
|||
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
|
||||
devices?: DeviceBox[]; bleed?: boolean;
|
||||
/** Text and logo boxes in image px (from the page's layout), for the safe-margin rule (lib.ts marginViolations). */
|
||||
boxes?: TextBox[] }>;
|
||||
boxes?: TextBox[];
|
||||
/** Title weight asked vs. served (weights.ts), and title ink contrast over the pixels behind it (palette.ts). */
|
||||
weights?: WeightSample[]; contrast?: ContrastSample[] }>;
|
||||
|
||||
/** Text or logo closer to an edge than the safe margin: an error (stores crop and round corners; it reads cramped). */
|
||||
export function marginIssues(entry: RenderReport[string] | undefined, W: number, H: number): Issue[] {
|
||||
|
|
@ -88,6 +92,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
|
|||
issues.push(...fontIssues(entry?.fonts));
|
||||
issues.push(...deviceIssues(entry, size, info.w, info.h));
|
||||
issues.push(...marginIssues(entry, info.w, info.h));
|
||||
issues.push(...weightIssues(entry?.weights), ...contrastIssues(entry?.contrast));
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function imageInfo(b: Uint8Array): ImageInfo {
|
|||
}
|
||||
|
||||
export type IssueLevel = 'error' | 'warn';
|
||||
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts' | 'margin'; msg: string }
|
||||
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts' | 'margin' | 'font-weight' | 'contrast' | 'background'; msg: string }
|
||||
|
||||
/** Problems with one image for a size key. */
|
||||
export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {
|
||||
|
|
|
|||
|
|
@ -95,12 +95,14 @@ const R = document.documentElement.style;
|
|||
// #rrggbb -> rgba with alpha, for the soft accent glow
|
||||
const rgba = (hex, a) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; };
|
||||
const lum = hex => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return 1; const n = parseInt(m[1], 16); return (.299 * (n >> 16) + .587 * ((n >> 8) & 255) + .114 * (n & 255)) / 255; };
|
||||
const dark = lum(P.bg[1]) < .45;
|
||||
const tone = P.bgTone ?? (Array.isArray(P.bg) ? P.bg[1] : P.bg);
|
||||
const dark = !!P.photo && P.photo.overlay >= .35 || lum(tone) < .45;
|
||||
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--accent', P.glow); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--line2', dark ? P.glow : P.ink);
|
||||
R.setProperty('--accentSoft', rgba(P.glow, dark ? .28 : .14));
|
||||
R.setProperty('--bg', P.bg[0] === P.bg[2] && P.bg[1] === P.bg[0] ? P.bg[0] : `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
|
||||
// bgCss from job.json (palette.ts bgCss / photoCss): stops, one colour, a CSS gradient string, or a photo under an overlay
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg));
|
||||
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
|
||||
// titleStack / bodyStack (from shots fonts): script face, the configured font, then any automatic Noto fallback
|
||||
const fstack = (st, def) => (st ?? (X ? [X, def] : [def])).map(f => `'${f}'`).join(', ') + ', sans-serif';
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const RTL = !!job.rtl;
|
|||
const R = document.documentElement.style;
|
||||
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--glow', P.glow); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--bg', `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 48%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
// A script the title font lacks (CJK, Arabic, Cyrillic, ...): the whole line switches to the Noto subset so a word
|
||||
// never mixes two typefaces.
|
||||
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
|
||||
|
|
|
|||
113
cli/shots/weights.ts
Normal file
113
cli/shots/weights.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Font weights: which weights the templates ask for, how to get them from Google Fonts, and whether a rendered title
|
||||
// got the weight it asked for. The @font-face rules use font-synthesis: none, so a face that lacks a weight is drawn
|
||||
// at the nearest weight it has (a 750 headline from a 400-only face renders thin), never faked bolder.
|
||||
// shots fonts: per role (title / body) the weights of the style's templates (templateWeights); a Google family is
|
||||
// fetched as the full wght axis when it is variable, else the range the templates use, else those weights one by
|
||||
// one (static families), else the default instance (fetchFaces).
|
||||
// shots render: the title's computed weight vs. the weights the loaded faces serve (report.json `weights`);
|
||||
// shots check warns when the browser fell back to another weight (weightIssues).
|
||||
|
||||
/** Weights a page's CSS asks for, per role: rules that use var(--tf) are title text, the rest body text (the body
|
||||
* default 400 included). Literal font-weight values plus the numbers set on --tw by the page script. Pure. */
|
||||
export function templateWeights(html: string): { title: number[]; body: number[] } {
|
||||
const tw = [...html.matchAll(/setProperty\('--tw',([^;]+)\)/g)].flatMap(m => [...m[1]!.matchAll(/\b([1-9]00|[1-9][0-9]0)\b/g)].map(x => +x[1]!));
|
||||
const title = new Set<number>(), body = new Set<number>([400]);
|
||||
const style = /<style>([\s\S]*?)<\/style>/i.exec(html)?.[1] ?? '';
|
||||
for (const m of style.matchAll(/[^{}]+\{([^{}]*)\}/g)) {
|
||||
const decl = m[1]!;
|
||||
const fw = /font-weight:\s*([^;}]+)/.exec(decl)?.[1]?.trim();
|
||||
if (!fw) continue;
|
||||
const vals = fw.includes('var(--tw)') ? tw : /^\d{3}$/.test(fw) ? [+fw] : fw === 'bold' ? [700] : [];
|
||||
for (const v of vals) (decl.includes('var(--tf)') ? title : body).add(v);
|
||||
}
|
||||
const sort = (s: Set<number>): number[] => [...s].sort((a, b) => a - b);
|
||||
return { title: sort(title.size ? title : new Set([400])), body: sort(body) };
|
||||
}
|
||||
|
||||
/** Weights per role over several pages (a style's screenshot template and feature graphic). */
|
||||
export function styleWeights(pages: string[]): { title: number[]; body: number[] } {
|
||||
const t = new Set<number>(), b = new Set<number>();
|
||||
for (const p of pages) { const w = templateWeights(p); w.title.forEach(x => t.add(x)); w.body.forEach(x => b.add(x)); }
|
||||
return { title: [...t].sort((x, y) => x - y), body: [...b].sort((x, y) => x - y) };
|
||||
}
|
||||
|
||||
/** css2 axis requests to try in order: the full variable axis, the range the templates use, then the weights the
|
||||
* templates use rounded to static instances (750 -> 700 and 800). Pure. */
|
||||
export function axisCandidates(weights: number[]): string[] {
|
||||
const out = ['wght@100..900'];
|
||||
const lo = Math.min(...weights), hi = Math.max(...weights);
|
||||
if (weights.length && lo < hi) out.push(`wght@${lo}..${hi}`);
|
||||
const statics = [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
|
||||
if (statics.length) out.push(`wght@${statics.join(';')}`);
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Static weights to try one by one when no combined request works. Pure. */
|
||||
export function staticWeights(weights: number[]): number[] {
|
||||
return [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** @font-face blocks of a css2 response: weight descriptor ("400", "200 800") and the font URL. Pure. */
|
||||
export function parseFaces(css: string): { weight: string; url: string }[] {
|
||||
return [...css.matchAll(/@font-face\s*\{([^}]*)\}/g)].flatMap(m => {
|
||||
const url = /url\((https:[^)]+)\)/.exec(m[1]!)?.[1];
|
||||
const weight = /font-weight:\s*([\d ]+)/.exec(m[1]!)?.[1]?.trim() ?? '400';
|
||||
return url ? [{ weight, url }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a weight descriptor ("700", "200 800") serves a weight. */
|
||||
export function serves(desc: string, w: number): boolean {
|
||||
const [a, b] = desc.trim().split(/\s+/).map(Number) as [number, number | undefined];
|
||||
return b === undefined ? a === w : w >= a && w <= b;
|
||||
}
|
||||
|
||||
export interface FetchedFace { weight: string; bin: ArrayBuffer }
|
||||
type Fetch = (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean; status: number; text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }>;
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
|
||||
/** Downloads a Google family subset to `chars` with the weights the templates use (see axisCandidates). `axis`
|
||||
* given: that one request (fallback faces), else the weight strategy. fetchFn is injectable (tests run offline). */
|
||||
export async function fetchFaces(family: string, chars: string, o: { weights?: number[]; axis?: string | null }, fetchFn: Fetch = fetch as unknown as Fetch): Promise<FetchedFace[]> {
|
||||
const css2 = async (axis: string | null): Promise<{ weight: string; url: string }[] | null> => {
|
||||
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
|
||||
const res = await fetchFn(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) { if (axis) return null; throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`); }
|
||||
const faces = parseFaces(await res.text());
|
||||
if (!faces.length) throw new Error(`${family}: no font url`);
|
||||
return faces;
|
||||
};
|
||||
let faces: { weight: string; url: string }[] | null = null;
|
||||
if (o.weights?.length && o.axis === undefined) {
|
||||
for (const axis of axisCandidates(o.weights)) if ((faces = await css2(axis))) break;
|
||||
if (!faces) { // a static family: each weight it has, one by one
|
||||
const got: { weight: string; url: string }[] = [];
|
||||
for (const w of staticWeights(o.weights)) got.push(...((await css2(`wght@${w}`)) ?? []));
|
||||
faces = got.length ? got : null;
|
||||
}
|
||||
} else if (o.axis) faces = await css2(o.axis);
|
||||
faces ??= await css2(null);
|
||||
const out: FetchedFace[] = [];
|
||||
for (const f of faces!) {
|
||||
const res = await fetchFn(f.url, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} for ${f.url}`);
|
||||
out.push({ weight: f.weight, bin: await res.arrayBuffer() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Weights a role asks for that a local font file's declared weight does not serve (no declared weight: the file is
|
||||
* declared for 100-900, so a static file draws its own weight for all of them). */
|
||||
export function localWeightGap(declared: string | undefined, want: number[]): number[] {
|
||||
return declared === undefined ? [] : want.filter(w => !serves(declared, w));
|
||||
}
|
||||
|
||||
/** One title element as `shots render` records it: the weight asked for, the family, and the weight descriptors of
|
||||
* that family's loaded faces. */
|
||||
export interface WeightSample { el: string; family: string; want: number; served: string[] }
|
||||
|
||||
/** The browser drew a title at another weight than asked (no loaded face serves it): a warning. */
|
||||
export function weightIssues(samples: WeightSample[] | undefined): { level: 'warn'; code: 'font-weight'; msg: string }[] {
|
||||
return (samples ?? []).filter(s => s.served.length && !s.served.some(d => serves(d, s.want))).map(s => ({ level: 'warn' as const, code: 'font-weight' as const,
|
||||
msg: `${s.el} asks for weight ${s.want} but ${s.family} is loaded only as ${s.served.join(', ')}, so the browser drew another weight (thin headline): run \`storebound shots fonts\` again (it now fetches the weights the templates use), pick a family with that weight, or give a local font file's real weight` }));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue