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.
- package/LICENSE +21 -21
- package/README.md +733 -211
- package/SKILL.md +183 -84
- package/bin/zdymak.mjs +3 -3
- package/package.json +66 -40
- package/src/canvas.mjs +116 -116
- package/src/capture/index.mjs +318 -242
- package/src/capture/web.mjs +137 -0
- package/src/cli.mjs +298 -165
- package/src/config.mjs +159 -96
- package/src/destinations.mjs +103 -0
- package/src/easings.mjs +74 -0
- package/src/effects.mjs +335 -0
- package/src/encode.mjs +70 -44
- package/src/fonts.mjs +42 -42
- package/src/frames.mjs +186 -168
- package/src/graphic.mjs +107 -77
- package/src/index.mjs +27 -12
- package/src/montage.mjs +428 -0
- package/src/png.mjs +50 -50
- package/src/premium.mjs +362 -351
- package/src/reel.mjs +366 -245
- package/src/screenshots.mjs +118 -66
- package/src/specs.mjs +123 -88
- package/src/statusbar.mjs +163 -0
- package/src/still.mjs +72 -62
- package/src/transitions.mjs +653 -0
- package/src/validate.mjs +98 -0
- package/src/video.mjs +243 -240
- package/types/index.d.ts +559 -0
package/src/screenshots.mjs
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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"
|
|
13
|
-
w: 886,
|
|
14
|
-
h: 1920,
|
|
15
|
-
fps: 30,
|
|
16
|
-
codec: 'h264',
|
|
17
|
-
profile: 'high',
|
|
18
|
-
level: '4.0', // Apple
|
|
19
|
-
minSec: 15,
|
|
20
|
-
maxSec: 30,
|
|
21
|
-
|
|
22
|
-
// Apple
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
'
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|