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/SKILL.md CHANGED
@@ -1,84 +1,183 @@
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.
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.7.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
- }
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
+ }