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:
parent
9ed1890de8
commit
d4cf1ad3a1
11 changed files with 320 additions and 11 deletions
|
|
@ -131,7 +131,16 @@ export interface ShotFrame { src: string; x?: number; dy?: number; scale?: numbe
|
|||
/** 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 }
|
||||
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number
|
||||
/** layout "bleed": the corner the caption sits in (top / bottom, left / right). Default "tl". */
|
||||
corner?: 'tl' | 'tr' | 'bl' | 'br';
|
||||
/** layout "bleed": caption size multiplier (0.6-1.4, default 1). */
|
||||
captionScale?: number;
|
||||
/** layout "bleed": the caption's maximum width as a share of the image width (0.2-0.9; default 0.4 landscape,
|
||||
* 0.84 portrait). Lines that do not fit shrink, down to 60 %. */
|
||||
captionWidth?: number;
|
||||
/** layout "bleed": the caption's distance from its corner as a share of the short side (0.01-0.15, default 0.05). */
|
||||
captionInset?: number }
|
||||
|
||||
/** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`;
|
||||
* `shots check` warns without one). weight / style: the @font-face descriptors (default: all weights, normal). */
|
||||
|
|
@ -144,6 +153,16 @@ export interface ShotsConfig {
|
|||
* device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles,
|
||||
* else game. */
|
||||
style?: 'game' | 'app';
|
||||
/** "device" (default): the frames inside device outlines on the style's background, titles above. "bleed": the
|
||||
* frame fills the whole image (no device, no background) and the scene's title is a short caption in one corner
|
||||
* (scene.corner), legible through a text shadow and a soft glow only. Bleed frames are captured per size at the
|
||||
* size's own pixels: <frames>/<languageId>/<size>/<name>.png (feature: <frames>/<languageId>/feature/<name>);
|
||||
* that includes the mac and chromebook sizes. */
|
||||
layout?: 'device' | 'bleed';
|
||||
/** "landscape" (only with layout "bleed"): every portrait screenshot size is rendered landscape (width and height
|
||||
* swapped; the feature graphic, mac and chromebook are landscape anyway). Both stores accept landscape
|
||||
* screenshots. Default "portrait". */
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
/** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */
|
||||
copy: string;
|
||||
/** Raw frames: <frames>/<languageId>/<phone|tablet>/<name>.png */
|
||||
|
|
@ -164,6 +183,8 @@ export interface ShotsConfig {
|
|||
* 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;
|
||||
/** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */
|
||||
title?: string;
|
||||
/** 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 } };
|
||||
|
|
@ -1214,6 +1235,15 @@ export function validateConfig(c: unknown): string[] {
|
|||
const s = cfg.shots;
|
||||
for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`);
|
||||
if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app');
|
||||
if (s.layout !== undefined && s.layout !== 'device' && s.layout !== 'bleed') e.push('shots.layout: device | bleed');
|
||||
if (s.orientation !== undefined && s.orientation !== 'portrait' && s.orientation !== 'landscape') e.push('shots.orientation: portrait | landscape');
|
||||
else if (s.orientation === 'landscape' && s.layout !== 'bleed') e.push("shots.orientation: 'landscape' needs shots.layout: 'bleed' (the device layouts draw portrait phones)");
|
||||
for (const sc of s.scenes ?? []) {
|
||||
if (sc.corner !== undefined && !['tl', 'tr', 'bl', 'br'].includes(sc.corner)) e.push(`shots.scenes ${sc.id}: corner tl | tr | bl | br`);
|
||||
if (sc.captionScale !== undefined && !(sc.captionScale >= 0.6 && sc.captionScale <= 1.4)) e.push(`shots.scenes ${sc.id}: captionScale 0.6-1.4`);
|
||||
if (sc.captionWidth !== undefined && !(sc.captionWidth >= 0.2 && sc.captionWidth <= 0.9)) e.push(`shots.scenes ${sc.id}: captionWidth 0.2-0.9`);
|
||||
if (sc.captionInset !== undefined && !(sc.captionInset >= 0.01 && sc.captionInset <= 0.15)) e.push(`shots.scenes ${sc.id}: captionInset 0.01-0.15`);
|
||||
}
|
||||
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
|
||||
if (s.landscape?.frame !== undefined && s.landscape.frame !== 'window' && s.landscape.frame !== 'none') e.push("shots.landscape.frame: 'window' | 'none'");
|
||||
if (s.landscape?.side !== undefined && s.landscape.side !== 'left' && s.landscape.side !== 'right') e.push("shots.landscape.side: 'left' | 'right'");
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -114,7 +114,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const dir = resolve(cap.folder ?? shots.frames);
|
||||
const issues = checkFrameFolder(config, dir, langs);
|
||||
for (const x of issues) warn('shots', `frames/${x.file}: ${x.msg}`);
|
||||
if (issues.length) throw new Error(`${issues.length} frame problems in ${dir} (expected <lang>/<phone|tablet>/<name>.png)`);
|
||||
if (issues.length) throw new Error(`${issues.length} frame problems in ${dir} (expected <lang>/${shots.layout === 'bleed' ? '<size>' : '<phone|tablet>'}/<name>.png)`);
|
||||
if (dir !== framesDir) warn('shots', `frames are read from shots.frames (${shots.frames}); point it at ${cap.folder} or copy them there`);
|
||||
return done('shots', `folder frames ok: ${dir}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { ShipConfig } from '../lib/config';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { log, printJson } from '../lib/log';
|
||||
import { checkFrames, type FrameIssue } from './devices';
|
||||
import { defaultSizes, frameDims, SIZES } from './lib';
|
||||
import { defaultSizes, frameDims, renderDims, SIZES } from './lib';
|
||||
import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
|
||||
import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report';
|
||||
import { imageInfo } from './spec';
|
||||
|
|
@ -65,7 +65,29 @@ export function framesNeeded(config: ShipConfig, langs: string[], tablets?: bool
|
|||
return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name }))));
|
||||
}
|
||||
|
||||
/** shots.layout 'bleed': one frame per scene for every size, at that size's pixels (<lang>/<size>/<name>; the mac and
|
||||
* chromebook sizes too), and the feature frame in <lang>/feature/. Each must have the size's aspect (1 %) and at least
|
||||
* its width (no upscaling). */
|
||||
export function checkBleedFrames(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] {
|
||||
const shots = config.shots!;
|
||||
const out: FrameIssue[] = [];
|
||||
const names = [...new Set(shots.scenes.map(s => s.frames[0]!.src))];
|
||||
for (const lang of langs) for (const size of defaultSizes(config, tablets)) {
|
||||
const feature = !!SIZES[size]!.feature;
|
||||
const want = renderDims(size, shots);
|
||||
for (const name of feature ? (shots.feature?.frame ? [shots.feature.frame] : []) : names) {
|
||||
const rel = `${lang}/${feature ? 'feature' : size}/${name}`, f = join(dir, rel);
|
||||
if (!existsSync(f)) { out.push({ file: rel, msg: 'missing' }); continue; }
|
||||
const i = imageInfo(readFileSync(f));
|
||||
if (Math.abs((i.w / i.h) / (want.w / want.h) - 1) > 0.01) out.push({ file: rel, msg: `${i.w}x${i.h} has aspect ${(i.w / i.h).toFixed(3)}, ${size} wants ${(want.w / want.h).toFixed(3)} (${want.w}x${want.h}): capture at that size` });
|
||||
else if (i.w < want.w * 0.99) out.push({ file: rel, msg: `${i.w}x${i.h} is smaller than ${want.w}x${want.h}: capture at native resolution (no upscaling)` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] {
|
||||
if (config.shots?.layout === 'bleed') return checkBleedFrames(config, dir, langs, tablets);
|
||||
const found = new Map<string, { w: number; h: number }>();
|
||||
for (const lang of langs) for (const device of ['phone', 'tablet']) {
|
||||
const d = join(dir, lang, device);
|
||||
|
|
|
|||
49
cli/shots/feature-bleed.html
Normal file
49
cli/shots/feature-bleed.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<!-- Play feature graphic for shots.layout "bleed": the frame /frames/<lang>/feature/<shots.feature.frame> fills the
|
||||
1024 x 500 canvas and the logo (shots.logo) or the name (shots.feature.title, else app.name) sits on the left with
|
||||
a text shadow and a soft glow only: no band, no scrim. Play may lay its own play button over the centre. -->
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 1024px; height: 500px; overflow: hidden; background: #000; }
|
||||
#c { position: relative; width: 1024px; height: 500px; overflow: hidden; }
|
||||
#c > img.frame { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.logo { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); max-width: 380px; max-height: 300px;
|
||||
filter: drop-shadow(0 4px 6px rgba(4,18,38,.7)) drop-shadow(0 0 28px rgba(4,22,48,.5)); }
|
||||
.name { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); max-width: 420px; font-family: var(--tf); font-weight: var(--tw);
|
||||
font-size: 84px; line-height: 1; color: #fff; text-wrap: balance;
|
||||
text-shadow: 0 0 4px rgba(4,18,38,.92), 0 3px 7px rgba(4,18,38,.92), 0 0 26px rgba(4,22,48,.55), 0 0 50px rgba(4,22,48,.55); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="c"></div>
|
||||
<script type="module">
|
||||
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 R = document.documentElement.style;
|
||||
const X = job.fonts.script;
|
||||
R.setProperty('--tf', (job.fonts.titleStack ?? (X ? [X, job.fonts.title] : [job.fonts.title])).map(f => `'${f}'`).join(', ') + ', sans-serif');
|
||||
R.setProperty('--tw', X ? 900 : job.fonts.titleWeight ?? 400);
|
||||
document.documentElement.lang = lang;
|
||||
const c = document.getElementById('c');
|
||||
const src = `/frames/${encodeURIComponent(lang)}/feature/${encodeURIComponent(job.frame)}`;
|
||||
const name = String(job.featureTitle ?? job.appName).replace(/&/g, '&').replace(/</g, '<');
|
||||
c.innerHTML = `<img class="frame" src="${src}">` + (job.logo ? '<img class="logo" src="/logo">' : `<div class="name">${name}</div>`);
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
|
||||
const overflow = [];
|
||||
const img = c.querySelector('img.frame');
|
||||
if (!img.naturalWidth) overflow.push(`feature frame ${job.frame} did not load`);
|
||||
else if (img.naturalWidth < 1024) overflow.push(`feature frame ${img.naturalWidth}x${img.naturalHeight} is upscaled: capture it at least 1024 px wide`);
|
||||
const t = c.querySelector('.name, .logo').getBoundingClientRect();
|
||||
if (t.right > 1024 || t.top < 0 || t.bottom > 500) overflow.push('the name runs off the feature graphic');
|
||||
document.body.dataset.overflow = JSON.stringify(overflow);
|
||||
document.body.dataset.devices = '[]';
|
||||
document.body.dataset.ready = '1';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -32,11 +32,13 @@ export const PLAY_TABLET_LANDSCAPE: Record<string, { w: number; h: number }> = {
|
|||
/** Pixel size and layout of one size key for a project: the landscape layout for mac / chromebook, and for the Play
|
||||
* tablet slots with shots.landscape.playTablets. frame = the device drawn around a landscape frame ('window' = a Mac
|
||||
* window, 'none' = the bare frame with rounded corners); default window on Mac, none on Play. Pure. */
|
||||
export function renderDims(size: string, shots: Pick<ShotsConfig, 'landscape'> | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } {
|
||||
export function renderDims(size: string, shots: Pick<ShotsConfig, 'landscape' | 'orientation'> | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } {
|
||||
const s = SIZES[size]!;
|
||||
const tabLand = !!shots?.landscape?.playTablets && !!PLAY_TABLET_LANDSCAPE[size];
|
||||
const landscape = s.device === 'landscape' || tabLand;
|
||||
const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : s;
|
||||
// shots.orientation 'landscape' (bleed layout): every other portrait screenshot size swaps width and height
|
||||
const swap = shots?.orientation === 'landscape' && !s.feature && !landscape;
|
||||
const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : swap ? { w: s.h, h: s.w } : s;
|
||||
const frame = shots?.landscape?.frame ?? (size === 'mac' ? 'window' : 'none');
|
||||
return { w: d.w, h: d.h, landscape, frame };
|
||||
}
|
||||
|
|
@ -310,11 +312,16 @@ export function boxOutside(b: DeviceBox, W: number, H: number, tol = 1): string[
|
|||
* loads. A missing one renders an image with no app or game content (only a 404 in the page), so `shots render`
|
||||
* refuses to start without them. Landscape layouts use each scene's first frame; the feature graphic a phone frame. */
|
||||
export function framesForRender(scenes: { frames: { src: string }[] }[], sizes: string[], langs: string[],
|
||||
shots: Pick<ShotsConfig, 'landscape'> & { feature?: { frame?: string } }): string[] {
|
||||
shots: Pick<ShotsConfig, 'landscape' | 'layout'> & { feature?: { frame?: string } }): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const lang of langs) for (const size of sizes) {
|
||||
const s = SIZES[size];
|
||||
if (!s) continue;
|
||||
if (shots.layout === 'bleed') { // one frame per scene at each size's own pixels: <lang>/<size>/<src>, the feature one in <lang>/feature/
|
||||
if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/feature/${f}`); }
|
||||
else for (const sc of scenes) if (sc.frames[0]) out.add(`${lang}/${size}/${sc.frames[0].src}`);
|
||||
continue;
|
||||
}
|
||||
if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/phone/${f}`); continue; }
|
||||
const d = renderDims(size, shots);
|
||||
const kind = d.landscape ? 'landscape' : s.device ? 'tablet' : 'phone';
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
97
cli/shots/template-bleed.html
Normal file
97
cli/shots/template-bleed.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<!-- Full-bleed layout (shots.layout "bleed"): the app's own frame fills the whole image, no device and no background,
|
||||
and a short caption sits in one corner. Legibility comes from a text shadow and a soft glow only: no band, no
|
||||
scrim, no box behind the words. template-bleed.html?s=<scene>&w=<px>&h=<px>&lang=<id>&size=<size key>.
|
||||
The frame is read at the size's own pixels: /frames/<lang>/<size>/<src> (shots check: frames for "bleed").
|
||||
Colours from the scene palette: ink = the tight outline shadow, shadow = the wide soft glow, glow = line 2.
|
||||
A dark custom palette suits it, e.g. { bg: [...], ink: 'rgba(4,18,38,.92)', shadow: 'rgba(4,22,48,.55)', glow: '#ffd76a' }. -->
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: var(--W); height: var(--H); overflow: hidden; background: #000; }
|
||||
#c { position: relative; width: var(--W); height: var(--H); overflow: hidden; }
|
||||
#c > img.frame { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cap { position: absolute; max-width: calc(var(--W) * var(--mw)); display: flex; flex-direction: column; gap: calc(var(--fs) * .06);
|
||||
font-family: var(--tf); font-weight: var(--tw); font-size: var(--fs); line-height: var(--lh); color: #fff; letter-spacing: var(--ls, .005em);
|
||||
text-wrap: balance;
|
||||
/* a dark outline-shadow for edges, then a wide soft glow: readable on light water and on dark rock alike */
|
||||
text-shadow: 0 0 calc(var(--fs) * .05) var(--ink), 0 calc(var(--fs) * .04) calc(var(--fs) * .08) var(--ink),
|
||||
0 0 calc(var(--fs) * .3) var(--shadow), 0 0 calc(var(--fs) * .6) var(--shadow); }
|
||||
.cap span { display: block; }
|
||||
.cap span + span { color: var(--accent); }
|
||||
.cap.tl { left: var(--mx); top: var(--my); }
|
||||
.cap.tr { right: var(--mx); top: var(--my); text-align: right; align-items: flex-end; }
|
||||
.cap.bl { left: var(--mx); bottom: var(--my); }
|
||||
.cap.br { right: var(--mx); bottom: var(--my); text-align: right; align-items: flex-end; }
|
||||
.cap[dir="rtl"].tl, .cap[dir="rtl"].bl { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="c"></div>
|
||||
<script type="module">
|
||||
const q = new URLSearchParams(location.search);
|
||||
const W = +q.get('w'), H = +q.get('h'), LANG = q.get('lang'), SID = q.get('s'), V = q.get('v') ?? '', SIZE = q.get('size') ?? '';
|
||||
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(LANG)}&s=${encodeURIComponent(SID)}&v=${encodeURIComponent(V)}`)).json();
|
||||
const S = job.scene, C = job.copy, P = job.palette;
|
||||
const R = document.documentElement.style;
|
||||
document.documentElement.lang = LANG;
|
||||
const short = Math.min(W, H);
|
||||
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px');
|
||||
// caption size from the short side: about 9 % of it on a landscape phone, a little less on squarer canvases
|
||||
const fs = short * (W / H > 1.9 ? .092 : W / H > 1.5 ? .08 : .066) * (S.captionScale ?? 1);
|
||||
R.setProperty('--fs', fs + 'px');
|
||||
const inset = S.captionInset ?? .05; // distance from the corner, share of the short side
|
||||
R.setProperty('--mx', (short * (inset + .005)) + 'px'); R.setProperty('--my', (short * inset) + 'px');
|
||||
// the caption keeps to its corner: at most 40 % of a landscape image's width (scene.captionWidth), so it stays clear of
|
||||
// what an app centres at the top
|
||||
const MW = S.captionWidth ?? (W > H ? .4 : .84);
|
||||
R.setProperty('--mw', MW);
|
||||
// palette: ink = the tight outline shadow, shadow = the wide soft glow, glow = the second line's colour
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow); R.setProperty('--accent', P.glow);
|
||||
const X = job.fonts.script;
|
||||
const fstack = (st, def) => (st ?? (X ? [X, def] : [def])).map(f => `'${f}'`).join(', ') + ', sans-serif';
|
||||
R.setProperty('--tf', fstack(job.fonts.titleStack, job.fonts.title));
|
||||
R.setProperty('--tw', X ? 900 : job.fonts.titleWeight ?? 400);
|
||||
const CJK = /^(ja|ko|zh)/.test(LANG);
|
||||
R.setProperty('--lh', /^(ar|hi|fa|ur|th)/.test(LANG) ? 1.3 : CJK ? 1.16 : 1.04);
|
||||
if (X) R.setProperty('--ls', '0');
|
||||
if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phrase';
|
||||
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
|
||||
const RTL = !!job.rtl;
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<');
|
||||
const corner = ['tl', 'tr', 'bl', 'br'].includes(S.corner) ? S.corner : 'tl';
|
||||
const src = `/frames/${encodeURIComponent(LANG)}/${encodeURIComponent(SIZE)}/${encodeURIComponent(S.frames[0].src)}`;
|
||||
const lines = (C.title ?? []).filter(Boolean);
|
||||
const c = document.getElementById('c');
|
||||
c.innerHTML = `<img class="frame" src="${src}"><div class="cap ${corner}"${RTL ? ' dir="rtl"' : ''}>${lines.map(l => `<span>${esc(l)}</span>`).join('')}</div>`;
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
|
||||
const overflow = []; // read by `shots render` -> report.json -> shots check / gallery
|
||||
const img = c.querySelector('img.frame');
|
||||
if (!img.naturalWidth) overflow.push(`frame ${SIZE}/${S.frames[0].src} did not load`);
|
||||
else {
|
||||
const ar = img.naturalWidth / img.naturalHeight, want = W / H;
|
||||
if (Math.abs(ar / want - 1) > .01) overflow.push(`frame ${img.naturalWidth}x${img.naturalHeight} is cropped to fit ${W}x${H}: capture it at the size's aspect`);
|
||||
if (img.naturalWidth < W * .99) overflow.push(`frame ${img.naturalWidth}x${img.naturalHeight} is upscaled to ${W}x${H}: capture it at native resolution`);
|
||||
}
|
||||
const cap = c.querySelector('.cap');
|
||||
const maxW = W * MW;
|
||||
for (const [i, sp] of [...cap.querySelectorAll('span')].entries()) { // shrink a line that would not fit its corner
|
||||
const wd = sp.scrollWidth;
|
||||
let f = 1; while (wd * f > maxW && f > .6) f -= .02;
|
||||
if (f < 1) sp.style.fontSize = `${(fs * f).toFixed(1)}px`;
|
||||
if (wd * f > maxW + 1) overflow.push(`caption line ${i + 1} is too long even at 60 % size: shorten it`);
|
||||
}
|
||||
const r = cap.getBoundingClientRect();
|
||||
if (r.left < 0 || r.top < 0 || r.right > W || r.bottom > H) overflow.push('caption runs off the image');
|
||||
if (r.height > H * .42) overflow.push(`caption covers ${Math.round(r.height / H * 100)} % of the height: shorten it`);
|
||||
if (lines.join(' ').split(/\s+/).length > 6 && !CJK && !/^th/.test(LANG)) overflow.push('caption has more than 6 words: keep it to 3-5');
|
||||
document.body.dataset.overflow = JSON.stringify(overflow);
|
||||
document.body.dataset.devices = '[]';
|
||||
document.body.dataset.ready = '1';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue