shots: full-bleed layout (shots.layout bleed) with corner captions, per-size frames, and landscape orientation, on top of the landscape sizes

This commit is contained in:
CRTkafa 2026-10-23 22:45:10 +08:00
parent 9ed1890de8
commit d4cf1ad3a1
11 changed files with 320 additions and 11 deletions

View file

@ -13,6 +13,9 @@
// paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails.
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
// Layout: shots.layout 'bleed' renders template-bleed.html / feature-bleed.html instead (the frame of that size fills
// the image, the title is a corner caption with a text shadow, no device or background); shots.orientation 'landscape'
// swaps every other portrait screenshot size (renderDims in lib.ts).
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { SHOTS_PAGES } from '../lib/embedded';
import { fontCodepoints } from './cmap';
@ -91,6 +94,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { phone, tablet } = frameDims(config);
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
const style = shotsStyle(config);
const bleedLayout = shots.layout === 'bleed';
const rf = resolveShotFonts(shots, style, resolve, root);
const sf = { title: rf.title.family, body: rf.body.family };
const fonts = { title: sf.title, body: sf.body };
@ -156,10 +160,10 @@ export async function run(ctx: Ctx): Promise<void> {
const st = stacks(lang, copy);
const ls = shots.landscape?.size;
const landscape = { w: ls?.w ?? 1440, h: ls?.h ?? 900, bar: 0 };
const base = { style, phone, tablet, landscape, side: shots.landscape?.side ?? 'left', rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
const base = { style, layout: shots.layout ?? 'device', phone, tablet, landscape, side: shots.landscape?.side ?? 'left', rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
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,
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, featureTitle: shots.feature?.title,
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') ?? '';
@ -173,7 +177,7 @@ export async function run(ctx: Ctx): Promise<void> {
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));
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });
else if (/^\/(template|feature)(-app|-bleed)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });
if (!file || file.includes('..')) return new Response('no', { status: 404 });
return rt.fileResponse(file, { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' }) ?? new Response('missing ' + p, { status: 404 });
},
@ -254,7 +258,7 @@ export async function run(ctx: Ctx): Promise<void> {
const s = SIZES[size]!, d = renderDims(size, shots);
if (s.feature) {
const f = join(dir, 'feature', '1024x500.png');
items.push({ url: `${base}/${style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f });
items.push({ url: `${base}/${bleedLayout ? 'feature-bleed' : style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f });
rows.push({ size, files: [f] });
continue;
}
@ -262,7 +266,7 @@ export async function run(ctx: Ctx): Promise<void> {
for (const [i, id] of sceneIds.entries()) {
const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`);
const dev = d.landscape ? `&device=landscape&frame=${d.frame}` : s.device ? '&device=tablet' : '';
items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}${dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f });
items.push({ url: `${base}/${bleedLayout ? 'template-bleed' : style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}&size=${encodeURIComponent(size)}${bleedLayout ? '' : dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f });
files.push(f);
}
rows.push({ size, files, dims: { w: d.w, h: d.h } });