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