zdymak 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zdymak contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # zdymak
2
+
3
+ **Premium App Store & Google Play preview videos β€” from your screenshots, in one command.**
4
+
5
+ Turn a handful of app screenshots into a cinematic, **spec-compliant** store preview: spring-eased camera
6
+ moves, kinetic captions, and an encode that App Store Connect and Google Play accept without a fight. One
7
+ small config per project; the engine lives here, shared across all your apps.
8
+
9
+ > Why it exists: a plain "Ken-Burns zoom over screenshots" reads as amateur, and hand-encoding to each
10
+ > store's exact spec (Apple: 886Γ—1920, H.264 High@4.0, 15–30s, **no device frame**) is fiddly and easy to
11
+ > get rejected. This bakes the premium motion and the store rules in.
12
+
13
+ <br>
14
+
15
+ ## What you get
16
+
17
+ | | |
18
+ |---|---|
19
+ | 🎬 **Premium motion** | Per-scene **spring dolly** (push-in / pull-back / drift) that eases and *settles* β€” not a flat pan. Captions sit **outside** the camera so they stay steady while the screen drifts (parallax), and rise in with a spring. |
20
+ | βœ… **Store-compliant** | Full-bleed (no bezel β€” Apple rejects bezels), exact resolution, H.264 High @ the right level, yuv420p, faststart. |
21
+ | 🍎 **App Store + πŸ€– Play** | One 886Γ—1920 file fills both iPhone App Preview slots; a 1080Γ—1920 file is ready for a Play/YouTube promo. |
22
+ | 🧩 **Two input modes** | **Bring your own** screenshots, *or* **capture** them from a booted simulator / device. |
23
+ | βš™οΈ **Config-driven** | Everything project-specific is one `zdymak.config.mjs`. The tool is otherwise generic. |
24
+
25
+ <br>
26
+
27
+ ## Install
28
+
29
+ Needs **Node β‰₯18** and **ffmpeg** on your `PATH` (`brew install ffmpeg`).
30
+
31
+ ```sh
32
+ # from a checkout (until published to npm):
33
+ npm i --prefix /path/to/zdymak
34
+ npm link --prefix /path/to/zdymak # gives you the `zdymak` command
35
+ # …or just call it directly:
36
+ node /path/to/zdymak/bin/zdymak.mjs <command>
37
+ ```
38
+
39
+ <br>
40
+
41
+ ## Quickstart
42
+
43
+ **1. Add a config** to your project root (copy `examples/example.config.mjs` β†’ `zdymak.config.mjs`):
44
+
45
+ ```js
46
+ export default {
47
+ brand: { ink: '#0b0b0a', title: '#F5F5F4', sub: '#9ae6b4' },
48
+ screenshotsDir: './screenshots', // where your PNGs live
49
+ suffix: '', // scene id "welcome" β†’ screenshots/welcome.png
50
+ scenes: [
51
+ { id: 'welcome', title: 'Your hook.', sub: 'The promise, briefly.', move: 'pushInSlow' },
52
+ { id: 'feature-1', title: 'What it does.', sub: 'Why it matters.', move: 'driftUp' },
53
+ { id: 'offer', title: 'The honest offer.', sub: 'Price / trial, plainly.', move: 'pullBackSlow' },
54
+ ],
55
+ targets: ['appstore-preview', 'play-promo'],
56
+ out: './store-assets',
57
+ };
58
+ ```
59
+
60
+ **2. Build:**
61
+
62
+ ```sh
63
+ zdymak video
64
+ # β†’ store-assets/appstore-preview.mp4 (886Γ—1920, upload to App Store Connect)
65
+ # β†’ store-assets/play-promo.mp4 (1080Γ—1920, upload to YouTube, link in Play Console)
66
+ ```
67
+
68
+ That's it. `zdymak specs` prints every target and its exact dimensions.
69
+
70
+ <br>
71
+
72
+ ## The two ways to get screenshots
73
+
74
+ **Mode A β€” bring your own** (default). Point `screenshotsDir` at any folder of PNGs β€” from Xcode, an
75
+ XCUITest capture, Android Studio, Figma, anywhere.
76
+
77
+ **Mode B β€” capture from a running app.** Boot a simulator/emulator (or connect a device), navigate to a
78
+ screen, and snap it. The tool strips the alpha channel (stores reject transparency) and writes a
79
+ store-ready PNG straight into your screenshots folder:
80
+
81
+ ```sh
82
+ zdymak capture --platform ios --name welcome --out ./screenshots
83
+ zdymak capture --platform android --name welcome --out ./screenshots
84
+ zdymak capture --platform ios --record --out ./screenshots # screen-record; Ctrl-C to stop
85
+ ```
86
+
87
+ It does **not** build your app β€” that's your toolchain. It captures whatever's on screen, so you drive the
88
+ navigation.
89
+
90
+ <br>
91
+
92
+ ## Config reference
93
+
94
+ | Key | Meaning |
95
+ |---|---|
96
+ | `brand.ink` / `.title` / `.sub` | Hex colours for the caption scrim + title + subtitle. |
97
+ | `brand.fontPaths` | Optional custom TTFs (else: system font β€” San Francisco on macOS). |
98
+ | `brand.name` / `.tagline` / `.endline` / `.endsub` / `.logo` | **Reel target only** β€” wordmark, cold-open tagline, end-card lines, and the icon PNG for the device-framed `social-reel` bookends. |
99
+ | `brand.reel` | **Reel target only** β€” optional palette overrides (`bgTop`, `bgBottom`, `glowLight`, `matteTop`, `matteBottom`, `glowDark`, `titleColor`, `subColor`, `bookendTitle`, `bookendSub`). |
100
+ | `screenshotsDir` + `suffix` | Resolve `scene.id` β†’ `${screenshotsDir}/${id}${suffix}.png`. |
101
+ | `scenes[]` | `{ id \| image, title, sub, move }`. `image` overrides the id lookup. |
102
+ | `scenes[].move` | `pushIn` Β· `pushInSlow` Β· `pullBack` Β· `pullBackSlow` Β· `driftUp` Β· `driftDown` Β· `driftLeft` Β· `driftRight` Β· `still`. Omit to auto-vary. |
103
+ | `targets[]` | Which videos to build from the top-level scenes (`zdymak specs` lists them). |
104
+ | `sceneDur` / `xfade` | Seconds per scene / cross-dissolve. Tune total length to the store's 15–30s window. |
105
+ | `theme` | Premium-technique styling (matte, `vignette`, `inset`, `handle`, cut timings). Brand-driven defaults. |
106
+ | `music` | Optional bed for **every** video: `{ path, offset, fadeIn, fadeOut, volume }` (silent if omitted). |
107
+ | `devices` | Per-device **screenshots + reels** (see below). Configure only the devices you ship. |
108
+ | `out` | Output directory. |
109
+
110
+ <br>
111
+
112
+ ## Screenshots & multiple devices
113
+
114
+ Videos are only half the set. `zdymak build` also renders **store screenshots** for each device you
115
+ configure, in the same styles (premium / bleed), at each store's exact dimensions, as **no-alpha PNGs**
116
+ (App Store & Play reject alpha). Each device points at its own captures; scenes with no matching capture
117
+ are **skipped cleanly**, so an app lists only the devices it actually ships:
118
+
119
+ ```js
120
+ devices: {
121
+ iphone: { capturesDir: './shots/iphone', suffix: '', screenshots: [{ target: 'appstore-iphone-6.9', style: 'premium' }] },
122
+ ipad: { capturesDir: './shots/ipad', suffix: '', screenshots: [{ target: 'appstore-ipad-13', style: 'premium' }] },
123
+ mac: { capturesDir: './shots/mac', suffix: '', screenshots: [{ target: 'appstore-mac', style: 'premium' }] },
124
+ watch: { capturesDir: './shots/watch', suffix: '',
125
+ scenes: [{ id: '01-study' }, { id: '02-answer' }], // per-device scene override (raw, no caption)
126
+ screenshots: [{ target: 'appstore-watch', style: 'bleed', size: [422, 514] }] },
127
+ android:{ capturesDir: './shots/android', suffix: '', screenshots: [
128
+ { target: 'play-phone', style: 'framed' }, // Android punch-hole frame
129
+ { target: 'play-tablet', style: 'premium' },
130
+ { target: 'play-feature-graphic' } ] }, // the 1024Γ—500 Play banner (not per-scene)
131
+ // a device may also carry `videos: [{ target: 'premium-reel', size: [2064, 2752] }]` at its own dimensions
132
+ },
133
+ ```
134
+
135
+ The **`framed`** style picks the right bezel per target β€” iPhone (Dynamic Island), Android (punch-hole),
136
+ iPad/tablet, or Watch ring β€” and Mac stays plain (its captures are already windowed). **`play-feature-graphic`**
137
+ is special: one 1024Γ—500 brand banner (logo + tagline + a tilted hero device), not a per-scene screenshot.
138
+
139
+ Commands:
140
+
141
+ ```sh
142
+ zdymak build # EVERYTHING: top-level videos + every device's screenshots (+ device videos)
143
+ zdymak screenshots # just the per-device screenshots
144
+ zdymak video # just the top-level video targets
145
+ ```
146
+
147
+ `zdymak specs` lists every image target and its exact dimensions. A device that only ships iPhone simply
148
+ omits the others β€” that's the "use only part of it" contract.
149
+
150
+ <br>
151
+
152
+ ## Where each file goes
153
+
154
+ - **App Store** β€” `appstore-preview.mp4` β†’ App Store Connect β†’ your app β†’ *(localization)* β†’ **App
155
+ Previews**. One 886Γ—1920 file fills **both** the 6.5" and 6.9" slots. Optional at launch; screenshots
156
+ alone are a valid submission.
157
+ - **Google Play** β€” Play takes a **YouTube URL**, not a file. Upload `play-promo.mp4` to YouTube, then
158
+ paste the link in Play Console β†’ Main store listing β†’ **Preview video**.
159
+ - **Web / social** β€” `social-reel.mp4` (**device-framed**: iPhone bezel + logo bookends) or
160
+ `premium-reel.mp4` (**premium**: matte + vignette + label pills). For your website, X / Instagram /
161
+ TikTok, or YouTube. **Never** put either in the App Store App Preview slot β€” the bezel/label make them
162
+ marketing assets, not App Previews; that's what the full-bleed `appstore-preview` is for.
163
+
164
+ > **Three styles, one config.** Every target reads the same `scenes`; the style is fixed per target:
165
+ > - **full-bleed** (`appstore-preview`, `play-promo`) β€” the screen fills the frame; required for App Previews.
166
+ > - **device-framed** (`social-reel`) β€” an iPhone bezel + brand background + logo cold-open/end-card.
167
+ > - **premium** (`premium-reel`) β€” the **Apple editing-vocabulary** preset: every screen floats on a brand
168
+ > matte with a soft glow + vignette, a motion-then-freeze spring dolly, **palette-aware cuts** (hard cut
169
+ > within a palette, dissolve only at a shift), and a bottom title **pill**. Tune it with the optional
170
+ > `theme` block (matte colours, `vignette`, `inset`, `handle`, cut timings) β€” brand-driven defaults apply
171
+ > if you omit it.
172
+
173
+ <br>
174
+
175
+ ## Programmatic use
176
+
177
+ ```js
178
+ import { buildVideo, loadConfig, registerFonts, videoTarget } from 'zdymak';
179
+
180
+ const cfg = await loadConfig('zdymak.config.mjs');
181
+ registerFonts(cfg.brand.fontPaths);
182
+ await buildVideo({ scenes: cfg.scenes, spec: videoTarget('appstore-preview'), brand: cfg.brand, outFile: 'out.mp4' });
183
+ ```
184
+
185
+ <br>
186
+
187
+ ## Troubleshooting
188
+
189
+ - **`ffmpeg failed to start`** β€” install it (`brew install ffmpeg`) or set `$FFMPEG`.
190
+ - **Captions look like a fallback font** β€” pass `brand.fontPaths` to your own TTF; on Linux install a
191
+ system sans (DejaVu).
192
+ - **Duration warning** β€” the tool warns if a video falls outside a store's min/max; adjust `sceneDur` or
193
+ the number of scenes.
194
+ - **`Truncating packet …` line** β€” harmless ffmpeg notice from the raw-frame pipe; the output is correct.
195
+
196
+ <br>
197
+
198
+ ## Roadmap
199
+
200
+ - [x] Video engine β€” three styles (full-bleed, device-framed, premium), App Store + Play + social targets.
201
+ - [x] **Multi-device screenshots** β€” iPhone / iPad / Mac / Watch, no-alpha PNG, modular `devices` config.
202
+ - [x] **Music bed** β€” `{ path, offset, fadeIn, fadeOut, volume }` across every video.
203
+ - [x] Automated publishing β€” npm trusted publishing (OIDC), see `RELEASING.md`.
204
+ - [ ] **Device-framed stills** β€” iPhone/iPad bezel, Mac window, Watch ring for the `framed` screenshot style
205
+ (premium/bleed already cover every device).
206
+ - [ ] Capture: **Playwright (web)** driver (adb / iOS-sim snapshot already ship).
207
+ - [ ] Play feature graphic (1024Γ—500) + per-locale caption sets.
208
+
209
+ See **SKILL.md** if you drive this with Claude Code.
210
+
211
+ MIT.
package/SKILL.md ADDED
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: zdymak
3
+ description: Generate premium, spec-compliant App Store & Google Play preview VIDEOS from a project's screenshots. Use when a user wants an "app preview", "store preview video", "App Store trailer", "Play promo video", or is preparing store-listing assets. Produces the exact resolution/codec each store requires (App Store 886Γ—1920 H.264 High@4.0, 15–30s, no bezel; Play 1080Γ—1920 for YouTube) with cinematic spring motion + kinetic captions β€” not a flat Ken-Burns slideshow.
4
+ ---
5
+
6
+ # zdymak
7
+
8
+ A standalone CLI that turns app screenshots into premium, store-compliant preview videos. Everything
9
+ project-specific is one `zdymak.config.mjs`; the engine (spring camera moves, parallax captions,
10
+ per-store encode) is shared.
11
+
12
+ ## When to use
13
+ The user is building App Store / Google Play listing assets, or asks for an app-preview / store trailer /
14
+ promo video. If they only need still screenshots resized to store dimensions, note that image targets are
15
+ on the roadmap (dimensions are in `zdymak specs`) and the video flow is what's built.
16
+
17
+ ## Prerequisites (check first)
18
+ - `ffmpeg` on PATH (`brew install ffmpeg`) β€” the encode needs it.
19
+ - Node β‰₯18.
20
+ - The tool itself: run via `zdymak …` if linked, else `node <path-to>/zdymak/bin/zdymak.mjs …`.
21
+
22
+ ## Workflow
23
+
24
+ 1. **Confirm the store spec** the user wants: `zdymak specs` lists every target with exact
25
+ dimensions. App Store App Previews must be **15–30s** and have **no device frame**; the engine already
26
+ enforces both.
27
+
28
+ 2. **Get screenshots** β€” two modes:
29
+ - **Bring-your-own:** point `screenshotsDir` at existing PNGs (Xcode/XCUITest/Android/Figma).
30
+ - **Capture:** boot a simulator/emulator, navigate to each screen, then
31
+ `zdymak capture --platform ios|android --name <screen> --out ./screenshots`. It strips alpha
32
+ (stores reject it). It does NOT build the app β€” you drive navigation.
33
+
34
+ 3. **Author the config** (`zdymak.config.mjs` at project root). Keep captions **terse** (a preview
35
+ autoplays muted in search results β€” it must read in ~2.5s per scene). Order the scenes as a narrative:
36
+ hook β†’ what it does β†’ the best moment β†’ the honest offer. Pick `move` per scene for variety, or omit to
37
+ auto-vary. Example:
38
+ ```js
39
+ export default {
40
+ brand: { ink: '#0b0b0a', title: '#F5F5F4', sub: '#9ae6b4' },
41
+ screenshotsDir: './screenshots', suffix: '',
42
+ scenes: [
43
+ { id: 'welcome', title: 'One-line hook.', sub: 'The promise.', move: 'pushInSlow' },
44
+ { id: 'best', title: 'The best moment.', sub: 'Show, don’t tell.', move: 'pullBack' },
45
+ { id: 'offer', title: 'The honest offer.', sub: 'Price / trial.', move: 'pullBackSlow' },
46
+ ],
47
+ targets: ['appstore-preview', 'play-promo'],
48
+ out: './store-assets',
49
+ };
50
+ ```
51
+
52
+ 4. **Build:** `zdymak video`. Then **verify** with ffprobe before telling the user it's ready:
53
+ ```sh
54
+ ffprobe -v error -select_streams v:0 \
55
+ -show_entries stream=width,height,codec_name,profile,level,pix_fmt -show_entries format=duration \
56
+ -of default=noprint_wrappers=1 store-assets/appstore-preview.mp4
57
+ ```
58
+ Confirm width/height/profile/level match the target and duration is in range. Heed any `⚠︎` duration
59
+ warning the CLI prints (adjust `sceneDur` or scene count).
60
+
61
+ 5. **Tell the user where it goes:**
62
+ - App Store β†’ App Store Connect β†’ App Previews (one 886Γ—1920 file fills the 6.5" **and** 6.9" slots).
63
+ - Play β†’ upload the 1080Γ—1920 file to **YouTube**, paste the link in Play Console β†’ Preview video.
64
+
65
+ ## Tuning for quality
66
+ - **Captions overlap busy UI?** Lower them (they sit at ~0.75Β·height) or pick screenshots with calmer
67
+ lower thirds.
68
+ - **Feels flat?** Ensure adjacent scenes use *different* moves; the spring dolly is what makes it premium.
69
+ - **Too long/short?** Total = `scenes Γ— sceneDur βˆ’ (scenesβˆ’1) Γ— xfade`. App Store wants 15–30s.
70
+
71
+ ## Three styles (fixed per target; all read the same `scenes`)
72
+ - **Full-bleed** (`appstore-preview`, `play-promo`) β€” screen fills the frame; required for App Previews.
73
+ - **Device-framed** (`social-reel`) β€” iPhone bezel + brand background + logo cold-open/end-card
74
+ (needs `brand.name/tagline/endline/logo`). Web/social/YouTube.
75
+ - **Premium** (`premium-reel`) β€” the **Apple editing-vocabulary** preset: matte + glow + vignette,
76
+ motion-then-freeze spring dolly, **palette-aware cuts**, bottom title pill. This is the default premium
77
+ marketing look; tune via the optional `theme` block (brand-driven defaults apply if omitted). Web/social.
78
+ Like the framed reel, it's **not** an App Preview (the matte/pill make it a marketing asset).
79
+
80
+ ## Guardrails
81
+ - Never submit the **device-framed** `social-reel` as an App Preview β€” Apple rejects device bezels there.
82
+ Use the full-bleed `appstore-preview` for the App Store slot; `social-reel` is web/social/YouTube only.
83
+ - Keep any music **commercially licensed**; the engine is silent by default (previews autoplay muted).
84
+ - Don't fabricate UI or overstate features in captions β€” App Review checks the preview matches the app.
package/bin/zdymak.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+ run();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "zdymak",
3
+ "version": "0.3.0",
4
+ "description": "Generate premium, spec-compliant App Store & Google Play preview videos (and store assets) from screenshots β€” one config-driven CLI across all your projects.",
5
+ "type": "module",
6
+ "bin": {
7
+ "zdymak": "bin/zdymak.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "SKILL.md"
16
+ ],
17
+ "scripts": {
18
+ "example": "node bin/zdymak.mjs video --config examples/asilak.config.mjs",
19
+ "release:patch": "node scripts/release.mjs patch",
20
+ "release:minor": "node scripts/release.mjs minor",
21
+ "release:major": "node scripts/release.mjs major"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@napi-rs/canvas": "^0.1.60"
28
+ },
29
+ "keywords": [
30
+ "app-store",
31
+ "google-play",
32
+ "app-preview",
33
+ "screenshots",
34
+ "aso",
35
+ "marketing",
36
+ "video",
37
+ "ffmpeg"
38
+ ],
39
+ "license": "MIT"
40
+ }
package/src/canvas.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /** Shared canvas primitives (Skia via @napi-rs/canvas) used by the reel renderer. */
2
+
3
+ export const font = (sizePx, weight = 'bold') => `${weight} ${sizePx}px Brand`;
4
+
5
+ /** #rrggbb + alpha β†’ rgba() string. */
6
+ export function hexA(hex, a) {
7
+ const n = parseInt(String(hex).replace('#', ''), 16);
8
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
9
+ }
10
+
11
+ /** #rrggbb β†’ [r,g,b]. */
12
+ export function hexRgb(hex) {
13
+ const n = parseInt(String(hex).replace('#', ''), 16);
14
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
15
+ }
16
+
17
+ /** Numerically integrated damped spring 0β†’1 (semi-implicit Euler) β†’ per-frame lookup. Over-damped configs
18
+ * glide and settle (camera dolly); lighter damping gives a small overshoot. */
19
+ export function springSeries(frames, fps, { stiffness = 55, damping = 24, mass = 1.5 } = {}) {
20
+ const dt = 1 / fps;
21
+ let x = 0;
22
+ let v = 0;
23
+ const out = new Array(frames);
24
+ for (let f = 0; f < frames; f++) {
25
+ const a = (stiffness * (1 - x) - damping * v) / mass;
26
+ v += a * dt;
27
+ x += v * dt;
28
+ out[f] = x;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Rounded-rectangle subpath (no fill/stroke). */
34
+ export function roundRectPath(ctx, x, y, w, h, r) {
35
+ const rr = Math.min(r, w / 2, h / 2);
36
+ ctx.beginPath();
37
+ ctx.moveTo(x + rr, y);
38
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
39
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
40
+ ctx.arcTo(x, y + h, x, y, rr);
41
+ ctx.arcTo(x, y, x + w, y, rr);
42
+ ctx.closePath();
43
+ }
44
+
45
+ /** Vertical linear gradient top→bottom filling the whole canvas. */
46
+ export function fillVerticalGradient(ctx, w, h, top, bottom) {
47
+ const g = ctx.createLinearGradient(0, 0, 0, h);
48
+ g.addColorStop(0, top);
49
+ g.addColorStop(1, bottom);
50
+ ctx.fillStyle = g;
51
+ ctx.fillRect(0, 0, w, h);
52
+ }
53
+
54
+ /** Soft radial brand glow β€” "something is arriving" depth on a dark matte. */
55
+ export function radialGlow(ctx, cx, cy, radius, color, alpha) {
56
+ const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
57
+ g.addColorStop(0, hexA(color, alpha));
58
+ g.addColorStop(1, hexA(color, 0));
59
+ ctx.fillStyle = g;
60
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
61
+ }
62
+
63
+ /** Word-wrap `text` to `maxWidth` at the current font; returns line array. */
64
+ export function wrapLines(ctx, text, maxWidth) {
65
+ const words = String(text).split(/\s+/);
66
+ const lines = [];
67
+ let line = '';
68
+ for (const w of words) {
69
+ const test = line ? `${line} ${w}` : w;
70
+ if (ctx.measureText(test).width > maxWidth && line) {
71
+ lines.push(line);
72
+ line = w;
73
+ } else {
74
+ line = test;
75
+ }
76
+ }
77
+ if (line) lines.push(line);
78
+ return lines;
79
+ }
80
+
81
+ /** Typeset a centred caption block (bold title + optional lighter subtitle). Returns height drawn. */
82
+ export function drawCaption(ctx, { title, subtitle, centerX, top, maxWidth, titleSize, subSize, titleColor, subColor }) {
83
+ ctx.textAlign = 'center';
84
+ ctx.textBaseline = 'top';
85
+ let y = top;
86
+ ctx.font = font(titleSize, 'bold');
87
+ ctx.fillStyle = titleColor;
88
+ const titleLH = Math.round(titleSize * 1.12);
89
+ for (const ln of wrapLines(ctx, title, maxWidth)) {
90
+ ctx.fillText(ln, centerX, y);
91
+ y += titleLH;
92
+ }
93
+ if (subtitle) {
94
+ y += Math.round(titleSize * 0.28);
95
+ ctx.font = font(subSize, 'regular');
96
+ ctx.fillStyle = subColor;
97
+ const subLH = Math.round(subSize * 1.3);
98
+ for (const ln of wrapLines(ctx, subtitle, maxWidth)) {
99
+ ctx.fillText(ln, centerX, y);
100
+ y += subLH;
101
+ }
102
+ }
103
+ return y - top;
104
+ }
105
+
106
+ /** Height a caption block WOULD occupy (mirror of drawCaption's layout), for vertical centring. */
107
+ export function measureCaption(ctx, { title, subtitle, maxWidth, titleSize, subSize }) {
108
+ ctx.font = font(titleSize, 'bold');
109
+ let h = wrapLines(ctx, title, maxWidth).length * Math.round(titleSize * 1.12);
110
+ if (subtitle) {
111
+ h += Math.round(titleSize * 0.28);
112
+ ctx.font = font(subSize, 'regular');
113
+ h += wrapLines(ctx, subtitle, maxWidth).length * Math.round(subSize * 1.3);
114
+ }
115
+ return h;
116
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Capture mode (mode B) β€” for devs who'd rather grab frames from a running app than hand-manage a
3
+ * screenshots folder. Project-agnostic on purpose: it does NOT build your app (that's your toolchain).
4
+ * It snapshots or screen-records whatever is on the BOOTED simulator / connected device, strips the
5
+ * alpha channel (Apple & Play reject transparency on screenshots), and writes store-ready PNGs you then
6
+ * reference from your config's `scenes`.
7
+ *
8
+ * zdymak capture --platform ios --name welcome # snap the booted iOS simulator
9
+ * zdymak capture --platform android --name welcome # snap the connected device/emulator
10
+ * zdymak capture --platform ios --record --out shots/rec # screen-record (stop with Ctrl-C)
11
+ *
12
+ * The two-mode contract: capture writes PNGs; the video engine consumes PNGs. So capture output drops
13
+ * straight into `screenshotsDir`, and mode A (bring-your-own-screenshots) and mode B share one pipeline.
14
+ */
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
19
+
20
+ /** Flatten alpha over black and rewrite as an opaque PNG (App Store & Play reject alpha on screenshots). */
21
+ async function stripAlpha(file) {
22
+ const img = await loadImage(file);
23
+ const c = createCanvas(img.width, img.height);
24
+ const ctx = c.getContext('2d');
25
+ ctx.fillStyle = '#000';
26
+ ctx.fillRect(0, 0, img.width, img.height);
27
+ ctx.drawImage(img, 0, 0);
28
+ fs.writeFileSync(file, c.toBuffer('image/png'));
29
+ }
30
+
31
+ function sh(cmd, args) {
32
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
33
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim()}`);
34
+ return r.stdout;
35
+ }
36
+
37
+ async function captureIos(flags) {
38
+ if (spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status !== 0) {
39
+ throw new Error('xcrun/simctl not found β€” install Xcode command-line tools.');
40
+ }
41
+ const outDir = path.resolve(flags.out || 'shots');
42
+ fs.mkdirSync(outDir, { recursive: true });
43
+
44
+ if (flags.record !== undefined) {
45
+ const out = path.join(outDir, `${flags.name || 'recording'}.mov`);
46
+ console.log(`β–ΆοΈŽ Recording the booted iOS simulator β†’ ${out}\n Interact with the app, then press Ctrl-C to stop.`);
47
+ const proc = spawn('xcrun', ['simctl', 'io', 'booted', 'recordVideo', '--codec=h264', '--force', out], { stdio: 'inherit' });
48
+ await new Promise((res) => proc.on('close', res));
49
+ console.log(`βœ“ Saved ${out}. Extract frames with: ffmpeg -i ${out} -vf fps=2 ${outDir}/frame-%03d.png`);
50
+ return;
51
+ }
52
+
53
+ const out = path.join(outDir, `${flags.name || 'shot'}.png`);
54
+ sh('xcrun', ['simctl', 'io', 'booted', 'screenshot', out]);
55
+ await stripAlpha(out);
56
+ console.log(`βœ“ ${out} (alpha stripped, store-ready)`);
57
+ }
58
+
59
+ async function captureAndroid(flags) {
60
+ if (spawnSync('adb', ['version'], { stdio: 'ignore' }).status !== 0) {
61
+ throw new Error('adb not found β€” install Android platform-tools and connect a device/emulator.');
62
+ }
63
+ const outDir = path.resolve(flags.out || 'shots');
64
+ fs.mkdirSync(outDir, { recursive: true });
65
+
66
+ if (flags.record !== undefined) {
67
+ const remote = '/sdcard/zdymak-rec.mp4';
68
+ const out = path.join(outDir, `${flags.name || 'recording'}.mp4`);
69
+ console.log(`β–ΆοΈŽ Recording the device β†’ ${out}\n Interact with the app, then press Ctrl-C to stop.`);
70
+ const proc = spawn('adb', ['shell', 'screenrecord', remote], { stdio: 'inherit' });
71
+ await new Promise((res) => proc.on('SIGINT', res).on('close', res));
72
+ sh('adb', ['pull', remote, out]);
73
+ console.log(`βœ“ Saved ${out}. Extract frames with: ffmpeg -i ${out} -vf fps=2 ${outDir}/frame-%03d.png`);
74
+ return;
75
+ }
76
+
77
+ const name = flags.name || 'shot';
78
+ const out = path.join(outDir, `${name}.png`);
79
+ const buf = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
80
+ if (buf.status !== 0) throw new Error(`adb screencap failed: ${buf.stderr}`);
81
+ fs.writeFileSync(out, buf.stdout);
82
+ await stripAlpha(out);
83
+ console.log(`βœ“ ${out} (alpha stripped, store-ready)`);
84
+ }
85
+
86
+ export async function runCapture(flags) {
87
+ const platform = flags.platform;
88
+ if (platform === 'ios') return captureIos(flags);
89
+ if (platform === 'android') return captureAndroid(flags);
90
+ throw new Error('capture needs --platform ios|android. Boot a simulator/emulator (or connect a device) first, navigate to the screen, then run capture --name <screen>.');
91
+ }