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

@ -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