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/SKILL.md
CHANGED
|
@@ -1,84 +1,183 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: zdymak
|
|
3
|
-
description:
|
|
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
|
|
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
|
-
|
|
1
|
+
---
|
|
2
|
+
name: zdymak
|
|
3
|
+
description: Capture app screenshots AND generate spec-compliant App Store / Google Play preview videos and store screenshots. Use when a user wants an "app preview", "store preview video", "App Store trailer", "Play promo video", store screenshots, a Play feature graphic, localized store assets — or needs the screenshots THEMSELVES taken: zdymak drives iOS simulators (launch-arg handle, can build+install), Android devices over adb (intent extra, SystemUI demo mode for a clean status bar) and any web app via Playwright, then frames/captions/encodes to each store's exact spec.
|
|
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, **or store screenshots**. `zdymak video` builds the previews; `zdymak build` *also* renders
|
|
15
|
+
**multi-device store screenshots** (iPhone / iPad / Mac / Watch / Android) — style inferred per target,
|
|
16
|
+
caption on top, `contain`-fit windows, no-alpha PNG. Dimensions are in `zdymak specs`. Add **`--clean`** to
|
|
17
|
+
any build/capture command to wipe the output folder first, so only this run's assets remain (no stale files).
|
|
18
|
+
For a premium reel with REAL motion (not a Ken-Burns zoom of a still), use **`zdymak reel`** — it composites
|
|
19
|
+
app RECORDINGS/clips (or an image sequence) on the matte with beat-matched hard cuts (the `reel` config block).
|
|
20
|
+
|
|
21
|
+
## Pick the right preset (do this before writing any config)
|
|
22
|
+
|
|
23
|
+
The two stores want **opposite** things. Choosing the wrong target is the #1 way to produce assets that
|
|
24
|
+
get rejected or quietly under-perform.
|
|
25
|
+
|
|
26
|
+
| The user wants… | Target | Style | Non-obvious rule |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| An App Store preview video | `appstore-preview` | full-bleed | **15–30 s** or Apple rejects it. **No device frame.** Apple expects footage from inside the app — captions are overlay, so keep them minimal (or use a captions-free real-footage cut). |
|
|
29
|
+
| A Play listing video | `play-promo` | full-bleed | Play takes a **YouTube URL**, not a file. Keep it **silent** unless the music is cleared — a ContentID claim can force ads on, which Play forbids on listing videos. |
|
|
30
|
+
| A marketing/social reel | `social-reel` | device-framed | Set `theme.frame: 'android'` for Android captures — the default body is an iPhone, and an Android UI in an iPhone shell misrepresents the app. Never submit this as an App Preview. |
|
|
31
|
+
| A cinematic showcase | `premium-reel` | premium matte | Override `size` for landscape (Mac: `[2880, 1800]`). |
|
|
32
|
+
| **App Store** screenshots | `appstore-iphone-6.9` (+`-6.5`), `appstore-ipad-13`, `appstore-mac`, `appstore-watch` | framed (inferred) | Marketing styling is **expected** here: frames, headlines, backgrounds. iPad/Mac/Watch shots are *required* if the app ships there. Pick ONE Watch size and keep it across localizations. |
|
|
33
|
+
| **Google Play** screenshots | `play-phone`, `play-tablet`, `play-wear`, `play-feature-graphic` | `bleed` + `caption: false` for the upload | Google forbids device frames, added text and backgrounds on store screenshots (hard requirement for Wear OS). Render a plain set for upload and a styled set for the website — `dir` keeps both. The feature graphic is **required** even without a video. |
|
|
34
|
+
| Web-app screenshots | any target, captured with `--platform web` | as above | Playwright driver; states are URL paths. |
|
|
35
|
+
|
|
36
|
+
Exact dimensions live in `zdymak specs` (printed from the code, so it can't drift). They're checked
|
|
37
|
+
against Apple's *Screenshot / App preview specifications* and Google's *Add preview assets* pages.
|
|
38
|
+
|
|
39
|
+
**Status bars.** An Android Compose capture reserves the status-bar inset but can't contain the system UI,
|
|
40
|
+
so the shot has an empty band. `statusBar: 'auto'` (default) detects that band and paints a clean bar
|
|
41
|
+
(9:41 · full signal/wifi/battery) — exactly the state Google asks for. Square (watch) captures are skipped.
|
|
42
|
+
iOS XCUITest captures already include a real status bar, so nothing is drawn there.
|
|
43
|
+
|
|
44
|
+
## It can take the screenshots too (don't assume the user must supply them)
|
|
45
|
+
`zdymak capture --platform ios|android|web` DRIVES the app through each screen and writes a store-ready
|
|
46
|
+
PNG per screen — it is not only a compositor. iOS: boots/creates a sim, optionally `--build` +installs,
|
|
47
|
+
relaunches per state via a launch-arg handle, pins the status bar to 9:41. Android: `am start --es <arg>
|
|
48
|
+
<state>` over adb with SystemUI demo mode on for a clean bar. Web: Playwright navigates URL paths itself,
|
|
49
|
+
no handle needed. Single-shot `--name <screen>` and `--record` also exist. If the user has no screenshots
|
|
50
|
+
yet, offer this before asking them to produce some. macOS capture is deliberately out of scope (TCC).
|
|
51
|
+
Capture TEARS DOWN what it set up (status-bar override cleared, a sim it booted shut down, a device it
|
|
52
|
+
created deleted, Android demo mode switched off) — `--keep` skips that for debugging. A simulator the
|
|
53
|
+
user already had booted is never touched.
|
|
54
|
+
|
|
55
|
+
## Prerequisites (check first)
|
|
56
|
+
- `ffmpeg` on PATH (`brew install ffmpeg`) — the encode needs it.
|
|
57
|
+
- Node ≥18.
|
|
58
|
+
- The tool itself: run via `zdymak …` if linked, else `node <path-to>/zdymak/bin/zdymak.mjs …`.
|
|
59
|
+
|
|
60
|
+
## Workflow
|
|
61
|
+
|
|
62
|
+
1. **Confirm the store spec** the user wants: `zdymak specs` lists every target with exact
|
|
63
|
+
dimensions. App Store App Previews must be **15–30s** and have **no device frame**; the engine already
|
|
64
|
+
enforces both.
|
|
65
|
+
|
|
66
|
+
2. **Get screenshots** — two modes:
|
|
67
|
+
- **Bring-your-own:** point `screenshotsDir` at existing PNGs (Xcode/XCUITest/Android/Figma).
|
|
68
|
+
- **Capture:** boot a simulator/emulator, navigate to each screen, then
|
|
69
|
+
`zdymak capture --platform ios|android --name <screen> --out ./screenshots`. It strips alpha
|
|
70
|
+
(stores reject it). It does NOT build the app — you drive navigation.
|
|
71
|
+
|
|
72
|
+
3. **Author the config** (`zdymak.config.mjs` at project root). Keep captions **terse** (a preview
|
|
73
|
+
autoplays muted in search results — it must read in ~2.5s per scene). Order the scenes as a narrative:
|
|
74
|
+
hook → what it does → the best moment → the honest offer. Pick `move` per scene for variety, or omit to
|
|
75
|
+
auto-vary. Example:
|
|
76
|
+
```js
|
|
77
|
+
export default {
|
|
78
|
+
brand: { ink: '#0b0b0a', title: '#F5F5F4', sub: '#9ae6b4' },
|
|
79
|
+
screenshotsDir: './screenshots', suffix: '',
|
|
80
|
+
scenes: [
|
|
81
|
+
{ id: 'welcome', title: 'One-line hook.', sub: 'The promise.', move: 'pushInSlow' },
|
|
82
|
+
{ id: 'best', title: 'The best moment.', sub: 'Show, don’t tell.', move: 'pullBack' },
|
|
83
|
+
{ id: 'offer', title: 'The honest offer.', sub: 'Price / trial.', move: 'pullBackSlow' },
|
|
84
|
+
],
|
|
85
|
+
targets: ['appstore-preview', 'play-promo'],
|
|
86
|
+
out: './store-assets',
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
4. **Build:** `zdymak video`. Then **verify** with ffprobe before telling the user it's ready:
|
|
91
|
+
```sh
|
|
92
|
+
ffprobe -v error -select_streams v:0 \
|
|
93
|
+
-show_entries stream=width,height,codec_name,profile,level,pix_fmt -show_entries format=duration \
|
|
94
|
+
-of default=noprint_wrappers=1 store-assets/appstore-preview.mp4
|
|
95
|
+
```
|
|
96
|
+
Confirm width/height/profile/level match the target and duration is in range. Heed any `⚠︎` duration
|
|
97
|
+
warning the CLI prints (adjust `sceneDur` or scene count).
|
|
98
|
+
|
|
99
|
+
5. **Tell the user where it goes:**
|
|
100
|
+
- App Store → App Store Connect → App Previews (one 886×1920 file fills the 6.5" **and** 6.9" slots).
|
|
101
|
+
- Play → upload the 1080×1920 file to **YouTube**, paste the link in Play Console → Preview video.
|
|
102
|
+
|
|
103
|
+
## Capturing the screenshots
|
|
104
|
+
`zdymak capture --platform ios|android|web`. For **web**, states are URL paths and the tool drives itself:
|
|
105
|
+
`--platform web --url http://localhost:3000 --states /,/today --suffix -light`, plus `--device "iPhone 15 Pro"`
|
|
106
|
+
for mobile-web, `--theme dark`, `--locale`, `--wait <selector>`, `--full-page`. Playwright is an optional
|
|
107
|
+
dep (`npm i -D playwright && npx playwright install chromium`). Shots are deterministic — animations are
|
|
108
|
+
zeroed and fonts/images awaited — so re-runs only differ where the UI did.
|
|
109
|
+
|
|
110
|
+
## Play wants PLAIN screenshots (Apple wants styled)
|
|
111
|
+
Google's asset guidance forbids device frames, added text and backgrounds on Play screenshots (a hard
|
|
112
|
+
requirement for Wear OS), while Apple expects marketing styling. Render both from one target with `dir`:
|
|
113
|
+
`{ target: 'play-phone', dir: 'play-phone-plain', style: 'bleed', caption: false, theme: { anchor: 'top' } }`
|
|
114
|
+
for the upload, plus a plain `{ target: 'play-phone' }` for the website. Google *does* want a tidy status
|
|
115
|
+
bar (no carrier/notifications, full battery/wifi/signal) — `statusBar: 'auto'` (the default) paints one into
|
|
116
|
+
the empty band an Android Compose capture leaves behind; `statusBarTime` sets the clock.
|
|
117
|
+
|
|
118
|
+
## Destination vs preset (and validation)
|
|
119
|
+
`destination` = what a store accepts (size/codec/duration/alpha/cap). `preset` = how it looks
|
|
120
|
+
(`full-bleed` | `framed` | `premium`). They're independent: `{ destination: 'play-promo', preset:
|
|
121
|
+
'premium' }` is valid, and `{ destination, preset, transitions: [...], effects: [...] }` lets the user
|
|
122
|
+
supply their own vocabulary (scenes without a `cut` cycle the list). `{ target: 'x' }` remains shorthand.
|
|
123
|
+
A destination can REFUSE a preset — `appstore-preview` + `framed` throws, because Apple rejects bezels
|
|
124
|
+
there. Every written asset is re-measured against its destination and REFUSED on violation (`--force`
|
|
125
|
+
downgrades to a warning), so never claim an asset is spec-compliant without running the build.
|
|
126
|
+
|
|
127
|
+
## Hard store requirements (verified July 2026 — re-check before a release)
|
|
128
|
+
**App Store screenshots** 1–10 per device type, JPEG/PNG, no alpha: iPhone 6.9" 1320×2868 · iPhone 6.5"
|
|
129
|
+
1284×2778 · iPad 13" 2064×2752 (required for iPad apps) · Mac 2880×1800 · Watch 422×514 (pick one size,
|
|
130
|
+
use it in every locale).
|
|
131
|
+
**App Previews** 15–30s or it's rejected · ≤500MB · ≤30fps · H.264 High ≤4.0 · up to 3 per family · NO
|
|
132
|
+
device frame. iPhone 886×1920 (`appstore-preview`) · **iPad 1200×1600** (`appstore-preview-ipad`) · Mac
|
|
133
|
+
1920×1080 landscape (`appstore-preview-mac`). The iPad preview is NOT the iPad screenshot size — that
|
|
134
|
+
mismatch is a routine rejection.
|
|
135
|
+
**Play images** JPEG/24-bit PNG, no alpha: phone 1080×1920 (2–8, max 2:1, 320–3840px) · tablet 2560×1440
|
|
136
|
+
· Wear 1080×1080 1:1 (required for Wear) · feature graphic 1024×500 (**required**) · icon 512×512 ≤1MB
|
|
137
|
+
(alpha OK). Wear OS: interface only (requirement). Phone/tablet: frames recommended against, taglines ≤20% allowed.
|
|
138
|
+
**Play video** is a YouTube URL, not a file — render `play-promo`, upload, paste the link. Keep it silent
|
|
139
|
+
unless the track is cleared (ContentID → forced ads → Play violation).
|
|
140
|
+
|
|
141
|
+
## Store cut vs social-ad cut (per-scene `cut` / `effect`)
|
|
142
|
+
`social-reel` supports **34 transitions** and **25 effects**, chosen per scene:
|
|
143
|
+
`{ id: 'study', cut: 'flip', effect: 'warm-film', push: true }`.
|
|
144
|
+
|
|
145
|
+
- **Store preview → stay plain.** Default `cut` (a one-frame hard cut) carries the rhythm; spend a
|
|
146
|
+
`dissolve` only where the meaning changes; use at most ONE `push: true` camera move in the whole reel.
|
|
147
|
+
Reaching for a different decorative transition at every boundary is the #1 amateur tell.
|
|
148
|
+
- **Social ad → open it up.** Anything in the tables is fair game; `auto` gives a deterministic rotation
|
|
149
|
+
that stays mostly plain without art direction.
|
|
150
|
+
- **Effects are reel-only.** Never grade a store screenshot — Google requires Play shots to show the
|
|
151
|
+
interface unaltered.
|
|
152
|
+
- **Beat-match the cuts** with `timing: { bpm, beatsPerCut }` (hold = beatsPerCut × 60 / bpm).
|
|
153
|
+
|
|
154
|
+
Full tables (id → duration → what it reads as) are in the README under *Transitions & effects*; the ids are
|
|
155
|
+
also the `CutId` / `EffectId` unions in `types/index.d.ts`, so an editor will autocomplete them.
|
|
156
|
+
|
|
157
|
+
## Localized store listings
|
|
158
|
+
If the app ships more than one store locale, add a `captions` block — `{ de: './captions/de.json' }` or an
|
|
159
|
+
inline `{ sceneId: { title, sub } }` table — and run `zdymak screenshots` (or `--locale de,fr`). Each locale
|
|
160
|
+
renders to `<out>/<locale>/<target>/`; untranslated scenes keep the base caption and are reported. The
|
|
161
|
+
reserved `$brand` key localizes the feature graphic's wordmark copy. Screenshots only — videos aren't
|
|
162
|
+
re-encoded per locale.
|
|
163
|
+
|
|
164
|
+
## Tuning for quality
|
|
165
|
+
- **Captions overlap busy UI?** Lower them (they sit at ~0.75·height) or pick screenshots with calmer
|
|
166
|
+
lower thirds.
|
|
167
|
+
- **Feels flat?** Ensure adjacent scenes use *different* moves; the spring dolly is what makes it premium.
|
|
168
|
+
- **Too long/short?** Total = `scenes × sceneDur − (scenes−1) × xfade`. App Store wants 15–30s.
|
|
169
|
+
|
|
170
|
+
## Three styles (fixed per target; all read the same `scenes`)
|
|
171
|
+
- **Full-bleed** (`appstore-preview`, `play-promo`) — screen fills the frame; required for App Previews.
|
|
172
|
+
- **Device-framed** (`social-reel`) — iPhone bezel + brand background + logo cold-open/end-card
|
|
173
|
+
(needs `brand.name/tagline/endline/logo`). Web/social/YouTube.
|
|
174
|
+
- **Premium** (`premium-reel`) — the **Apple editing-vocabulary** preset: matte + glow + vignette,
|
|
175
|
+
motion-then-freeze spring dolly, **palette-aware cuts**, bottom title pill. This is the default premium
|
|
176
|
+
marketing look; tune via the optional `theme` block (brand-driven defaults apply if omitted). Web/social.
|
|
177
|
+
Like the framed reel, it's **not** an App Preview (the matte/pill make it a marketing asset).
|
|
178
|
+
|
|
179
|
+
## Guardrails
|
|
180
|
+
- Never submit the **device-framed** `social-reel` as an App Preview — Apple rejects device bezels there.
|
|
181
|
+
Use the full-bleed `appstore-preview` for the App Store slot; `social-reel` is web/social/YouTube only.
|
|
182
|
+
- Keep any music **commercially licensed**; the engine is silent by default (previews autoplay muted).
|
|
183
|
+
- Don't fabricate UI or overstate features in captions — App Review checks the preview matches the app.
|
package/bin/zdymak.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { run } from '../src/cli.mjs';
|
|
3
|
-
run();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from '../src/cli.mjs';
|
|
3
|
+
run();
|
package/package.json
CHANGED
|
@@ -1,40 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "zdymak",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"zdymak": "bin/zdymak.mjs"
|
|
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
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "zdymak",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Store screenshots and spec-compliant App Store / Google Play preview videos — captured from your app and composed, in one config-driven CLI. Drives iOS simulators, Android devices and web apps to take the screenshots, then frames, captions and encodes them to each store's exact requirements.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zdymak": "bin/zdymak.mjs"
|
|
8
|
+
},
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
13
|
+
"default": "./src/index.mjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"src",
|
|
19
|
+
"types/index.d.ts",
|
|
20
|
+
"SKILL.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"example": "node bin/zdymak.mjs video --config examples/asilak.config.mjs",
|
|
24
|
+
"demo": "node scripts/build-demo.mjs",
|
|
25
|
+
"docs:diagram": "node scripts/build-diagram.mjs",
|
|
26
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
27
|
+
"check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
|
|
28
|
+
"prepublishOnly": "node scripts/check-docs.mjs && npm run check:types",
|
|
29
|
+
"release:patch": "node scripts/release.mjs patch",
|
|
30
|
+
"release:minor": "node scripts/release.mjs minor",
|
|
31
|
+
"release:major": "node scripts/release.mjs major"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/Lonli-Lokli/zdymak.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/Lonli-Lokli/zdymak#readme",
|
|
41
|
+
"bugs": "https://github.com/Lonli-Lokli/zdymak/issues",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@napi-rs/canvas": "^0.1.60"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"app-store",
|
|
47
|
+
"google-play",
|
|
48
|
+
"app-preview",
|
|
49
|
+
"screenshots",
|
|
50
|
+
"aso",
|
|
51
|
+
"marketing",
|
|
52
|
+
"video",
|
|
53
|
+
"ffmpeg",
|
|
54
|
+
"screenshot-automation",
|
|
55
|
+
"playwright",
|
|
56
|
+
"simulator",
|
|
57
|
+
"adb",
|
|
58
|
+
"app-preview-video",
|
|
59
|
+
"store-assets"
|
|
60
|
+
],
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^26.1.1",
|
|
64
|
+
"typescript": "^7.0.2"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/canvas.mjs
CHANGED
|
@@ -1,116 +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
|
-
}
|
|
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
|
+
}
|