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/README.md CHANGED
@@ -1,211 +1,733 @@
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.
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
+ <p align="center">
10
+ <video src="https://github.com/Lonli-Lokli/zdymak/raw/main/docs/demo.mp4" width="300" controls muted loop playsinline></video>
11
+ <img src="docs/demo.gif" alt="zdymak demo flat screenshots composed into a device-framed store preview" width="300">
12
+ </p>
13
+ <p align="center"><em>▶︎ Made by zdymak, from six real app screenshots. <code>npm run demo</code> rebuilds it
14
+ (<code>examples/promo.config.mjs</code>) — the same scene list also renders an iPhone reel and a Play promo.</em></p>
15
+
16
+ > Why it exists: a plain "Ken-Burns zoom over screenshots" reads as amateur, and hand-encoding to each
17
+ > store's exact spec (Apple: 886×1920, H.264 High@4.0, 15–30s, **no device frame**) is fiddly and easy to
18
+ > get rejected. This bakes the premium motion and the store rules in.
19
+
20
+ <br>
21
+
22
+ ## What you get
23
+
24
+ | | |
25
+ |---|---|
26
+ | 🎬 **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. |
27
+ | ✅ **Store-compliant** | Full-bleed (no bezel — Apple rejects bezels), exact resolution, H.264 High @ the right level, yuv420p, faststart. |
28
+ | 🍎 **App Store + 🤖 Play** | One 886×1920 file fills both iPhone App Preview slots; a 1080×1920 file is ready for a Play/YouTube promo. |
29
+ | 📸 **It can take the screenshots too** | Not just a compositor: it **drives your app** and captures every screen — boots an iOS sim (and can build+install), walks Android over `adb` with a clean status bar, or navigates a web app with Playwright. |
30
+ | ⚙️ **Config-driven** | Everything project-specific is one `zdymak.config.mjs`. The tool is otherwise generic. |
31
+
32
+ <br>
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ npm i -g zdymak # global CLI → `zdymak <command>`
38
+ # …or per-project:
39
+ npm i -D zdymak # → `npx zdymak <command>`
40
+ ```
41
+
42
+ Also needs **Node ≥18** and **ffmpeg** on your `PATH`:
43
+
44
+ | Platform | Install ffmpeg |
45
+ |---|---|
46
+ | **macOS** | `brew install ffmpeg` |
47
+ | **Linux** Debian/Ubuntu | `sudo apt install ffmpeg` |
48
+ | **Linux** Fedora | `sudo dnf install ffmpeg` |
49
+ | **Windows** | `winget install Gyan.FFmpeg` · or `choco install ffmpeg` / `scoop install ffmpeg` |
50
+
51
+ zdymak finds ffmpeg on `PATH`, or set `$FFMPEG` to an explicit binary. Video generation is pure Node +
52
+ ffmpeg no platform SDKs. Screenshot **capture** additionally needs that platform's toolchain: **Xcode**
53
+ for `--platform ios`, the **Android SDK / `adb`** for `--platform android`.
54
+
55
+ <br>
56
+
57
+ ## Quickstart
58
+
59
+ **1. Add a config** to your project root (copy `examples/example.config.mjs` → `zdymak.config.mjs`):
60
+
61
+ ```js
62
+ export default {
63
+ brand: { ink: '#0b0b0a', title: '#F5F5F4', sub: '#9ae6b4' },
64
+ screenshotsDir: './screenshots', // where your PNGs live
65
+ suffix: '', // scene id "welcome" screenshots/welcome.png
66
+ scenes: [
67
+ { id: 'welcome', title: 'Your hook.', sub: 'The promise, briefly.', move: 'pushInSlow' },
68
+ { id: 'feature-1', title: 'What it does.', sub: 'Why it matters.', move: 'driftUp' },
69
+ { id: 'offer', title: 'The honest offer.', sub: 'Price / trial, plainly.', move: 'pullBackSlow' },
70
+ ],
71
+ targets: ['appstore-preview', 'play-promo'],
72
+ out: './store-assets',
73
+ };
74
+ ```
75
+
76
+ **2. Build:**
77
+
78
+ ```sh
79
+ zdymak video
80
+ # → store-assets/appstore-preview.mp4 (886×1920, upload to App Store Connect)
81
+ # → store-assets/play-promo.mp4 (1080×1920, upload to YouTube, link in Play Console)
82
+ ```
83
+
84
+ That's it. `zdymak specs` prints every target and its exact dimensions.
85
+
86
+ <br>
87
+
88
+ ## Getting the screenshots — bring your own, or let zdymak drive the app
89
+
90
+ <p align="center">
91
+ <img src="docs/capture-modes.png" alt="Two ways to get screenshots: bring your own PNGs, or let zdymak drive your app on iOS, Android and web" width="820">
92
+ </p>
93
+
94
+ **Mode A bring your own.** Point `screenshotsDir` at any folder of PNGs: Xcode, an XCUITest capture,
95
+ Android Studio, Figma, anywhere.
96
+
97
+ **Mode B zdymak drives your app and captures it.** This is the part people miss: it isn't only a
98
+ compositor. Give it a way to reach each screen and it will boot the device, walk the app through every
99
+ screen in order, and write a store-ready PNG per screen (alpha stripped stores reject transparency).
100
+
101
+ ```sh
102
+ # iOS boots a simulator, optionally BUILDS + installs, then relaunches the app once per screen
103
+ zdymak capture --platform ios --bundle com.x.app --arg -marketingScreen --states welcome,today,study,answer --suffix -light --build --project App.xcodeproj --scheme App --out ./screenshots
104
+
105
+ # Android same idea over adb, driving an intent extra
106
+ zdymak capture --platform android --component com.x.app/.MainActivity --arg marketingScreen --states welcome,today,study,answer --out ./screenshots
107
+
108
+ # Web no handle needed at all; the URL IS the handle
109
+ zdymak capture --platform web --url http://localhost:3000 --states /,/today,/study --out ./screenshots
110
+ ```
111
+
112
+ | Platform | How it drives | What your app must provide | Needs |
113
+ |---|---|---|---|
114
+ | **iOS** | Boots/creates a simulator, optionally `xcodebuild`s and installs, then relaunches with `<arg> <state>` per screen and screenshots via `simctl`. Pins the status bar to 9:41, full bars, charging. | A launch-arg "handle" that routes to a seeded screen (e.g. reads `-marketingScreen <id>`) | Xcode |
115
+ | **Android** | `am start -n <component> --es <arg> <state>` per screen, then `adb exec-out screencap`. Turns on **SystemUI demo mode** first, so the status bar is clean (9:41, full battery/wifi, no notifications) and restores it after. | An intent extra that routes to a seeded screen | Android SDK / `adb` |
116
+ | **Web** | Playwright navigates each path itself, disables animations, waits for `document.fonts.ready` and undecoded images. Device descriptors, dark mode, locale, full-page. | Nothing — just a URL | `npm i -D playwright` |
117
+
118
+ There's also a single-shot mode (`--name welcome`) that snaps whatever is currently on screen, and
119
+ `--record` for a screen recording you can feed to the live-footage `reel`.
120
+
121
+ **It cleans up after itself.** Capture is setup *and* teardown: on iOS the status-bar override is cleared,
122
+ a simulator this run booted is shut down, and a throwaway device it created is deleted — all in a
123
+ `finally`, so a failed build can't strand a booted sim (~1.7 GB resident) with a faked status bar. A
124
+ simulator you already had running is left exactly as it was. On Android, SystemUI demo mode is switched
125
+ back off. Pass **`--keep`** to skip teardown when you want to poke at the device afterwards.
126
+
127
+ It does **not** build your app for Android or web — that's your toolchain; on iOS `--build` will do it.
128
+ And a note on macOS: there is deliberately no `--platform macos` capture, because reading a specific
129
+ native window's pixels needs a TCC grant (Screen Recording or Accessibility) and can't be a clean
130
+ one-command capture. Drive a Mac app from your own XCUITest and let zdymak compose the result.
131
+
132
+ **Web (Playwright).** A web app's "handle" is just its URL, so `--states` takes paths and the tool drives
133
+ itself:
134
+
135
+ ```sh
136
+ zdymak capture --platform web --url http://localhost:3000 --states /,/today,/study --suffix -light --out ./screenshots
137
+
138
+ zdymak capture --platform web --url http://localhost:3000 --states / --theme dark --device "iPhone 15 Pro" --suffix -mobile --out ./screenshots
139
+ ```
140
+
141
+ | Flag | Meaning |
142
+ |---|---|
143
+ | `--url` | Base URL. A state may also be an absolute URL. |
144
+ | `--states` | Comma-separated paths. `/` → `home.png`, `/today` → `today.png`, `/today.html` → `today.png`. |
145
+ | `--viewport` / `--dsf` | Desktop size (default `1280x800`) and device scale factor (default `2`, retina-sharp). |
146
+ | `--device` | A Playwright device descriptor (`"iPhone 15 Pro"`, `"Pixel 7"`, `"iPad Pro 11"`) — brings its own viewport, DPR and UA. Overrides `--viewport`. |
147
+ | `--theme` | `light` (default) or `dark` `prefers-color-scheme`. |
148
+ | `--locale` | e.g. `de-DE`, for localized capture sets. |
149
+ | `--wait` | CSS selector to wait for before shooting. |
150
+ | `--full-page` | Whole scrollable page instead of the viewport. |
151
+ | `--settle` | Extra seconds after load (default `1`). |
152
+
153
+ Playwright is an **optional dependency** — it downloads a browser, which nobody capturing only mobile
154
+ should pay for. Install it where you need it: `npm i -D playwright && npx playwright install chromium`.
155
+
156
+ Shots are made **reproducible**, so a re-run differs only where the UI did: CSS/SMIL animations and
157
+ transitions are forced to zero duration (`prefers-reduced-motion` is only a hint apps may ignore), the
158
+ caret is hidden, and each shot waits for `document.fonts.ready` plus any undecoded images — a webfont
159
+ swapping in one frame late is the classic cause of a mystery screenshot diff.
160
+
161
+ <br>
162
+
163
+ ## Config reference
164
+
165
+ | Key | Meaning |
166
+ |---|---|
167
+ | `brand.ink` / `.title` / `.sub` | Hex colours for the caption scrim + title + subtitle. |
168
+ | `brand.fontPaths` | Optional custom TTFs (else: system font San Francisco on macOS). |
169
+ | `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. |
170
+ | `brand.reel` | **Reel target only** — optional palette overrides (`bgTop`, `bgBottom`, `glowLight`, `matteTop`, `matteBottom`, `glowDark`, `titleColor`, `subColor`, `bookendTitle`, `bookendSub`). |
171
+ | `screenshotsDir` + `suffix` | Resolve `scene.id` → `${screenshotsDir}/${id}${suffix}.png`. |
172
+ | `scenes[]` | `{ id \| image, title, sub, move }`. `image` overrides the id lookup. |
173
+ | `scenes[].move` | `pushIn` · `pushInSlow` · `pullBack` · `pullBackSlow` · `driftUp` · `driftDown` · `driftLeft` · `driftRight` · `still`. Omit to auto-vary. |
174
+ | `targets[]` | Which videos to build from the top-level scenes (`zdymak specs` lists them). |
175
+ | `sceneDur` / `xfade` | Seconds per scene / cross-dissolve. Tune total length to the store's 15–30s window. |
176
+ | `theme` | **Video** matte styling — the premium-technique block (see **Theme options** below). Brand-driven defaults. |
177
+ | `stillTheme` | **Screenshot** matte styling — same option shape as `theme`; falls back to `theme` when unset. |
178
+ | `timing` | Reel-mode timeline override `{ coldOpen, scene, endCard, xfade }` for the `social-reel` bookends. |
179
+ | `music` | Optional bed for **every** video: `{ path, offset, fadeIn, fadeOut, volume }` (silent if omitted). |
180
+ | `devices` | Per-device **screenshots + reels** (see below). Configure only the devices you ship. |
181
+ | `captions` | **Localized screenshot sets** — `{ locale: './captions/de.json' \| { sceneId: { title, sub } } }` (see below). |
182
+ | `reel` | **Live-footage reel** — composite driven video `clip`s / `images` on a clean light matte, cross-dissolves (see **Live-footage reel** below). |
183
+ | `out` | Output directory. |
184
+
185
+ ### Theme options (`theme` / `stillTheme`)
186
+
187
+ Both accept the same block; every key is optional with a brand-driven default. **Screenshots assume the
188
+ premium store-shot shape by default** — `captionAnchor: 'top'`, and `fit: 'contain'` for frameless windows —
189
+ so a typical `stillTheme` only sets colours. Override any key per shot.
190
+
191
+ | Key | Default | Meaning |
192
+ |---|---|---|
193
+ | `bgTop` / `bgBottom` | brand | Matte gradient top / bottom colour. |
194
+ | `glow` / `glowAlpha` | brand.sub / `0.16` | Soft radial brand-glow colour + strength. |
195
+ | `vignette` | `0.3` | 0..1 edge darkening (use `0` on a light matte). |
196
+ | `inset` | `0.955` | Fraction of the frame the screen fills (lower floats it with a wider matte border). |
197
+ | `label` | `true` | Show the caption on a pill; `false` = plain text, no pill. |
198
+ | `labelColor` / `subColor` | brand.title / brand.sub | Caption title + subtitle colour. |
199
+ | `handle` | — | Optional persistent top handle text (e.g. `@yourapp`). |
200
+ | `captionAnchor` | `bottom` (video) · `top` (stills) | Caption above (`top`) or below (`bottom`) the device. |
201
+ | `fit` | `cover` · `contain` (frameless stills) | `cover` fills + crops; `contain` shows the **whole** capture (e.g. a Mac window) with matte margins. |
202
+ | `headlineScale` | `0.062` | Caption headline size as a fraction of the frame's short edge — bump it for bigger, bolder headlines. |
203
+ | `statusBar` | `auto` | Paint a clean status bar (time · signal · wifi · **full** battery) into the empty status-bar band a capture may carry. `auto` draws it only when such a band is detected, `true` forces it, `false` skips. |
204
+ | `statusBarTime` | `9:41` | The clock shown in that status bar. |
205
+ | `statusBarCellular` | inferred | Draw cell-signal bars. On for phones, off for tablets (Wi-Fi models) and landscape captures — a Wi-Fi-only device showing signal bars is a detail that's simply false. Google asks for battery/WiFi/cell **full**, so phones show all three. |
206
+ | `anchor` | `center` | Cover-fit anchor when the capture is taller than the slot. `top` crops from the bottom only, keeping the status bar and the screen's own heading. |
207
+ | `frame` | `phone` (reel) | Device frame around a reel screen: `'phone'` iPhone bezel, or `false` for a bare rounded screen. |
208
+ | `bleed` | `false` | Reel only: the source **fills the whole frame** (no matte / frame / shadow) — a compliant full-bleed App Store App Preview. |
209
+
210
+ <br>
211
+
212
+ ## Store requirements (verified)
213
+
214
+ The numbers below are read from the stores' own pages, not from blog posts, and each row says which
215
+ zdymak target produces it. `zdymak specs` prints the same matrix from the code. **Re-verify before a
216
+ release** — store forms change, and a bounced submission is usually a dimension that moved.
217
+
218
+ Sources: Apple [Screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/)
219
+ · Apple [App preview specifications](https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/)
220
+ · Google [Add preview assets](https://support.google.com/googleplay/android-developer/answer/9866151)
221
+ *(checked July 2026)*
222
+
223
+ ### App Store — screenshots
224
+
225
+ | Slot | Size | Required? | Target |
226
+ |---|---|---|---|
227
+ | iPhone 6.9" | 1320×2868 | one iPhone size required if it runs on iPhone | `appstore-iphone-6.9` |
228
+ | iPhone 6.5" | 1284×2778 *(or 1242×2688)* | only if you skip 6.9" | `appstore-iphone-6.5` |
229
+ | iPad 13" | 2064×2752 | **required** if it runs on iPad | `appstore-ipad-13` |
230
+ | Mac | 2880×1800 *(16:10; also 1280×800 · 1440×900 · 2560×1600)* | **required** for Mac apps | `appstore-mac` |
231
+ | Apple Watch | 422×514 *(or 410×502 · 416×496 · 396×484 · 368×448 · 312×390)* | **required** for Watch apps | `appstore-watch` |
232
+
233
+ **1–10 per device type · JPEG/PNG · no alpha.** Apple down-scales the largest class to smaller ones, so
234
+ 6.9" + 13" covers the phone and tablet families. Pick ONE Watch size and use it in every localization.
235
+
236
+ ### App Store — App Previews (video)
237
+
238
+ | Slot | Size | Target |
239
+ |---|---|---|
240
+ | iPhone (6.9" / 6.5" / 6.3" / 6.1") | 886×1920 portrait | `appstore-preview` |
241
+ | iPad (13" / 12.9" / 11" / 10.5") | 1200×1600 portrait | `appstore-preview-ipad` |
242
+ | Mac | 1920×1080 landscape only | `appstore-preview-mac` |
243
+
244
+ **15–30 s** (App Store Connect rejects outside it) · **≤500 MB** · **≤30 fps** · H.264 **High ≤ 4.0** at
245
+ 10–12 Mbps, or ProRes 422 HQ · `.mov` / `.m4v` / `.mp4` · stereo AAC 256 kbps @ 44.1/48 kHz ·
246
+ **up to 3 previews per device family** · poster frame defaults to 0:05 · **no device frame** — Apple
247
+ rejects bezels here, which is why `social-reel` must never go in this slot.
248
+
249
+ > **The iPad trap:** the iPad *preview* is 1200×1600 — nothing like the 2064×2752 iPad *screenshot*.
250
+ > Submitting the screenshot dimensions as a preview is a routine rejection.
251
+
252
+ ### Google Play — images
253
+
254
+ | Asset | Size | Required? | Target |
255
+ |---|---|---|---|
256
+ | Phone screenshots | 1080×1920 (320–3840 px per side, **max 2:1**) | 2–8; min 2 to publish | `play-phone` |
257
+ | Tablet screenshots | 2560×1440 (16:9) | recommended for large-screen | `play-tablet` |
258
+ | Wear OS screenshots | 1080×1080 (**1:1**, 384–3840) | **required** for Wear listings | `play-wear` |
259
+ | Feature graphic | **1024×500** | **required** | `play-feature-graphic` |
260
+ | App icon | 512×512, ≤1 MB, alpha OK | **required** | `play-icon` (from `brand.logo`) |
261
+
262
+ **JPEG or 24-bit PNG · no alpha** (the icon is the one exception — 32-bit with alpha). The feature
263
+ graphic is what carries the promo video: when a video is set, it plays over the graphic at the top of
264
+ the listing, so keep the centre clear.
265
+
266
+ **Styling — read this carefully, the rule differs by form factor:**
267
+
268
+ - **Wear OS** — a *requirement*: show only the app interface, no device frames, no added graphics.
269
+ - **Phone / tablet** — device frames are **recommended against** (it affects eligibility for Play's
270
+ editorial promotion, not acceptance), taglines are allowed up to ~20% of the image, and stylized
271
+ screenshots are permitted.
272
+
273
+ So a framed, captioned phone set is unlikely to be *rejected*, but the plain set is the safer upload and
274
+ the one that stays promotion-eligible — which is why the config below renders both. Status bars should be
275
+ tidy either way — no carrier, no notifications, battery/WiFi/signal full — which `statusBar: 'auto'` does.
276
+
277
+ ### Google Play — video
278
+
279
+ Play takes a **YouTube URL**, never a file: render `play-promo` (1080×1920), upload it to YouTube, then
280
+ paste the link in Play Console → Main store listing → Preview video. Keep it **silent** unless the music
281
+ is cleared — a ContentID claim can force ads onto the video, and Play forbids ads on listing videos.
282
+
283
+ <br>
284
+
285
+ ## Destination vs preset
286
+
287
+ Two independent questions, previously welded into one `target` id:
288
+
289
+ - **destination** — *will a store accept this?* Pixel size, codec profile/level, duration bounds, alpha,
290
+ file cap. Says nothing about how it looks.
291
+ - **preset** — *how does it look?* `full-bleed` · `framed` · `premium`. Says nothing about what a store
292
+ accepts.
293
+
294
+ Welding them meant "the premium look at Play dimensions" and "App Store validation with my own
295
+ transitions" were unsayable. Now they're just fields:
296
+
297
+ ```js
298
+ videos: [
299
+ { target: 'appstore-preview' }, // shorthand — unchanged, still works
300
+ { destination: 'play-promo', preset: 'premium' }, // our matte treatment, Play's dimensions
301
+ { destination: 'play-promo', preset: 'framed', // …or bring your own vocabulary:
302
+ transitions: ['blur-dissolve', 'push-up', 'flip'], // scenes without a `cut` cycle these
303
+ effects: ['warm-film'] },
304
+ ]
305
+ ```
306
+
307
+ A destination can refuse a preset. `appstore-preview` + `framed` throws at config time, because Apple
308
+ rejects a device bezel in the App Preview slot — better a build error than a review rejection.
309
+
310
+ ### Validation is enforced, not advertised
311
+
312
+ Every asset is re-measured **after** it's written — `ffprobe` for video, the PNG IHDR for stills — and
313
+ **refused** if the destination wouldn't take it:
314
+
315
+ ```
316
+ ✗ out/appstore-preview.mp4 violates its destination:
317
+ • duration 13.1s is under App Store's 15s minimum
318
+ Fix the config, or pass --force to write it regardless.
319
+ ```
320
+
321
+ Checked: exact pixel size (or one of the accepted sizes), duration window, alpha where forbidden, and the
322
+ file cap (500 MB for App Previews, 1 MB for the Play icon). It reads the artefact rather than trusting
323
+ our intent, so a renderer or encoder bug is caught too. `--force` downgrades it to a warning.
324
+
325
+ <br>
326
+
327
+ ## Presets — every target, style and frame
328
+
329
+ `zdymak specs` prints this table from the code, so it can never drift from what the tool actually
330
+ produces. Sizes below were checked against Apple's *Screenshot specifications* / *App preview
331
+ specifications* and Google's *Add preview assets* pages.
332
+
333
+ ### Video targets (`targets` / a device's `videos`) → `.mp4`
334
+
335
+ | Target | Size | Style | Where it goes | Notes |
336
+ |---|---|---|---|---|
337
+ | `appstore-preview` | 886×1920 | full-bleed | **App Store Connect → App Previews** | One file fills the 6.5" **and** 6.9" slots. H.264 High@4.0. Apple enforces **15–30 s** — zdymak warns outside it. **No device frame** (Apple rejects bezels here). |
338
+ | `play-promo` | 1080×1920 | full-bleed | **YouTube**, then the link in Play Console | Play takes a URL, not a file. Any length. Keep it silent unless the track is cleared — a ContentID claim can force ads on, which Play forbids on listing videos. |
339
+ | `social-reel` | 1080×1920 | device-framed | web · social · ads | Brand cold-open + end card (`brand.name/tagline/endline/endsub/logo`). Frame follows `theme.frame` (default iPhone — set `'android'` for Android captures). **Never** submit this as an App Preview. |
340
+ | `premium-reel` | 1080×1920 | premium matte | web · social · a landscape Mac reel | The Apple editing-vocabulary preset: floating screen on a brand matte, spring-then-freeze dolly, palette-aware cuts. Override `size` for landscape (e.g. `[2880, 1800]`). |
341
+
342
+ ### Image targets (a device's `screenshots`) → no-alpha `.png`
343
+
344
+ | Target | Size | Store slot | Required? |
345
+ |---|---|---|---|
346
+ | `appstore-iphone-6.9` | 1320×2868 | iPhone 6.9" — Air, 17/16/15 Pro Max, 16/15 Plus, 14 Pro Max | One iPhone size required if the app runs on iPhone; Apple down-scales 6.9" to the smaller classes |
347
+ | `appstore-iphone-6.5` | 1284×2778 *(or 1242×2688)* | iPhone 6.5" — 14 Plus, 13/12/11 Pro Max, XS Max, XR | Only needed if you don't ship 6.9" |
348
+ | `appstore-ipad-13` | 2064×2752 | iPad 13" — iPad Pro M5/M4, Air M4/M3/M2 | **Required** if the app runs on iPad |
349
+ | `appstore-mac` | 2880×1800 | Mac (16:10) | **Required** for Mac apps |
350
+ | `appstore-watch` | 422×514 *(or 410×502 · 416×496 · 396×484 · 368×448 · 312×390)* | Apple Watch — Ultra 3 / Ultra 2 / Series 11 / 10 / 9…3 | **Required** for Watch apps. Pick ONE size and use it in every localization |
351
+ | `play-phone` | 1080×1920 | Play phone | 2–8 per form factor; 320–3840 px per side, max 2:1 |
352
+ | `play-tablet` | 2560×1440 | Play 7"/10" tablet + Chromebook | Recommended for large-screen visibility |
353
+ | `play-wear` | 1080×1080 | Play Wear OS (1:1) | **Required** for Wear OS listings, min 384×384 |
354
+ | `play-feature-graphic` | 1024×500 | Play feature graphic | **Required** even without a video; it's also the promo-video thumbnail, so keep the centre clear |
355
+ | `play-icon` | 512×512 | Play app icon | The only target where alpha is allowed |
356
+
357
+ Apple takes 1–10 screenshots per device type; Google takes 2–8 per form factor. Both reject transparency
358
+ everywhere except the Play icon — zdymak always writes colour-type-2 PNG, so that's handled.
359
+
360
+ ### Styles (`style` on a shot — normally inferred)
361
+
362
+ | Style | What it draws | Inferred for |
363
+ |---|---|---|
364
+ | `framed` | The capture inside a device body on the matte, headline on top | Any phone/tablet/watch target |
365
+ | `premium` | The capture floating on the matte (no bezel), `fit: 'contain'` so a window shows whole | Frameless targets — Mac/desktop |
366
+ | `bleed` | The capture alone, filling the frame; caption only if the scene has one | — set it explicitly (raw Watch shots, **plain Play uploads**) |
367
+
368
+ ### Frames (`theme.frame` — normally inferred from the target)
369
+
370
+ `iphone`/`phone` (Dynamic Island) · `android` (punch-hole) · `ipad`/`tablet` · `watch` (round body, crown)
371
+ · `mac` (no bezel — the capture is already a window). Inference order: *watch|wear* → `watch`, then **any
372
+ `play-*` target or *android*** → `android` (so `play-tablet` gets an Android body, not an iPad one), then
373
+ *ipad|tablet* → `ipad`, then *iphone|phone* → `phone`, else none.
374
+
375
+ > **Two compliance rules worth repeating**, because they pull in opposite directions:
376
+ > **Apple** wants an App Preview to be footage *from inside the app* with no device frame — use
377
+ > `appstore-preview` (and `examples/preview.config.mjs` for a captions-free, real-footage cut).
378
+ > **Google** requires interface-only shots for **Wear OS**, and recommends against frames elsewhere —
379
+ > see *Store-compliant Play screenshots* below. Styled output belongs on your own site and in ads.
380
+
381
+ <br>
382
+
383
+ ## Screenshots & multiple devices
384
+
385
+ Videos are only half the set. `zdymak build` also renders **store screenshots** for each device you
386
+ configure, at each store's exact dimensions, as **no-alpha PNGs** (App Store & Play reject alpha). Each
387
+ device points at its own captures; scenes with no matching capture are **skipped cleanly**, so an app lists
388
+ only the devices it actually ships. **The style is inferred from the target**, so a device is usually just
389
+ its captures + target(s):
390
+
391
+ ```js
392
+ devices: {
393
+ iphone: { capturesDir: './shots/iphone', suffix: '', screenshots: [{ target: 'appstore-iphone-6.9' }] },
394
+ ipad: { capturesDir: './shots/ipad', suffix: '', screenshots: [{ target: 'appstore-ipad-13' }] },
395
+ mac: { capturesDir: './shots/mac', suffix: '', screenshots: [{ target: 'appstore-mac' }] }, // window on the matte
396
+ watch: { capturesDir: './shots/watch', suffix: '',
397
+ scenes: [{ id: '01-study' }, { id: '02-answer' }], // per-device scene override (raw, no caption)
398
+ screenshots: [{ target: 'appstore-watch', style: 'bleed', size: [422, 514] }] }, // override: raw fill
399
+ android:{ capturesDir: './shots/android', suffix: '', screenshots: [
400
+ { target: 'play-phone' }, { target: 'play-tablet' },
401
+ { target: 'play-wear' }, // 1080×1080 square, round watch body
402
+ { target: 'play-feature-graphic' } ] }, // the 1024×500 Play banner (not per-scene)
403
+ // a device may also carry `videos: [{ target: 'premium-reel', size: [2064, 2752] }]` at its own dimensions
404
+ },
405
+ ```
406
+
407
+ **Inferred style** (override per shot with `style`): a **framed** device — iPhone (Dynamic Island), Android
408
+ (punch-hole), iPad/tablet, Watch ring — for phone/tablet/watch targets; a **premium** window-on-the-matte for
409
+ Mac/desktop (its capture is already a window, so `fit: 'contain'` shows the whole thing). Screenshots put the
410
+ **caption on top** by default. Override any of it via `stillTheme` / a per-shot `theme`, or `style: 'bleed'`
411
+ for a raw full-frame shot (Watch). **`play-feature-graphic`** is special: one 1024×500 brand banner (logo +
412
+ tagline + a tilted hero device), not a per-scene screenshot.
413
+
414
+ Commands:
415
+
416
+ ```sh
417
+ zdymak build # EVERYTHING: top-level videos + every device's screenshots (+ device videos)
418
+ zdymak screenshots # just the per-device screenshots
419
+ zdymak video # just the top-level video targets
420
+ zdymak reel # LIVE-FOOTAGE montage (real motion) from the `reel` block — see below
421
+ zdymak build --clean # wipe the output folder first, so ONLY this run's assets remain (no stale files)
422
+ ```
423
+
424
+ **`--clean`** (on `build` / `screenshots` / `video` / `capture`) empties the target folder before writing,
425
+ so a removed target or renamed scene can't leave a stale screenshot behind — every file is produced by this
426
+ run. On `capture` it clears only stale PNG/MOV captures and keeps the `.dd` build cache (rebuilds stay
427
+ incremental).
428
+
429
+ `zdymak specs` lists every image target and its exact dimensions. A device that only ships iPhone simply
430
+ omits the others — that's the "use only part of it" contract.
431
+
432
+ <br>
433
+
434
+ ## Store-compliant Play screenshots
435
+
436
+ Apple and Google want **opposite** things from a store screenshot, and it's worth getting right:
437
+
438
+ - **App Store** — marketing styling is expected: device frames, headlines, branded backgrounds.
439
+ - **Google Play** — the [asset guidance](https://support.google.com/googleplay/android-developer/answer/9866151)
440
+ says the opposite: *"Do not position the screenshots within device frames, or include additional text,
441
+ graphics, or backgrounds that are not part of the interface of the app."* For **Wear OS** it's stated as a
442
+ requirement. Google *does* ask for a tidy status bar: *"Do not show service providers or notifications.
443
+ The battery, WiFi, and cell service logos should be full."*
444
+
445
+ So render the Play set plain, and keep the styled one for your site and ads — `dir` lets one target do both:
446
+
447
+ ```js
448
+ android: {
449
+ capturesDir: './android/captures',
450
+ screenshots: [
451
+ // what you upload: app interface only, clean status bar, cropped from the bottom
452
+ { target: 'play-phone', dir: 'play-phone-plain', style: 'bleed', caption: false, theme: { anchor: 'top' } },
453
+ // what you put on the landing page / in ads
454
+ { target: 'play-phone' },
455
+ { target: 'play-feature-graphic' },
456
+ ],
457
+ },
458
+ ```
459
+
460
+ `caption: false` drops the headline; `style: 'bleed'` drops the frame and matte; `dir` keeps the two sets
461
+ from overwriting each other. The status bar comes free — see `statusBar` in the theme options above.
462
+
463
+ **Why the status bar needs drawing at all:** an Android Compose capture (`captureToImage()`) grabs the
464
+ app's window only. An edge-to-edge app still reserves the status-bar inset, so the shot has an empty band
465
+ where the clock and battery belong — the system UI is a separate window and never lands in the PNG. iOS
466
+ XCUITest captures the whole screen, so its shots already have one. zdymak detects that blank band and
467
+ paints a clean bar into it, at the capture's own density, never over app pixels.
468
+
469
+ <br>
470
+
471
+ ## Localized screenshot sets
472
+
473
+ Both stores let you upload a different screenshot set per locale, and localized shots convert better than
474
+ English ones. Add a `captions` block — each locale is either a JSON file (hand it to a translator, or
475
+ generate it from the app's own i18n catalog) or an inline table:
476
+
477
+ ```js
478
+ captions: {
479
+ de: './captions/de.json',
480
+ fr: { welcome: { title: 'Apprenez n’importe quoi.', sub: 'Retenez-le pour de bon.' } },
481
+ },
482
+ ```
483
+
484
+ ```json
485
+ // captions/de.json — scene id → the caption to draw
486
+ {
487
+ "welcome": { "title": "Lerne alles.", "sub": "Und behalte es." },
488
+ "study": { "title": "Abrufen, kurz bevor du vergisst." },
489
+ "$brand": { "tagline": "Lerne alles." }
490
+ }
491
+ ```
492
+
493
+ ```sh
494
+ zdymak screenshots # base set + every configured locale
495
+ zdymak screenshots --locale de,fr # just those two (an unconfigured locale is an error, not a no-op)
496
+ ```
497
+
498
+ The base (source-language) set stays where it always was; each locale lands in `<out>/<locale>/<target>/`,
499
+ same filenames, ready to drop into that locale's store listing. Rules worth knowing:
500
+
501
+ - **A scene a locale doesn't translate keeps its base caption** — a shot with the source headline beats an
502
+ empty slot — and every fallback is printed at the end of that locale's run, never silently.
503
+ - **`$brand`** is the one reserved key: it localizes the wordmark copy on the **feature graphic**
504
+ (`name` / `tagline` / `endline` / `endsub`). Colours, logo and captures stay global.
505
+ - **Screenshots only.** Videos aren't re-rendered per locale — stores take localized stills far more often
506
+ than localized previews, and each video is a multi-minute encode.
507
+
508
+ <br>
509
+
510
+ ## Transitions & effects (social-ad mode)
511
+
512
+ Store previews want restraint; **social ads** legitimately want range. Both come from the same registries,
513
+ and every one is chosen **per scene** — so one config can hold a disciplined store cut and an expressive
514
+ ad cut side by side.
515
+
516
+ ```js
517
+ scenes: [
518
+ { id: 'welcome', title: 'Learn anything.' }, // default: a hard cut
519
+ { id: 'study', title: 'Recall it.', cut: 'flip', push: true }, // flip in, and the one camera move
520
+ { id: 'answer', title: 'In context.', cut: 'push', effect: 'warm-film' },
521
+ { id: 'offer', title: 'Buy once.', cut: 'dissolve' }, // spend a dissolve on the meaning change
522
+ ],
523
+ timing: { bpm: 112, beatsPerCut: 4 }, // cuts land on the beat
524
+ ```
525
+
526
+ **`cut` names how the video gets INTO that scene.** The default is `cut` — a single frame — because a
527
+ montage that reaches for a different decorative transition at every boundary is the loudest amateur tell.
528
+ Use `auto` for a deterministic rotation that stays mostly plain, or name one yourself:
529
+
530
+ | id | duration | what it reads as |
531
+ |---|---|---|
532
+ | `cut` | 0.04s | Hard cut — instant change of subject (default) |
533
+ | `dissolve` | 0.45s | Cross-dissolve — a change of meaning, or a bookend |
534
+ | `cinematic-dissolve` | 0.70s | Dissolve with a slow breath of scale |
535
+ | `match-cut` | 0.28s | Match cut — snaps early, then holds (subjects that rhyme) |
536
+ | `fade-through-black` | 0.70s | Dip to black — a chapter break |
537
+ | `dip-to-white` | 0.60s | Dip to white — a lighter chapter break |
538
+ | `soft-flash` | 0.35s | Soft white bloom over the cut |
539
+ | `slow-zoom-through` | 0.60s | Push in through the cut |
540
+ | `pull-out` | 0.60s | Pull back through the cut |
541
+ | `soft-zoom-punch` | 0.30s | Fast scale punch — energy on the beat |
542
+ | `frame-fill` | 0.50s | The incoming frame scales up to fill |
543
+ | `push` | 0.42s | Push — navigation within the app |
544
+ | `push-up` | 0.42s | Push upward - a feed or story advancing |
545
+ | `page-slide` | 0.50s | The incoming page slides over the outgoing one |
546
+ | `warp-slide` | 0.45s | Slide with a stretch — speed you can feel |
547
+ | `whip-pan` | 0.34s | Whip pan — a blurred swing between subjects |
548
+ | `polaroid-drop` | 0.55s | The incoming frame drops in and settles |
549
+ | `blur-dissolve` | 0.55s | Defocus out, focus in - the premium app-ad standard |
550
+ | `zoom-punch` | 0.40s | Radial zoom-blur through the cut - the beat-drop cut |
551
+ | `clean-line-wipe` | 0.45s | Hard-edged linear wipe |
552
+ | `edge-wipe-soft` | 0.50s | Soft-edged wipe — the seam is feathered |
553
+ | `iris-circle` | 0.55s | Iris — the incoming frame opens from a point |
554
+ | `iris-split` | 0.55s | Two irises open and meet |
555
+ | `mirror-split` | 0.50s | The outgoing frame splits apart down the middle |
556
+ | `heart-wipe` | 0.60s | Heart-shaped reveal |
557
+ | `flip` | 0.50s | Card flip — same object, other side |
558
+ | `spin-3d` | 0.55s | Spin — the frame swings past the camera and the next swings in |
559
+ | `page-peel` | 0.60s | The outgoing page peels away from the corner |
560
+ | `tearing-paper` | 0.65s | The outgoing frame tears in two and parts |
561
+ | `light-leak-wipe` | 0.60s | A warm light leak sweeps the cut |
562
+ | `glare-sweep` | 0.50s | A lens-flare streak crosses the cut |
563
+ | `floodlight-sweep` | 0.60s | A warm stage light swells across the cut |
564
+ | `clouds-wipe` | 0.70s | Soft billows part to reveal |
565
+ | `glitch-cut` | 0.36s | RGB split + scanlines — a deliberate digital break |
566
+
567
+ **`effect` is the per-scene look.** A `grade` is a CSS filter applied to the capture; an `overlay` is drawn
568
+ over the finished frame. Effects are **reel-only** and never touch store screenshots — Google requires Play
569
+ shots to show the interface unaltered, so grade your ads, not your listing.
570
+
571
+ | id | kind | look |
572
+ |---|---|---|
573
+ | `none` | — | No effect |
574
+ | `bw` | grade | Black & white |
575
+ | `sepia` | grade | Sepia |
576
+ | `cool` | grade | Cool cast |
577
+ | `vibrant` | grade | Vibrant |
578
+ | `soft-faded` | grade + overlay | Soft faded film |
579
+ | `warm-film` | grade + overlay | Warm film |
580
+ | `duotone` | grade + overlay | Duotone |
581
+ | `vignette` | overlay | Vignette — darkens the corners |
582
+ | `soft-glow` | overlay | Soft glow — light blooms out of the highlights |
583
+ | `dreamy-haze` | grade + overlay | Dreamy haze - soft-focus bloom |
584
+ | `bokeh` | overlay | Bokeh — drifting out-of-focus lights |
585
+ | `glare` | overlay | Glare — a static lens streak |
586
+ | `floodlight` | overlay | Floodlight — a warm pool of stage light |
587
+ | `light-leak` | overlay | Light leak — warm bleed from the edge |
588
+ | `film-grain` | overlay | Film grain |
589
+ | `scanlines` | overlay | Scanlines — CRT texture |
590
+ | `dust-scratches` | overlay | Dust & scratches — projected-print wear |
591
+ | `camera-shake` | overlay | Camera shake - handheld energy |
592
+ | `letterbox` | overlay | Letterbox - cinematic bars |
593
+ | `falling-snow` | overlay | Falling snow |
594
+ | `sparkles-fireflies` | overlay | Sparkles / fireflies |
595
+ | `heart-drift` | overlay | Drifting hearts |
596
+ | `confetti-drift` | overlay | Drifting confetti |
597
+ | `clouds-drift` | overlay | Drifting clouds |
598
+
599
+ Adding one is a single entry in `src/transitions.mjs` / `src/effects.mjs` — `paint(ctx, prev, next, p)` and
600
+ `overlay(ctx, { W, H, t, p })` are pure functions over a canvas, so nothing in the render loop changes. The
601
+ type guard fails the build if a registered id isn't declared in `types/index.d.ts`.
602
+
603
+ Everything random is derived from a hashed index rather than `Math.random()`, so a re-render is
604
+ bit-identical — you can diff two cuts of a video and see only what you actually changed.
605
+
606
+ <br>
607
+
608
+ ## Live-footage reel — real motion, not Ken Burns
609
+
610
+ The video *targets* above animate a **static screenshot** (a subtle dolly) — inherently Ken Burns. For a
611
+ genuinely premium reel, feed **real motion**: `zdymak reel` composites short **recordings** of your app (or
612
+ an image sequence) on a clean matte, floats each with a rounded frame + soft shadow, puts the headline **on
613
+ top**, and **cross-dissolves** between beats — the restrained, Apple-App-Preview language. The matte defaults
614
+ to **light** (consistent with the store screenshots) and stills get a slow, never-freezing push-in.
615
+ Source-agnostic, like the two screenshot modes: each segment's footage can be **brought by you** or
616
+ **captured** by `zdymak capture --record`.
617
+
618
+ ```js
619
+ reel: {
620
+ size: [1080, 1920], bpm: 120, beatsPerCut: 4, // hold = beatsPerCut × 60/bpm seconds per segment
621
+ transition: 'dissolve', // default; 'cut' for beat-matched hard cuts
622
+ music: { path: './bed.mp3', volume: 0.9, fadeIn: 0.6, fadeOut: 0.8 }, // optional, faded
623
+ // theme: { bgTop: '#0e1a12', bgBottom: '#0b0b0a', label: true }, // override to a DARK bed if you want
624
+ segments: [
625
+ { clip: './rec/study.mov', caption: { title: 'Recall it.', sub: 'Right before you forget.' } },
626
+ { images: ['a.png', 'b.png'], caption: { title: 'Many cards.', sub: 'One page.' } }, // multi-photo page
627
+ ],
628
+ }
629
+ ```
630
+
631
+ - **`clip`** = a recording (real motion). **`image`** / **`images`** = one still or a sequence shown within
632
+ the segment (a "multiple photos per page" beat). **`transition`** = `dissolve` (default) or `cut`. The screen
633
+ sits in an **iPhone frame** by default (`theme.frame`: `'phone'` | `false` for a bare rounded screen). Matte
634
+ colours / `inset` / `radius` / `shadow` / caption anchor default to a clean **light** look; override via the
635
+ reel `theme`. Run `zdymak reel` → `<out>/reel.mp4`.
636
+ - **Compliant App Store App Preview** — Apple's in-store slot wants *real footage, full-bleed, no device
637
+ frame*. Point the reel at recordings (not stills) with `theme: { bleed: true }`, `size: [886, 1920]`,
638
+ `level: '4.0'`, and no captions. (The framed light reel above is the marketing/social asset, not the slot.)
639
+
640
+ ## Where each file goes
641
+
642
+ - **App Store** — `appstore-preview.mp4` → App Store Connect → your app → *(localization)* → **App
643
+ Previews**. One 886×1920 file fills **both** the 6.5" and 6.9" slots. Optional at launch; screenshots
644
+ alone are a valid submission.
645
+ - **Google Play** — Play takes a **YouTube URL**, not a file. Upload `play-promo.mp4` to YouTube, then
646
+ paste the link in Play Console → Main store listing → **Preview video**.
647
+ - **Web / social** — `social-reel.mp4` (**device-framed**: iPhone bezel + logo bookends) or
648
+ `premium-reel.mp4` (**premium**: matte + vignette + label pills). For your website, X / Instagram /
649
+ TikTok, or YouTube. **Never** put either in the App Store App Preview slot — the bezel/label make them
650
+ marketing assets, not App Previews; that's what the full-bleed `appstore-preview` is for.
651
+
652
+ > **Three styles, one config.** Every target reads the same `scenes`; the style is fixed per target:
653
+ > - **full-bleed** (`appstore-preview`, `play-promo`) — the screen fills the frame; required for App Previews.
654
+ > - **device-framed** (`social-reel`) — an iPhone bezel + brand background + logo cold-open/end-card.
655
+ > - **premium** (`premium-reel`) — the **Apple editing-vocabulary** preset: every screen floats on a brand
656
+ > matte with a soft glow + vignette, a motion-then-freeze spring dolly, **palette-aware cuts** (hard cut
657
+ > within a palette, dissolve only at a shift), and a bottom title **pill**. Tune it with the optional
658
+ > `theme` block (matte colours, `vignette`, `inset`, `handle`, cut timings) — brand-driven defaults apply
659
+ > if you omit it.
660
+
661
+ <br>
662
+
663
+ ## Programmatic use
664
+
665
+ ```js
666
+ import { buildVideo, loadConfig, registerFonts, videoTarget } from 'zdymak';
667
+
668
+ const cfg = await loadConfig('zdymak.config.mjs');
669
+ registerFonts(cfg.brand.fontPaths);
670
+ await buildVideo({ scenes: cfg.scenes, spec: videoTarget('appstore-preview'), brand: cfg.brand, outFile: 'out.mp4' });
671
+ ```
672
+
673
+ <br>
674
+
675
+ ## TypeScript
676
+
677
+ Types ship with the package (`types/index.d.ts`) — nothing to install, and **your project doesn't need
678
+ TypeScript**. Wrap the config in `defineConfig` and any modern editor type-checks and autocompletes it,
679
+ including the target ids, the `move` / `captionAnchor` unions and every theme option:
680
+
681
+ ```js
682
+ // zdymak.config.mjs
683
+ import { defineConfig } from 'zdymak';
684
+
685
+ export default defineConfig({
686
+ brand: { name: 'Asilak', tagline: 'Learn anything.' },
687
+ scenes: [{ id: 'welcome', title: 'Learn anything.', move: 'pushInSlow' }],
688
+ devices: { android: { screenshots: [{ target: 'play-phone' }, { target: 'play-feature-graphic' }] } },
689
+ });
690
+ ```
691
+
692
+ Prefer no import? A JSDoc annotation gets you the same thing:
693
+
694
+ ```js
695
+ /** @type {import('zdymak').Config} */
696
+ export default { /* … */ };
697
+ ```
698
+
699
+ The programmatic API is typed too (`loadConfig` returns a `ResolvedConfig` with absolute paths). Two
700
+ guards keep the types honest: `npm run check:types` type-checks `types/types.test-d.ts` — which asserts
701
+ that bad configs are *rejected* — and fails if any config field, theme option or store target isn't
702
+ declared in the `.d.ts`. Both run on `prepublishOnly`.
703
+
704
+ <br>
705
+
706
+ ## Troubleshooting
707
+
708
+ - **`ffmpeg failed to start`** — install it (`brew install ffmpeg`) or set `$FFMPEG`.
709
+ - **Captions look like a fallback font** — pass `brand.fontPaths` to your own TTF; on Linux install a
710
+ system sans (DejaVu).
711
+ - **Duration warning** — the tool warns if a video falls outside a store's min/max; adjust `sceneDur` or
712
+ the number of scenes.
713
+ - **`Truncating packet …` line** — harmless ffmpeg notice from the raw-frame pipe; the output is correct.
714
+
715
+ <br>
716
+
717
+ ## Roadmap
718
+
719
+ - [x] Video engine — three styles (full-bleed, device-framed, premium), App Store + Play + social targets.
720
+ - [x] **Multi-device screenshots** — iPhone / iPad / Mac / Watch, no-alpha PNG, modular `devices` config.
721
+ - [x] **Device-framed stills** — inferred per target (iPhone/iPad/Android/Watch bezel, Mac window), caption
722
+ on top, `contain`-fit windows; all overridable via `stillTheme` / per-shot `theme`.
723
+ - [x] **Music bed** — `{ path, offset, fadeIn, fadeOut, volume }` across every video.
724
+ - [x] Automated publishing — npm trusted publishing (OIDC), see `RELEASING.md`.
725
+ - [x] **TypeScript definitions** — `defineConfig` + a typed `Config`/`ResolvedConfig`, drift-guarded.
726
+ - [x] Play feature graphic (1024×500) + the `play-wear` (1:1) slot.
727
+ - [x] **Capture: Playwright (web)** driver — URL states, device descriptors, deterministic shots
728
+ (adb / iOS-sim snapshot already shipped).
729
+ - [x] **Per-locale caption sets** — one screenshot set per store locale, reported fallbacks.
730
+
731
+ See **SKILL.md** if you drive this with Claude Code.
732
+
733
+ MIT.