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/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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
zdymak
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
<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.
|