shots: Mac App Store screenshots (mac size, APP_DESKTOP to the macOS version), a landscape layout (Mac window or bare; Mac, Play Chromebook, Play tablets), safe margins in every template checked by shots check from the render's layout boxes, a contact sheet timeout that scales with images, sizes and load; pitfall 80

This commit is contained in:
CRTkafa 2026-10-20 03:29:19 +07:00
parent 30488c512d
commit e59539fec8
26 changed files with 532 additions and 67 deletions

View file

@ -9,6 +9,11 @@
style=android (Play 7" / 10" tablets): a plain Android tablet body (thin dark bezel, punch-hole camera) instead of the iPad look.
/job.json gives the scene (palette, frames, badge slots), the words for this language and the font families.
Layout: everything is in u = W / 100; the text block is measured first, then the phone fills the room below it.
device=landscape (Mac, Chromebook, Play tablets with shots.landscape.playTablets): u = H / 100, the copy in a column on
one side (job.side), the first frame of the scene from frames/<lang>/landscape/ on the other, in a Mac window
(frame=window) or bare with rounded corners (frame=none). Badges are not drawn in landscape.
Safe margins (lib.ts SAFE_MARGIN): text and logo keep 7 % of the width left and right and 5 % of the height at the
top; their boxes go to body.dataset.boxes -> report.json -> shots check.
-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -34,7 +39,7 @@
/* text block */
.top { position: absolute; left: 0; right: 0; top: calc(var(--u) * var(--top)); text-align: center; z-index: 6;
padding: 0 calc(var(--u) * 5); display: flex; flex-direction: column; align-items: center; }
padding: 0 calc(var(--W) * .07); display: flex; flex-direction: column; align-items: center; }
.eyebrow { display: inline-flex; align-items: center; gap: calc(var(--u) * 1.6); margin-bottom: calc(var(--u) * 3);
padding: calc(var(--u) * 1.15) calc(var(--u) * 3) calc(var(--u) * 1.15) calc(var(--u) * 1.3); border-radius: 999px;
background: rgba(0,0,0,.26); box-shadow: inset 0 0 0 calc(var(--u) * .25) rgba(255,255,255,.28);
@ -51,7 +56,7 @@
p.sub { margin-top: calc(var(--u) * 2.6); max-width: calc(var(--u) * 80); font-size: calc(var(--u) * var(--sub));
line-height: 1.32; font-weight: 700; color: #fff; text-wrap: balance;
text-shadow: 0 calc(var(--u) * .3) calc(var(--u) * 1) rgba(0,0,0,.35); }
.logo { width: calc(var(--u) * 86); max-height: calc(var(--u) * 36); object-fit: contain; margin: calc(var(--u) * -1) 0 calc(var(--u) * 1);
.logo { width: 100%; max-height: calc(var(--u) * 32); object-fit: contain; margin: 0 0 calc(var(--u) * 1);
filter: drop-shadow(0 calc(var(--u) * 1.5) calc(var(--u) * 2.5) rgba(0,0,0,.35)); }
/* phone */
@ -84,6 +89,20 @@
.android .island { width: calc(var(--pw) * .013); height: calc(var(--pw) * .013); top: calc(var(--pw) * .009); }
.android .bar { padding: 0 4%; }
/* landscape: copy column + a Mac window (or the bare frame) */
.land .top { right: auto; width: var(--colW); padding: 0; left: var(--colL); }
.land h1 span { margin: 0 auto; }
.lwin { position: absolute; z-index: 3; left: var(--lx); top: var(--ly); width: var(--lw); border-radius: calc(var(--lw) * .012);
overflow: hidden; background: #1c1c1e; box-shadow: 0 0 0 calc(var(--u) * .12) rgba(255,255,255,.18),
0 calc(var(--u) * 2) calc(var(--u) * 5) rgba(0,0,0,.35), 0 calc(var(--u) * 5) calc(var(--u) * 12) var(--shadow); }
.lwin .tb { height: calc(var(--lw) * .028); display: flex; align-items: center; gap: calc(var(--lw) * .006); padding-left: calc(var(--lw) * .012);
background: linear-gradient(180deg, #3a3a3c, #2c2c2e); }
.lwin .tb i { width: calc(var(--lw) * .009); height: calc(var(--lw) * .009); border-radius: 50%; background: #ff5f57; }
.lwin .tb i:nth-child(2) { background: #febc2e; } .lwin .tb i:nth-child(3) { background: #28c840; }
.lwin img { display: block; width: 100%; aspect-ratio: var(--lar); object-fit: cover; }
.lwin.bare { border-radius: calc(var(--lw) * .02); }
.lwin.bare .tb { display: none; }
/* badges */
.badge { position: absolute; z-index: 9; display: flex; align-items: center; gap: calc(var(--u) * 2.2);
padding: calc(var(--u) * 1.7) calc(var(--u) * 3.6) calc(var(--u) * 1.7) calc(var(--u) * 1.7); border-radius: calc(var(--u) * 4);
@ -110,12 +129,14 @@
<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') ?? '';
const tablet = q.get('device') === 'tablet';
const tablet = q.get('device') === 'tablet', LAND = q.get('device') === 'landscape';
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 u = W / 100, Hu = H / u;
const dev = tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
const FR = `/frames/${encodeURIComponent(LANG)}/${tablet ? 'tablet' : 'phone'}/`;
const u = LAND ? H / 100 : W / 100, Hu = H / u;
const dev = LAND ? job.landscape : tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
const FR = `/frames/${encodeURIComponent(LANG)}/${LAND ? 'landscape' : tablet ? 'tablet' : 'phone'}/`;
const MX = W * .07, MT = H * .05; // safe margins (lib.ts SAFE_MARGIN)
const colW = W * .36, colL = job.side === 'right' ? W - MX - colW : MX;
document.documentElement.lang = LANG;
const RTL = !!job.rtl;
@ -139,11 +160,12 @@ if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phra
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
if (tablet) document.body.classList.add('tablet');
if (q.get('style') === 'android') document.body.classList.add('android');
const tall = Hu > 200; // iOS 6.9" / 6.5" vs 16:9
if (LAND) { document.body.classList.add('land'); R.setProperty('--colW', colW + 'px'); R.setProperty('--colL', colL + 'px'); }
const tall = !LAND && Hu > 200; // iOS 6.9" / 6.5" vs 16:9
const wide = Hu < 150; // 3:4 iPad
R.setProperty('--top', tall ? 9 : wide ? 4 : 5.5);
R.setProperty('--fs', S.hero ? (tall ? 10.5 : wide ? 7 : 8.6) : (tall ? 12 : wide ? 7.6 : 9.8));
R.setProperty('--sub', tall ? 3.75 : wide ? 2.7 : 3.3);
R.setProperty('--top', Math.max(tall ? 9 : wide ? 4 : 5.5, MT / u));
R.setProperty('--fs', LAND ? (S.hero ? 9 : 10) : S.hero ? (tall ? 10.5 : wide ? 7 : 8.6) : (tall ? 12 : wide ? 7.6 : 9.8));
R.setProperty('--sub', LAND ? 3.6 : tall ? 3.75 : wide ? 2.7 : 3.3);
const AR = dev.w / (dev.h + dev.bar);
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
@ -188,7 +210,7 @@ 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
for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) { // shrink a line that would overflow (long words)
const max = W - 10 * u, wd = sp.getBoundingClientRect().width;
const max = LAND ? colW : W - 2 * MX, wd = sp.getBoundingClientRect().width;
let f = 1; while (wd * f > max && f > .55) f -= .02;
if (f < 1) sp.style.fontSize = `calc(${getComputedStyle(sp).fontSize} * ${f.toFixed(2)})`;
if (wd * f > max + 1) overflow.push(`title line ${i + 1} is too long even at 55 % size: shorten it`);
@ -197,8 +219,24 @@ for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) { // shrink
const subEl = c.querySelector('p.sub');
if (subEl) { const lines = Math.round(subEl.getBoundingClientRect().height / parseFloat(getComputedStyle(subEl).lineHeight)); if (lines > 3) overflow.push(`subtitle wraps to ${lines} lines (max 3)`); }
const eb = c.querySelector('.eyebrow');
if (eb && eb.getBoundingClientRect().width > W - 8 * u) overflow.push('eyebrow is wider than the image');
if (eb && eb.getBoundingClientRect().width > (LAND ? colW : W - 2 * MX)) overflow.push('eyebrow is wider than the image');
const textBottom = c.querySelector('.top').getBoundingClientRect().bottom / u;
portrait: {
if (LAND) {
const topEl = c.querySelector('.top'), bh = topEl.getBoundingClientRect().height;
topEl.style.top = Math.max(MT, (H - bh) / 2) + 'px';
if (bh > H - MT * 2) overflow.push('text block is taller than the image: shorten the copy');
const f = S.frames[0], src = FR + encodeURIComponent(f.src), bare = q.get('frame') === 'none';
const x0 = job.side === 'right' ? W * .04 : colL + colW + W * .05, x1 = job.side === 'right' ? colL - W * .05 : W - W * .04;
let lw = x1 - x0;
const lh = k => k / (dev.w / dev.h) + (bare ? 0 : k * .028);
if (lh(lw) > H * .88) lw = lw * H * .88 / lh(lw);
R.setProperty('--lar', `${dev.w} / ${dev.h}`);
R.setProperty('--rayY', H / 2 + 'px'); R.setProperty('--rayRot', (rnd() * 20) + 'deg');
c.insertAdjacentHTML('beforeend', `<div class="lwin front${bare ? ' bare' : ''}" style="--lw:${lw}px;--lx:${(x0 + x1 - lw) / 2}px;--ly:${(H - lh(lw)) / 2}px"><div class="tb"><i></i><i></i><i></i></div><img src="${src}"></div>`);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
break portrait;
}
const frames = S.frames.map(f => ({ x: 0, dy: 0, scale: 1, rotate: 0, ...f }));
const front = frames[0];
@ -244,9 +282,13 @@ c.insertAdjacentHTML('beforeend', html);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
if (textBottom > Hu * .48) overflow.push(`text block covers ${Math.round(textBottom / Hu * 100)} % of the height (the game should be the star)`);
for (const b of c.querySelectorAll('.badge')) { const r = b.getBoundingClientRect(); if (r.left < 0 || r.right > W || r.bottom > H) overflow.push(`badge "${b.textContent.trim().slice(0, 30)}" runs off the image`); }
}
document.body.dataset.overflow = JSON.stringify(overflow);
// text and logo boxes for the safe-margin rule (shots check)
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.top .logo, .top .eyebrow, .top h1 span, .top p.sub')].map(e => { const r = e.getBoundingClientRect();
return { el: e.matches('.logo') ? 'logo' : e.matches('.eyebrow') ? 'eyebrow' : e.matches('p') ? 'subtitle' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }).filter(b => b.w > 0 && b.h > 0));
// the front device only: back phones of a fanned layout peek in from the side on purpose
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front, .lwin')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>