zdymak 0.7.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,66 +1,118 @@
1
- /**
2
- * Multi-device store-screenshot generator. For each configured device group and each of its screenshot
3
- * targets, renders one still per scene (in the chosen style, at the target's exact dimensions) and writes a
4
- * store-safe **no-alpha PNG**. Scenes whose capture is missing for a device are skipped gracefully, so an
5
- * app configures only the devices it actually ships.
6
- */
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { renderStill } from './still.mjs';
10
- import { rgbPngBuffer } from './png.mjs';
11
- import { IMAGE_TARGETS } from './specs.mjs';
12
- import { inferFrame } from './frames.mjs';
13
- import { buildFeatureGraphic } from './graphic.mjs';
14
-
15
- /** Resolve a concrete [w,h] for a screenshot target: explicit override → spec w/h → first accepted size. */
16
- export function targetSize(spec, override) {
17
- if (override) return override;
18
- if (spec.w && spec.h) return [spec.w, spec.h];
19
- if (spec.accepts?.length) return spec.accepts[0];
20
- throw new Error('screenshot target has no resolvable size');
21
- }
22
-
23
- /** Build every screenshot for one resolved device group → array of written file paths. */
24
- export async function buildDeviceScreenshots({ device, brand, theme, outDir }) {
25
- const written = [];
26
- for (const shot of device.screenshots || []) {
27
- const spec = IMAGE_TARGETS[shot.target];
28
- if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
29
- const [W, H] = targetSize(spec, shot.size);
30
- const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
31
- // Infer the style from the target: a framed device (phone/tablet/watch) 'framed'; a frameless
32
- // target (Mac/desktop) 'premium' (window on the matte). Overridable per shot (e.g. Watch → 'bleed').
33
- const style = shot.style || (frame ? 'framed' : 'premium');
34
-
35
- // Feature graphic (Play banner) is a single branded image, not a per-scene shot.
36
- if (spec.graphic) {
37
- const hero = device.scenes.find((s) => fs.existsSync(s.image));
38
- if (!hero) continue;
39
- const outFile = path.join(outDir, `${shot.target}.png`);
40
- await buildFeatureGraphic({ W, H, brand, theme: shot.theme || theme, heroPath: hero.image, outFile, frame: frame || 'android' });
41
- written.push({ file: outFile, W, H, style: 'graphic' });
42
- continue;
43
- }
44
-
45
- const dir = path.join(outDir, shot.target);
46
- fs.mkdirSync(dir, { recursive: true });
47
-
48
- let n = 0;
49
- for (const scene of device.scenes) {
50
- if (!fs.existsSync(scene.image)) continue; // graceful: this device lacks this scene's capture
51
- n++;
52
- const still = await renderStill(style, {
53
- W, H,
54
- imgPath: scene.image,
55
- caption: { title: scene.title || '', sub: scene.sub || '' },
56
- brand,
57
- theme: shot.theme || theme,
58
- frame,
59
- });
60
- const file = path.join(dir, `${String(n).padStart(2, '0')}-${scene.id || n}.png`);
61
- fs.writeFileSync(file, rgbPngBuffer(still));
62
- written.push({ file, W, H, style });
63
- }
64
- }
65
- return written;
66
- }
1
+ /**
2
+ * Multi-device store-screenshot generator. For each configured device group and each of its screenshot
3
+ * targets, renders one still per scene (in the chosen style, at the target's exact dimensions) and writes a
4
+ * store-safe **no-alpha PNG**. Scenes whose capture is missing for a device are skipped gracefully, so an
5
+ * app configures only the devices it actually ships.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { renderStill } from './still.mjs';
10
+ import { rgbPngBuffer } from './png.mjs';
11
+ import { IMAGE_TARGETS } from './specs.mjs';
12
+ import { inferFrame } from './frames.mjs';
13
+ import { buildFeatureGraphic, buildAppIcon } from './graphic.mjs';
14
+ import { validateImage } from './validate.mjs';
15
+
16
+ /**
17
+ * Apply a locale's caption table to a scene list. A scene the locale doesn't translate keeps its base
18
+ * caption a store shot with the source-language headline beats a missing slot — and the caller reports
19
+ * the fallbacks rather than hiding them.
20
+ */
21
+ export function localizeScenes(scenes, table) {
22
+ if (!table) return scenes;
23
+ return scenes.map((s) => {
24
+ const t = table[s.id];
25
+ return t ? { ...s, title: t.title ?? s.title, sub: t.sub ?? s.sub } : s;
26
+ });
27
+ }
28
+
29
+ /** Scene ids in `scenes` that this locale's table doesn't translate (used for the fallback report). */
30
+ export function untranslatedScenes(scenes, table) {
31
+ if (!table) return [];
32
+ return scenes.filter((s) => !table[s.id]).map((s) => s.id);
33
+ }
34
+
35
+ /**
36
+ * Localized brand lines for the feature graphic, from the reserved `$brand` key of a caption table
37
+ * (`{ "$brand": { "tagline": "…" } }`). Only the wordmark copy is localizable — colours/logo are global.
38
+ */
39
+ export function localizeBrand(brand, table) {
40
+ const b = table?.$brand;
41
+ if (!b) return brand;
42
+ const { name, tagline, endline, endsub } = b;
43
+ return {
44
+ ...brand,
45
+ ...(name !== undefined && { name }),
46
+ ...(tagline !== undefined && { tagline }),
47
+ ...(endline !== undefined && { endline }),
48
+ ...(endsub !== undefined && { endsub }),
49
+ };
50
+ }
51
+
52
+ /** Resolve a concrete [w,h] for a screenshot target: explicit override → spec w/h → first accepted size. */
53
+ export function targetSize(spec, override) {
54
+ if (override) return override;
55
+ if (spec.w && spec.h) return [spec.w, spec.h];
56
+ if (spec.accepts?.length) return spec.accepts[0];
57
+ throw new Error('screenshot target has no resolvable size');
58
+ }
59
+
60
+ /** Build every screenshot for one resolved device group → array of written file paths. */
61
+ export async function buildDeviceScreenshots({ device, brand, theme, outDir, force }) {
62
+ const written = [];
63
+ for (const shot of device.screenshots || []) {
64
+ const spec = IMAGE_TARGETS[shot.target];
65
+ if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
66
+ const [W, H] = targetSize(spec, shot.size);
67
+ const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
68
+ // Infer the style from the target: a framed device (phone/tablet/watch) → 'framed'; a frameless
69
+ // target (Mac/desktop) → 'premium' (window on the matte). Overridable per shot (e.g. Watch → 'bleed').
70
+ const style = shot.style || (frame ? 'framed' : 'premium');
71
+
72
+ // The app icon is a branded square built from brand.logo — and the only target that keeps alpha.
73
+ if (spec.icon) {
74
+ const outFile = path.join(outDir, `${shot.target}.png`);
75
+ await buildAppIcon({ W, H, brand, theme: shot.theme || theme, outFile });
76
+ validateImage({ file: outFile, destination: spec, size: [W, H], force });
77
+ written.push({ file: outFile, W, H, style: 'icon' });
78
+ continue;
79
+ }
80
+
81
+ // Feature graphic (Play banner) is a single branded image, not a per-scene shot.
82
+ if (spec.graphic) {
83
+ const hero = device.scenes.find((s) => fs.existsSync(s.image));
84
+ if (!hero) continue;
85
+ const outFile = path.join(outDir, `${shot.target}.png`);
86
+ await buildFeatureGraphic({ W, H, brand, theme: shot.theme || theme, heroPath: hero.image, outFile, frame: frame || 'android' });
87
+ validateImage({ file: outFile, destination: spec, size: [W, H], force });
88
+ written.push({ file: outFile, W, H, style: 'graphic' });
89
+ continue;
90
+ }
91
+
92
+ // `dir` lets two shots of the SAME target coexist — e.g. a styled `play-phone/` for the website and a
93
+ // plain `play-phone-plain/` for the Play upload, which would otherwise overwrite each other.
94
+ const dir = path.join(outDir, shot.dir || shot.target);
95
+
96
+ let n = 0;
97
+ for (const scene of device.scenes) {
98
+ if (!fs.existsSync(scene.image)) continue; // graceful: this device lacks this scene's capture
99
+ if (!n) fs.mkdirSync(dir, { recursive: true }); // only once we have something to put in it
100
+ n++;
101
+ const still = await renderStill(style, {
102
+ W, H,
103
+ // `caption: false` renders the app interface alone — what Google Play asks for on store
104
+ // screenshots ("no additional text, graphics, or backgrounds that are not part of the interface").
105
+ caption: shot.caption === false ? { title: '', sub: '' } : { title: scene.title || '', sub: scene.sub || '' },
106
+ imgPath: scene.image,
107
+ brand,
108
+ theme: shot.theme || theme,
109
+ frame,
110
+ });
111
+ const file = path.join(dir, `${String(n).padStart(2, '0')}-${scene.id || n}.png`);
112
+ fs.writeFileSync(file, rgbPngBuffer(still));
113
+ validateImage({ file, destination: spec, size: [W, H], force });
114
+ written.push({ file, W, H, style });
115
+ }
116
+ }
117
+ return written;
118
+ }
package/src/specs.mjs CHANGED
@@ -1,88 +1,123 @@
1
- /**
2
- * Store spec matrix — the single source of truth for every store's asset requirements.
3
- *
4
- * VERIFY before each release: store forms change. Sources are cited inline; re-check the Apple and
5
- * Google help pages when a submission bounces on dimensions.
6
- */
7
-
8
- /** Video targets that produce an actual .mp4 file. */
9
- export const VIDEO_TARGETS = {
10
- 'appstore-preview': {
11
- store: 'App Store',
12
- label: 'App Store App Preview (iPhone 6.5" + 6.9")',
13
- w: 886,
14
- h: 1920,
15
- fps: 30,
16
- codec: 'h264',
17
- profile: 'high',
18
- level: '4.0', // Apple requires High @ 4.0 for App Previews
19
- minSec: 15,
20
- maxSec: 30,
21
- slot: 'App Store Connect your app → (localization) → App Previews. One 886×1920 file fills BOTH the 6.5" and 6.9" slots.',
22
- // Apple: https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/
23
- },
24
- 'play-promo': {
25
- store: 'Google Play',
26
- label: 'Google Play promo video (portrait 1080×1920 → upload to YouTube)',
27
- w: 1080,
28
- h: 1920,
29
- fps: 30,
30
- codec: 'h264',
31
- profile: 'high',
32
- level: '4.1', // 1080×1920@30 needs level ≥4.1
33
- minSec: null,
34
- maxSec: null,
35
- slot: 'Play does NOT take a file: upload this to YouTube, then paste the URL in Play Console → Main store listing → Preview video.',
36
- // Google: https://support.google.com/googleplay/android-developer/answer/9866151
37
- },
38
- 'social-reel': {
39
- store: 'Web / Social',
40
- label: 'Device-framed promo reel (1080×1920) — website, X/IG/TikTok, or YouTube for Play',
41
- w: 1080,
42
- h: 1920,
43
- fps: 30,
44
- codec: 'h264',
45
- profile: 'high',
46
- level: '4.1',
47
- minSec: null,
48
- maxSec: null,
49
- style: 'reel', // ← device bezel + brand background + logo bookends (buildReel, not buildVideo)
50
- slot: 'For your website, X / Instagram / TikTok, or YouTube (Play listing). NOT the App Store App Preview slot — the device bezel is rejected there.',
51
- },
52
- 'premium-reel': {
53
- store: 'Web / Social',
54
- label: 'Premium Apple-style reel (1080×1920) — matte + vignette + label pills + palette-aware cuts',
55
- w: 1080,
56
- h: 1920,
57
- fps: 30,
58
- codec: 'h264',
59
- profile: 'high',
60
- level: '4.1',
61
- minSec: null,
62
- maxSec: null,
63
- style: 'premium', // ← the Apple editing-vocabulary preset (buildPremium); themeable via `theme`
64
- slot: 'The "one device, many apps" marketing reel for web/social/YouTube. Full-bleed screens on a brand matte (no bezel) — but the label pill/matte make it a marketing asset, not an App Store App Preview.',
65
- },
66
- };
67
-
68
- /** Screenshot / graphic targets that produce still images (v0.2 — dimensions locked in now). */
69
- export const IMAGE_TARGETS = {
70
- 'appstore-iphone-6.9': { store: 'App Store', w: 1320, h: 2868, alpha: false, format: 'png', label: 'iPhone 6.9" (largest — Apple scales down for smaller iPhones)' },
71
- 'appstore-iphone-6.5': { store: 'App Store', accepts: [[1242, 2688], [1284, 2778]], alpha: false, format: 'png', label: 'iPhone 6.5" (accepts 1242×2688 or 1284×2778)' },
72
- 'appstore-ipad-13': { store: 'App Store', w: 2064, h: 2752, alpha: false, format: 'png', label: 'iPad 13" (largest iPad class)' },
73
- 'appstore-watch': { store: 'App Store', accepts: [[422, 514], [410, 502], [416, 496], [396, 484], [368, 448], [312, 390]], alpha: false, format: 'png', label: 'Apple Watch (any one accepted size; alpha NOT allowed)' },
74
- 'appstore-mac': { store: 'Mac App Store', w: 2880, h: 1800, alpha: false, format: 'png', label: 'Mac (2880×1800, 16:10 landscape — largest required class)' },
75
- 'play-phone': { store: 'Google Play', w: 1080, h: 1920, alpha: false, format: 'png', label: 'Play phone (9:16, min 1080px, no alpha)' },
76
- 'play-tablet': { store: 'Google Play', w: 2560, h: 1440, alpha: false, format: 'png', label: 'Play tablet (16:9, 1080–7680px, no alpha)' },
77
- 'play-feature-graphic': { store: 'Google Play', w: 1024, h: 500, alpha: false, format: 'png', graphic: true, label: 'Play feature graphic (1024×500, no alpha) — brand banner, not a per-scene shot' },
78
- 'play-icon': { store: 'Google Play', w: 512, h: 512, alpha: true, format: 'png', label: 'Play app icon (32-bit PNG, alpha OK)' },
79
- };
80
-
81
- /** Resolve a video target by id, throwing a helpful error listing valid ids. */
82
- export function videoTarget(id) {
83
- const t = VIDEO_TARGETS[id];
84
- if (!t) {
85
- throw new Error(`Unknown video target "${id}". Valid: ${Object.keys(VIDEO_TARGETS).join(', ')}`);
86
- }
87
- return t;
88
- }
1
+ /**
2
+ * Store spec matrix — the single source of truth for every store's asset requirements.
3
+ *
4
+ * VERIFY before each release: store forms change. Sources are cited inline; re-check the Apple and
5
+ * Google help pages when a submission bounces on dimensions.
6
+ */
7
+
8
+ /** Video targets that produce an actual .mp4 file. */
9
+ export const VIDEO_TARGETS = {
10
+ 'appstore-preview': {
11
+ store: 'App Store',
12
+ label: 'App Store App Preview (iPhone — one file fills 6.9" / 6.5" / 6.3" / 6.1")',
13
+ w: 886,
14
+ h: 1920,
15
+ fps: 30,
16
+ codec: 'h264',
17
+ profile: 'high',
18
+ level: '4.0', // Apple's wording is "up to High Profile Level 4.0" a ceiling, not a mandate
19
+ minSec: 15,
20
+ maxSec: 30,
21
+ maxBytes: 500 * 1024 * 1024,
22
+ noFrame: true, // Apple rejects a device bezel in the App Preview slot
23
+ slot: 'App Store Connect → your app → (localization) → App Previews. One 886×1920 file covers the 6.9", 6.5", 6.3" and 6.1" iPhone families. Up to 3 previews per family.',
24
+ // Apple: https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/
25
+ },
26
+ 'appstore-preview-ipad': {
27
+ store: 'App Store',
28
+ label: 'App Store App Preview (iPad 13" / 12.9" / 11" / 10.5")',
29
+ w: 1200,
30
+ h: 1600,
31
+ fps: 30,
32
+ codec: 'h264',
33
+ profile: 'high',
34
+ level: '4.0',
35
+ minSec: 15,
36
+ maxSec: 30,
37
+ maxBytes: 500 * 1024 * 1024,
38
+ noFrame: true,
39
+ slot: 'App Store Connect → App Previews (iPad). NOTE the iPad preview is 1200×1600 — NOT the 2064×2752 of the iPad screenshot slot.',
40
+ // Apple: https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/
41
+ },
42
+ 'appstore-preview-mac': {
43
+ store: 'Mac App Store',
44
+ label: 'App Store App Preview (Mac — landscape only)',
45
+ w: 1920,
46
+ h: 1080,
47
+ fps: 30,
48
+ codec: 'h264',
49
+ profile: 'high',
50
+ level: '4.0',
51
+ minSec: 15,
52
+ maxSec: 30,
53
+ maxBytes: 500 * 1024 * 1024,
54
+ noFrame: true,
55
+ slot: 'App Store Connect → App Previews (Mac). Landscape only, same as Apple TV.',
56
+ // Apple: https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/
57
+ },
58
+ 'play-promo': {
59
+ store: 'Google Play',
60
+ label: 'Google Play promo video (portrait 1080×1920 → upload to YouTube)',
61
+ w: 1080,
62
+ h: 1920,
63
+ fps: 30,
64
+ codec: 'h264',
65
+ profile: 'high',
66
+ level: '4.1', // 4.0 would also fit (8160 MBs); 4.1 is the safe, universally-decoded choice for 1080p
67
+ minSec: null,
68
+ maxSec: null,
69
+ slot: 'Play does NOT take a file: upload this to YouTube, then paste the URL in Play Console → Main store listing → Preview video.',
70
+ // Google: https://support.google.com/googleplay/android-developer/answer/9866151
71
+ },
72
+ 'social-reel': {
73
+ store: 'Web / Social',
74
+ label: 'Device-framed promo reel (1080×1920) website, X/IG/TikTok, or YouTube for Play',
75
+ w: 1080,
76
+ h: 1920,
77
+ fps: 30,
78
+ codec: 'h264',
79
+ profile: 'high',
80
+ level: '4.1',
81
+ minSec: null,
82
+ maxSec: null,
83
+ style: 'reel', // ← device bezel + brand background + logo bookends (buildReel, not buildVideo)
84
+ slot: 'For your website, X / Instagram / TikTok, or YouTube (Play listing). NOT the App Store App Preview slot — the device bezel is rejected there.',
85
+ },
86
+ 'premium-reel': {
87
+ store: 'Web / Social',
88
+ label: 'Premium Apple-style reel (1080×1920) — matte + vignette + label pills + palette-aware cuts',
89
+ w: 1080,
90
+ h: 1920,
91
+ fps: 30,
92
+ codec: 'h264',
93
+ profile: 'high',
94
+ level: '4.1',
95
+ minSec: null,
96
+ maxSec: null,
97
+ style: 'premium', // ← the Apple editing-vocabulary preset (buildPremium); themeable via `theme`
98
+ slot: 'The "one device, many apps" marketing reel for web/social/YouTube. Full-bleed screens on a brand matte (no bezel) — but the label pill/matte make it a marketing asset, not an App Store App Preview.',
99
+ },
100
+ };
101
+
102
+ /** Screenshot / graphic targets that produce still images (v0.2 — dimensions locked in now). */
103
+ export const IMAGE_TARGETS = {
104
+ 'appstore-iphone-6.9': { store: 'App Store', w: 1320, h: 2868, accepts: [[1320, 2868], [1290, 2796], [1260, 2736]], alpha: false, format: 'png', label: 'iPhone 6.9" (largest — Apple scales down for smaller iPhones; also accepts 1290×2796 and 1260×2736)' },
105
+ 'appstore-iphone-6.5': { store: 'App Store', accepts: [[1242, 2688], [1284, 2778]], alpha: false, format: 'png', label: 'iPhone 6.5" (accepts 1242×2688 or 1284×2778)' },
106
+ 'appstore-ipad-13': { store: 'App Store', w: 2064, h: 2752, alpha: false, format: 'png', label: 'iPad 13" (largest iPad class)' },
107
+ 'appstore-watch': { store: 'App Store', accepts: [[422, 514], [410, 502], [416, 496], [396, 484], [368, 448], [312, 390]], alpha: false, format: 'png', label: 'Apple Watch (any one accepted size; alpha NOT allowed)' },
108
+ 'appstore-mac': { store: 'Mac App Store', w: 2880, h: 1800, alpha: false, format: 'png', label: 'Mac (2880×1800, 16:10 landscape — largest required class)' },
109
+ 'play-phone': { store: 'Google Play', w: 1080, h: 1920, alpha: false, format: 'png', label: 'Play phone (9:16; store minimum is 320px per side, 1080+ for promotion eligibility; max 2:1; no alpha)' },
110
+ 'play-tablet': { store: 'Google Play', w: 2560, h: 1440, alpha: false, format: 'png', label: 'Play tablet (16:9, 1080–7680px, no alpha)' },
111
+ 'play-wear': { store: 'Google Play', w: 1080, h: 1080, alpha: false, format: 'png', label: 'Play Wear OS (1:1 square, 384–3840px, no alpha)' },
112
+ 'play-feature-graphic': { store: 'Google Play', w: 1024, h: 500, alpha: false, format: 'png', graphic: true, label: 'Play feature graphic (1024×500, no alpha) — brand banner, not a per-scene shot' },
113
+ 'play-icon': { store: 'Google Play', w: 512, h: 512, alpha: true, format: 'png', graphic: true, icon: true, maxBytes: 1024 * 1024, label: 'Play app icon (512×512, 32-bit PNG — the one asset where alpha is allowed; ≤1MB). Built from brand.logo.' },
114
+ };
115
+
116
+ /** Resolve a video target by id, throwing a helpful error listing valid ids. */
117
+ export function videoTarget(id) {
118
+ const t = VIDEO_TARGETS[id];
119
+ if (!t) {
120
+ throw new Error(`Unknown video target "${id}". Valid: ${Object.keys(VIDEO_TARGETS).join(', ')}`);
121
+ }
122
+ return t;
123
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Synthetic status bar for captures that reserve the inset but don't contain the system UI.
3
+ *
4
+ * WHY: an Android Compose instrumentation capture (`captureToImage()`) grabs the APP's window only. An
5
+ * edge-to-edge app still pads for the status bar, so the shot carries an empty band where the clock and
6
+ * battery should be — the system UI lives in a separate window and never lands in the PNG. iOS XCUITest
7
+ * captures the whole screen, so its shots already have one; this closes the gap.
8
+ *
9
+ * Google asks for exactly this state: "Edit excess elements in the notification bar before submitting. Do
10
+ * not show service providers or notifications. The battery, WiFi, and cell service logos should be full."
11
+ * (Play Console Help — preview assets.) Android ships SystemUI *demo mode* for the same purpose; drawing
12
+ * the bar at compose time gets the identical result without re-capturing.
13
+ *
14
+ * Nothing is ever drawn over app pixels: the band is detected as a run of IDENTICAL rows at the top, and
15
+ * the bar is painted only inside it.
16
+ */
17
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
18
+ import { roundRectPath, font } from './canvas.mjs';
19
+
20
+ /** 9:41 — Apple's keynote time, and the de-facto marketing convention on both stores. */
21
+ const DEFAULT_TIME = '9:41';
22
+
23
+ /**
24
+ * Height in px of the uniform band at the top of an image, or 0 if there isn't a plausible one.
25
+ * Bounded to 2–15% of the height: below that it's a coincidence (one flat row), above it the image is
26
+ * mostly empty and we'd be guessing.
27
+ */
28
+ export function detectBlankBand(ctx, W, H) {
29
+ const data = ctx.getImageData(0, 0, W, Math.ceil(H * 0.16)).data;
30
+ const at = (x, y) => { const i = (y * W + x) * 4; return [data[i], data[i + 1], data[i + 2]]; };
31
+ const [r0, g0, b0] = at(0, 0);
32
+ const same = (c) => Math.abs(c[0] - r0) <= 3 && Math.abs(c[1] - g0) <= 3 && Math.abs(c[2] - b0) <= 3;
33
+ const step = Math.max(1, Math.floor(W / 64)); // sample the row rather than reading every pixel
34
+ let y = 0;
35
+ const maxY = Math.floor(H * 0.15);
36
+ for (; y < maxY; y++) {
37
+ let uniform = true;
38
+ for (let x = 0; x < W; x += step) {
39
+ if (!same(at(x, y))) { uniform = false; break; }
40
+ }
41
+ if (!uniform) break;
42
+ }
43
+ return y >= H * 0.02 ? y : 0;
44
+ }
45
+
46
+ /** Perceived luminance → pick glyph colour that reads on the band it sits in. */
47
+ const isLight = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
48
+
49
+ /**
50
+ * Paint a clean status bar into the band: time on the left, cell + wifi + full battery on the right.
51
+ * Vector-drawn (no font/icon assets), scaled to the band so it looks native at any density.
52
+ */
53
+ export function drawStatusBar(ctx, W, bandH, { time = DEFAULT_TIME, color = '#0b0b0a', cellular = true } = {}) {
54
+ // Sit low enough in the strip to clear a device's corner radius, and inset far enough horizontally
55
+ // that the clock and the battery aren't chewed by the rounded corners of the screen cutout.
56
+ const cy = bandH * 0.62;
57
+ const fs = Math.round(bandH * 0.36);
58
+ const pad = Math.round(W * 0.08);
59
+
60
+ ctx.save();
61
+ ctx.fillStyle = color;
62
+ ctx.textBaseline = 'middle';
63
+ ctx.textAlign = 'left';
64
+ ctx.font = font(fs, '600'); // the registered brand/system face, same as every other caption
65
+ ctx.fillText(time, pad, cy);
66
+
67
+ // ── right cluster, laid out right-to-left ──
68
+ const u = bandH * 0.3; // glyph unit
69
+ let x = W - pad;
70
+
71
+ // Battery: rounded body, nub, full fill.
72
+ const bw = u * 1.85;
73
+ const bh = u * 0.92;
74
+ const r = bh * 0.28;
75
+ x -= bw;
76
+ const by = cy - bh / 2;
77
+ ctx.globalAlpha = 0.55;
78
+ ctx.lineWidth = Math.max(1, bh * 0.1);
79
+ ctx.strokeStyle = color;
80
+ roundRectPath(ctx, x, by, bw, bh, r);
81
+ ctx.stroke();
82
+ roundRectPath(ctx, x + bw + bh * 0.08, cy - bh * 0.16, bh * 0.12, bh * 0.32, bh * 0.06); // nub
83
+ ctx.fill();
84
+ ctx.globalAlpha = 1;
85
+ const inset = bh * 0.16;
86
+ roundRectPath(ctx, x + inset, by + inset, bw - inset * 2, bh - inset * 2, r * 0.6); // full charge
87
+ ctx.fill();
88
+
89
+ // Wi-Fi: three arcs + dot.
90
+ x -= u * 1.5;
91
+ ctx.lineWidth = Math.max(1, u * 0.16);
92
+ ctx.strokeStyle = color;
93
+ ctx.lineCap = 'round';
94
+ for (let i = 2; i >= 1; i--) {
95
+ ctx.beginPath();
96
+ ctx.arc(x, cy + u * 0.42, u * 0.34 * i, Math.PI * 1.25, Math.PI * 1.75);
97
+ ctx.stroke();
98
+ }
99
+ ctx.beginPath();
100
+ ctx.arc(x, cy + u * 0.34, u * 0.12, 0, Math.PI * 2);
101
+ ctx.fill();
102
+
103
+ // Cell signal: four full bars, drawn LEFT-to-right from this anchor, so the anchor must clear the
104
+ // wifi glyph by the bars' full width (1.44u) plus a gap — not the 1.9u that had them touching. A Wi-Fi-only tablet
105
+ // showing signal bars is a detail that's simply false, and Google asks for a status bar that reflects
106
+ // the device ("do not show service providers"), not a decorative one.
107
+ if (cellular) {
108
+ x -= u * 2.6;
109
+ const bars = 4;
110
+ const bwid = u * 0.22;
111
+ const gap = u * 0.14;
112
+ for (let i = 0; i < bars; i++) {
113
+ const h = u * (0.3 + i * 0.22);
114
+ roundRectPath(ctx, x + i * (bwid + gap), cy + u * 0.5 - h, bwid, h, bwid * 0.35);
115
+ ctx.fill();
116
+ }
117
+ }
118
+ ctx.restore();
119
+ }
120
+
121
+ /**
122
+ * If the capture reserves an empty status-bar band, fill it with a clean bar. Returns the canvas either
123
+ * way, so callers can use it unconditionally.
124
+ *
125
+ * `mode`: 'auto' (default — only when a band is detected) · true (force, using a 6% band) · false (skip).
126
+ */
127
+ export function withStatusBar(canvas, mode = 'auto', opts = {}) {
128
+ if (mode === false) return canvas;
129
+ const ctx = canvas.getContext('2d');
130
+ const { width: W, height: H } = canvas;
131
+ // A (near-)square capture is a watch face. Watches have no marketing status bar, and their corners fall
132
+ // outside a round display — auto-detection would otherwise paint glyphs into the bezel. `true` still
133
+ // forces one, for the rare square phone-ish capture.
134
+ if (mode === 'auto' && Math.abs(W - H) / Math.max(W, H) < 0.1) return canvas;
135
+ const detected = mode === true ? Math.round(H * 0.06) : detectBlankBand(ctx, W, H);
136
+ if (!detected) return canvas;
137
+ // Detection says WHETHER to draw; it must not decide how big. A sparse screen can leave a 200px band
138
+ // and a dense one 90px — scaling the bar to that gives every screen a different-sized clock, which
139
+ // reads as a mistake the moment two of them sit in the same reel. Real status bars are ~4% of height.
140
+ const band = Math.min(detected, Math.round(H * 0.052)); // ≈48dp, a real status bar's height
141
+ const px = ctx.getImageData(0, 0, 1, 1).data;
142
+ drawStatusBar(ctx, W, band, { ...opts, color: opts.color || (isLight(px) ? '#0b0b0a' : '#f5f5f4') });
143
+ return canvas;
144
+ }
145
+
146
+ /**
147
+ * Load a capture with the status bar already painted in — the single entry point every renderer uses, so
148
+ * a still and the video built from the same PNG can't disagree about whether the bar is there.
149
+ */
150
+ export async function loadCapture(imgPath, theme, frame) {
151
+ const img = await loadImage(imgPath);
152
+ const mode = theme?.statusBar ?? 'auto';
153
+ if (mode === false) return img;
154
+ const c = createCanvas(img.width, img.height);
155
+ c.getContext('2d').drawImage(img, 0, 0);
156
+ // Cellular is inferred: tablets in our frame set are Wi-Fi models, and a landscape capture is a
157
+ // tablet/desktop class. `statusBarCellular` overrides it for a cellular tablet.
158
+ const inferred = !/ipad|tablet/.test(String(frame || '')) && img.height >= img.width;
159
+ return withStatusBar(c, mode, {
160
+ time: theme?.statusBarTime,
161
+ cellular: theme?.statusBarCellular ?? inferred,
162
+ });
163
+ }