zdymak 0.5.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -22
- package/SKILL.md +6 -2
- package/package.json +9 -1
- package/src/capture/index.mjs +53 -10
- package/src/cli.mjs +56 -6
- package/src/config.mjs +30 -4
- package/src/montage.mjs +419 -0
- package/src/premium.mjs +51 -14
- package/src/screenshots.mjs +3 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Turn a handful of app screenshots into a cinematic, **spec-compliant** store pre
|
|
|
6
6
|
moves, kinetic captions, and an encode that App Store Connect and Google Play accept without a fight. One
|
|
7
7
|
small config per project; the engine lives here, shared across all your apps.
|
|
8
8
|
|
|
9
|
+
<p align="center"><em>▶︎ <b>See it in action:</b> run <code>npm run example</code> to render the demo reel from three flat screenshots — a hosted preview lands in a later release.</em></p>
|
|
10
|
+
|
|
9
11
|
> Why it exists: a plain "Ken-Burns zoom over screenshots" reads as amateur, and hand-encoding to each
|
|
10
12
|
> store's exact spec (Apple: 886×1920, H.264 High@4.0, 15–30s, **no device frame**) is fiddly and easy to
|
|
11
13
|
> get rejected. This bakes the premium motion and the store rules in.
|
|
@@ -26,16 +28,25 @@ small config per project; the engine lives here, shared across all your apps.
|
|
|
26
28
|
|
|
27
29
|
## Install
|
|
28
30
|
|
|
29
|
-
Needs **Node ≥18** and **ffmpeg** on your `PATH` (`brew install ffmpeg`).
|
|
30
|
-
|
|
31
31
|
```sh
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
npm
|
|
35
|
-
# …or just call it directly:
|
|
36
|
-
node /path/to/zdymak/bin/zdymak.mjs <command>
|
|
32
|
+
npm i -g zdymak # global CLI → `zdymak <command>`
|
|
33
|
+
# …or per-project:
|
|
34
|
+
npm i -D zdymak # → `npx zdymak <command>`
|
|
37
35
|
```
|
|
38
36
|
|
|
37
|
+
Also needs **Node ≥18** and **ffmpeg** on your `PATH`:
|
|
38
|
+
|
|
39
|
+
| Platform | Install ffmpeg |
|
|
40
|
+
|---|---|
|
|
41
|
+
| **macOS** | `brew install ffmpeg` |
|
|
42
|
+
| **Linux** — Debian/Ubuntu | `sudo apt install ffmpeg` |
|
|
43
|
+
| **Linux** — Fedora | `sudo dnf install ffmpeg` |
|
|
44
|
+
| **Windows** | `winget install Gyan.FFmpeg` · or `choco install ffmpeg` / `scoop install ffmpeg` |
|
|
45
|
+
|
|
46
|
+
zdymak finds ffmpeg on `PATH`, or set `$FFMPEG` to an explicit binary. Video generation is pure Node +
|
|
47
|
+
ffmpeg — no platform SDKs. Screenshot **capture** additionally needs that platform's toolchain: **Xcode**
|
|
48
|
+
for `--platform ios`, the **Android SDK / `adb`** for `--platform android`.
|
|
49
|
+
|
|
39
50
|
<br>
|
|
40
51
|
|
|
41
52
|
## Quickstart
|
|
@@ -102,39 +113,66 @@ navigation.
|
|
|
102
113
|
| `scenes[].move` | `pushIn` · `pushInSlow` · `pullBack` · `pullBackSlow` · `driftUp` · `driftDown` · `driftLeft` · `driftRight` · `still`. Omit to auto-vary. |
|
|
103
114
|
| `targets[]` | Which videos to build from the top-level scenes (`zdymak specs` lists them). |
|
|
104
115
|
| `sceneDur` / `xfade` | Seconds per scene / cross-dissolve. Tune total length to the store's 15–30s window. |
|
|
105
|
-
| `theme` |
|
|
116
|
+
| `theme` | **Video** matte styling — the premium-technique block (see **Theme options** below). Brand-driven defaults. |
|
|
117
|
+
| `stillTheme` | **Screenshot** matte styling — same option shape as `theme`; falls back to `theme` when unset. |
|
|
118
|
+
| `timing` | Reel-mode timeline override `{ coldOpen, scene, endCard, xfade }` for the `social-reel` bookends. |
|
|
106
119
|
| `music` | Optional bed for **every** video: `{ path, offset, fadeIn, fadeOut, volume }` (silent if omitted). |
|
|
107
120
|
| `devices` | Per-device **screenshots + reels** (see below). Configure only the devices you ship. |
|
|
121
|
+
| `reel` | **Live-footage reel** — composite driven video `clip`s / `images` on a clean light matte, cross-dissolves (see **Live-footage reel** below). |
|
|
108
122
|
| `out` | Output directory. |
|
|
109
123
|
|
|
124
|
+
### Theme options (`theme` / `stillTheme`)
|
|
125
|
+
|
|
126
|
+
Both accept the same block; every key is optional with a brand-driven default. **Screenshots assume the
|
|
127
|
+
premium store-shot shape by default** — `captionAnchor: 'top'`, and `fit: 'contain'` for frameless windows —
|
|
128
|
+
so a typical `stillTheme` only sets colours. Override any key per shot.
|
|
129
|
+
|
|
130
|
+
| Key | Default | Meaning |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `bgTop` / `bgBottom` | brand | Matte gradient top / bottom colour. |
|
|
133
|
+
| `glow` / `glowAlpha` | brand.sub / `0.16` | Soft radial brand-glow colour + strength. |
|
|
134
|
+
| `vignette` | `0.3` | 0..1 edge darkening (use `0` on a light matte). |
|
|
135
|
+
| `inset` | `0.955` | Fraction of the frame the screen fills (lower floats it with a wider matte border). |
|
|
136
|
+
| `label` | `true` | Show the caption on a pill; `false` = plain text, no pill. |
|
|
137
|
+
| `labelColor` / `subColor` | brand.title / brand.sub | Caption title + subtitle colour. |
|
|
138
|
+
| `handle` | — | Optional persistent top handle text (e.g. `@yourapp`). |
|
|
139
|
+
| `captionAnchor` | `bottom` (video) · `top` (stills) | Caption above (`top`) or below (`bottom`) the device. |
|
|
140
|
+
| `fit` | `cover` · `contain` (frameless stills) | `cover` fills + crops; `contain` shows the **whole** capture (e.g. a Mac window) with matte margins. |
|
|
141
|
+
| `headlineScale` | `0.062` | Caption headline size as a fraction of the frame's short edge — bump it for bigger, bolder headlines. |
|
|
142
|
+
| `frame` | `phone` (reel) | Device frame around a reel screen: `'phone'` iPhone bezel, or `false` for a bare rounded screen. |
|
|
143
|
+
| `bleed` | `false` | Reel only: the source **fills the whole frame** (no matte / frame / shadow) — a compliant full-bleed App Store App Preview. |
|
|
144
|
+
|
|
110
145
|
<br>
|
|
111
146
|
|
|
112
147
|
## Screenshots & multiple devices
|
|
113
148
|
|
|
114
149
|
Videos are only half the set. `zdymak build` also renders **store screenshots** for each device you
|
|
115
|
-
configure,
|
|
116
|
-
|
|
117
|
-
|
|
150
|
+
configure, at each store's exact dimensions, as **no-alpha PNGs** (App Store & Play reject alpha). Each
|
|
151
|
+
device points at its own captures; scenes with no matching capture are **skipped cleanly**, so an app lists
|
|
152
|
+
only the devices it actually ships. **The style is inferred from the target**, so a device is usually just
|
|
153
|
+
its captures + target(s):
|
|
118
154
|
|
|
119
155
|
```js
|
|
120
156
|
devices: {
|
|
121
|
-
iphone: { capturesDir: './shots/iphone', suffix: '', screenshots: [{ target: 'appstore-iphone-6.9'
|
|
122
|
-
ipad: { capturesDir: './shots/ipad', suffix: '', screenshots: [{ target: 'appstore-ipad-13'
|
|
123
|
-
mac: { capturesDir: './shots/mac', suffix: '', screenshots: [{ target: 'appstore-mac'
|
|
157
|
+
iphone: { capturesDir: './shots/iphone', suffix: '', screenshots: [{ target: 'appstore-iphone-6.9' }] },
|
|
158
|
+
ipad: { capturesDir: './shots/ipad', suffix: '', screenshots: [{ target: 'appstore-ipad-13' }] },
|
|
159
|
+
mac: { capturesDir: './shots/mac', suffix: '', screenshots: [{ target: 'appstore-mac' }] }, // window on the matte
|
|
124
160
|
watch: { capturesDir: './shots/watch', suffix: '',
|
|
125
161
|
scenes: [{ id: '01-study' }, { id: '02-answer' }], // per-device scene override (raw, no caption)
|
|
126
|
-
screenshots: [{ target: 'appstore-watch', style: 'bleed', size: [422, 514] }] },
|
|
162
|
+
screenshots: [{ target: 'appstore-watch', style: 'bleed', size: [422, 514] }] }, // override: raw fill
|
|
127
163
|
android:{ capturesDir: './shots/android', suffix: '', screenshots: [
|
|
128
|
-
{ target: 'play-phone',
|
|
129
|
-
{ target: 'play-tablet', style: 'premium' },
|
|
164
|
+
{ target: 'play-phone' }, { target: 'play-tablet' },
|
|
130
165
|
{ target: 'play-feature-graphic' } ] }, // the 1024×500 Play banner (not per-scene)
|
|
131
166
|
// a device may also carry `videos: [{ target: 'premium-reel', size: [2064, 2752] }]` at its own dimensions
|
|
132
167
|
},
|
|
133
168
|
```
|
|
134
169
|
|
|
135
|
-
|
|
136
|
-
iPad/tablet,
|
|
137
|
-
|
|
170
|
+
**Inferred style** (override per shot with `style`): a **framed** device — iPhone (Dynamic Island), Android
|
|
171
|
+
(punch-hole), iPad/tablet, Watch ring — for phone/tablet/watch targets; a **premium** window-on-the-matte for
|
|
172
|
+
Mac/desktop (its capture is already a window, so `fit: 'contain'` shows the whole thing). Screenshots put the
|
|
173
|
+
**caption on top** by default. Override any of it via `stillTheme` / a per-shot `theme`, or `style: 'bleed'`
|
|
174
|
+
for a raw full-frame shot (Watch). **`play-feature-graphic`** is special: one 1024×500 brand banner (logo +
|
|
175
|
+
tagline + a tilted hero device), not a per-scene screenshot.
|
|
138
176
|
|
|
139
177
|
Commands:
|
|
140
178
|
|
|
@@ -142,13 +180,52 @@ Commands:
|
|
|
142
180
|
zdymak build # EVERYTHING: top-level videos + every device's screenshots (+ device videos)
|
|
143
181
|
zdymak screenshots # just the per-device screenshots
|
|
144
182
|
zdymak video # just the top-level video targets
|
|
183
|
+
zdymak reel # LIVE-FOOTAGE montage (real motion) from the `reel` block — see below
|
|
184
|
+
zdymak build --clean # wipe the output folder first, so ONLY this run's assets remain (no stale files)
|
|
145
185
|
```
|
|
146
186
|
|
|
187
|
+
**`--clean`** (on `build` / `screenshots` / `video` / `capture`) empties the target folder before writing,
|
|
188
|
+
so a removed target or renamed scene can't leave a stale screenshot behind — every file is produced by this
|
|
189
|
+
run. On `capture` it clears only stale PNG/MOV captures and keeps the `.dd` build cache (rebuilds stay
|
|
190
|
+
incremental).
|
|
191
|
+
|
|
147
192
|
`zdymak specs` lists every image target and its exact dimensions. A device that only ships iPhone simply
|
|
148
193
|
omits the others — that's the "use only part of it" contract.
|
|
149
194
|
|
|
150
195
|
<br>
|
|
151
196
|
|
|
197
|
+
## Live-footage reel — real motion, not Ken Burns
|
|
198
|
+
|
|
199
|
+
The video *targets* above animate a **static screenshot** (a subtle dolly) — inherently Ken Burns. For a
|
|
200
|
+
genuinely premium reel, feed **real motion**: `zdymak reel` composites short **recordings** of your app (or
|
|
201
|
+
an image sequence) on a clean matte, floats each with a rounded frame + soft shadow, puts the headline **on
|
|
202
|
+
top**, and **cross-dissolves** between beats — the restrained, Apple-App-Preview language. The matte defaults
|
|
203
|
+
to **light** (consistent with the store screenshots) and stills get a slow, never-freezing push-in.
|
|
204
|
+
Source-agnostic, like the two screenshot modes: each segment's footage can be **brought by you** or
|
|
205
|
+
**captured** by `zdymak capture --record`.
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
reel: {
|
|
209
|
+
size: [1080, 1920], bpm: 120, beatsPerCut: 4, // hold = beatsPerCut × 60/bpm seconds per segment
|
|
210
|
+
transition: 'dissolve', // default; 'cut' for beat-matched hard cuts
|
|
211
|
+
music: { path: './bed.mp3', volume: 0.9, fadeIn: 0.6, fadeOut: 0.8 }, // optional, faded
|
|
212
|
+
// theme: { bgTop: '#0e1a12', bgBottom: '#0b0b0a', label: true }, // override to a DARK bed if you want
|
|
213
|
+
segments: [
|
|
214
|
+
{ clip: './rec/study.mov', caption: { title: 'Recall it.', sub: 'Right before you forget.' } },
|
|
215
|
+
{ images: ['a.png', 'b.png'], caption: { title: 'Many cards.', sub: 'One page.' } }, // multi-photo page
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **`clip`** = a recording (real motion). **`image`** / **`images`** = one still or a sequence shown within
|
|
221
|
+
the segment (a "multiple photos per page" beat). **`transition`** = `dissolve` (default) or `cut`. The screen
|
|
222
|
+
sits in an **iPhone frame** by default (`theme.frame`: `'phone'` | `false` for a bare rounded screen). Matte
|
|
223
|
+
colours / `inset` / `radius` / `shadow` / caption anchor default to a clean **light** look; override via the
|
|
224
|
+
reel `theme`. Run `zdymak reel` → `<out>/reel.mp4`.
|
|
225
|
+
- **Compliant App Store App Preview** — Apple's in-store slot wants *real footage, full-bleed, no device
|
|
226
|
+
frame*. Point the reel at recordings (not stills) with `theme: { bleed: true }`, `size: [886, 1920]`,
|
|
227
|
+
`level: '4.0'`, and no captions. (The framed light reel above is the marketing/social asset, not the slot.)
|
|
228
|
+
|
|
152
229
|
## Where each file goes
|
|
153
230
|
|
|
154
231
|
- **App Store** — `appstore-preview.mp4` → App Store Connect → your app → *(localization)* → **App
|
|
@@ -199,10 +276,10 @@ await buildVideo({ scenes: cfg.scenes, spec: videoTarget('appstore-preview'), br
|
|
|
199
276
|
|
|
200
277
|
- [x] Video engine — three styles (full-bleed, device-framed, premium), App Store + Play + social targets.
|
|
201
278
|
- [x] **Multi-device screenshots** — iPhone / iPad / Mac / Watch, no-alpha PNG, modular `devices` config.
|
|
279
|
+
- [x] **Device-framed stills** — inferred per target (iPhone/iPad/Android/Watch bezel, Mac window), caption
|
|
280
|
+
on top, `contain`-fit windows; all overridable via `stillTheme` / per-shot `theme`.
|
|
202
281
|
- [x] **Music bed** — `{ path, offset, fadeIn, fadeOut, volume }` across every video.
|
|
203
282
|
- [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
283
|
- [ ] Capture: **Playwright (web)** driver (adb / iOS-sim snapshot already ship).
|
|
207
284
|
- [ ] Play feature graphic (1024×500) + per-locale caption sets.
|
|
208
285
|
|
package/SKILL.md
CHANGED
|
@@ -11,8 +11,12 @@ per-store encode) is shared.
|
|
|
11
11
|
|
|
12
12
|
## When to use
|
|
13
13
|
The user is building App Store / Google Play listing assets, or asks for an app-preview / store trailer /
|
|
14
|
-
promo video
|
|
15
|
-
|
|
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).
|
|
16
20
|
|
|
17
21
|
## Prerequisites (check first)
|
|
18
22
|
- `ffmpeg` on PATH (`brew install ffmpeg`) — the encode needs it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zdymak",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"example": "node bin/zdymak.mjs video --config examples/asilak.config.mjs",
|
|
19
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
20
|
+
"prepublishOnly": "node scripts/check-docs.mjs",
|
|
19
21
|
"release:patch": "node scripts/release.mjs patch",
|
|
20
22
|
"release:minor": "node scripts/release.mjs minor",
|
|
21
23
|
"release:major": "node scripts/release.mjs major"
|
|
@@ -23,6 +25,12 @@
|
|
|
23
25
|
"engines": {
|
|
24
26
|
"node": ">=18"
|
|
25
27
|
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Lonli-Lokli/zdymak.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Lonli-Lokli/zdymak#readme",
|
|
33
|
+
"bugs": "https://github.com/Lonli-Lokli/zdymak/issues",
|
|
26
34
|
"dependencies": {
|
|
27
35
|
"@napi-rs/canvas": "^0.1.60"
|
|
28
36
|
},
|
package/src/capture/index.mjs
CHANGED
|
@@ -82,8 +82,17 @@ async function captureIos(flags) {
|
|
|
82
82
|
}
|
|
83
83
|
const outDir = path.resolve(flags.out || 'shots');
|
|
84
84
|
fs.mkdirSync(outDir, { recursive: true });
|
|
85
|
+
// --clean removes stale capture images first so the folder holds ONLY this run's screenshots. Keeps the
|
|
86
|
+
// `.dd` build cache (and any subdirs) so a rebuild stays incremental — only loose PNG/MOV files are cleared.
|
|
87
|
+
if (flags.clean) {
|
|
88
|
+
let cleared = 0;
|
|
89
|
+
for (const f of fs.readdirSync(outDir)) {
|
|
90
|
+
if (/\.(png|mov)$/i.test(f)) { fs.rmSync(path.join(outDir, f), { force: true }); cleared++; }
|
|
91
|
+
}
|
|
92
|
+
console.log(`🧹 cleaned ${cleared} stale capture(s) in ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
93
|
+
}
|
|
85
94
|
|
|
86
|
-
if (flags.record !== undefined) {
|
|
95
|
+
if (flags.record !== undefined && !flags.states) {
|
|
87
96
|
const out = path.join(outDir, `${flags.name || 'recording'}.mov`);
|
|
88
97
|
console.log(`▶︎ Recording the booted iOS simulator → ${out}\n Interact with the app, then press Ctrl-C to stop.`);
|
|
89
98
|
const proc = spawn('xcrun', ['simctl', 'io', 'booted', 'recordVideo', '--codec=h264', '--force', out], { stdio: 'inherit' });
|
|
@@ -116,20 +125,45 @@ async function captureIos(flags) {
|
|
|
116
125
|
sh('xcrun', ['simctl', 'install', udid, app]);
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
//
|
|
128
|
+
// Apple's canonical marketing status bar: 9:41, full signal/wifi, FULL battery but NOT charging
|
|
120
129
|
// (a charging bolt reads as a simulator override; Apple's own shots show an unplugged full battery).
|
|
121
|
-
|
|
122
|
-
'
|
|
130
|
+
const pinStatusBar = () =>
|
|
131
|
+
spawnSync('xcrun', ['simctl', 'status_bar', udid, 'override', '--time', '9:41',
|
|
132
|
+
'--batteryState', 'discharging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
|
|
133
|
+
pinStatusBar();
|
|
123
134
|
|
|
124
|
-
|
|
135
|
+
// --record turns each screen into a short CLIP (real motion) instead of a still. The app must move
|
|
136
|
+
// during the window: pass the reel handle (default `-marketingReel`) so the harness auto-animates
|
|
137
|
+
// (auto-flip the card, auto-scroll a list, reveal the paywall). The reel engine (`zdymak reel`) then
|
|
138
|
+
// composites these clips on the matte. See SKILL/README "produce mode".
|
|
139
|
+
const recording = flags.record !== undefined;
|
|
140
|
+
const dur = Number(flags.duration || 3);
|
|
141
|
+
const reelArg = flags['reel-arg'] || '-marketingReel';
|
|
142
|
+
const verb = recording ? `Recording ${dur}s clips` : 'Driving';
|
|
143
|
+
console.log(`▶︎ ${verb} for ${states.length} screens via "${flags.arg} <id>" on ${flags.bundle}…`);
|
|
125
144
|
for (const st of states) {
|
|
126
145
|
spawnSync('xcrun', ['simctl', 'terminate', udid, flags.bundle], { stdio: 'ignore' });
|
|
127
|
-
|
|
146
|
+
const launch = ['simctl', 'launch', udid, flags.bundle, flags.arg, st];
|
|
147
|
+
if (recording) launch.push(reelArg, 'YES'); // tell the harness to auto-animate this screen
|
|
148
|
+
sh('xcrun', launch);
|
|
128
149
|
await sleep(settle * 1000);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
// Re-assert RIGHT BEFORE capture: a launch + the settle lets the sim re-sync the battery to the host
|
|
151
|
+
// (a charging bolt creeps back on later screens). Re-pinning per screen keeps every shot clean.
|
|
152
|
+
pinStatusBar();
|
|
153
|
+
if (recording) {
|
|
154
|
+
const out = path.join(outDir, `${st}${suffix}.mov`);
|
|
155
|
+
const rec = spawn('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', out], { stdio: 'ignore' });
|
|
156
|
+
await sleep(dur * 1000);
|
|
157
|
+
rec.kill('SIGINT'); // simctl finalizes the mp4/mov on SIGINT
|
|
158
|
+
await new Promise((res) => rec.on('close', res));
|
|
159
|
+
console.log(` ✓ ${st}${suffix}.mov (${dur}s)`);
|
|
160
|
+
} else {
|
|
161
|
+
await sleep(500); // let the pinned bar paint before the screenshot
|
|
162
|
+
const out = path.join(outDir, `${st}${suffix}.png`);
|
|
163
|
+
sh('xcrun', ['simctl', 'io', udid, 'screenshot', out]);
|
|
164
|
+
await stripAlpha(out);
|
|
165
|
+
console.log(` ✓ ${st}${suffix}.png`);
|
|
166
|
+
}
|
|
133
167
|
}
|
|
134
168
|
console.log(`Done → ${outDir}`);
|
|
135
169
|
return;
|
|
@@ -162,6 +196,15 @@ async function captureAndroid(flags) {
|
|
|
162
196
|
}
|
|
163
197
|
const outDir = path.resolve(flags.out || 'shots');
|
|
164
198
|
fs.mkdirSync(outDir, { recursive: true });
|
|
199
|
+
// --clean removes stale capture images first so the folder holds ONLY this run's screenshots. Keeps the
|
|
200
|
+
// `.dd` build cache (and any subdirs) so a rebuild stays incremental — only loose PNG/MOV files are cleared.
|
|
201
|
+
if (flags.clean) {
|
|
202
|
+
let cleared = 0;
|
|
203
|
+
for (const f of fs.readdirSync(outDir)) {
|
|
204
|
+
if (/\.(png|mov)$/i.test(f)) { fs.rmSync(path.join(outDir, f), { force: true }); cleared++; }
|
|
205
|
+
}
|
|
206
|
+
console.log(`🧹 cleaned ${cleared} stale capture(s) in ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
207
|
+
}
|
|
165
208
|
|
|
166
209
|
if (flags.record !== undefined) {
|
|
167
210
|
const remote = '/sdcard/zdymak-rec.mp4';
|
package/src/cli.mjs
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* zdymak help
|
|
10
10
|
*/
|
|
11
11
|
import path from 'node:path';
|
|
12
|
+
import fs from 'node:fs';
|
|
12
13
|
import { registerFonts } from './fonts.mjs';
|
|
13
14
|
import { loadConfig } from './config.mjs';
|
|
14
15
|
import { buildVideo } from './video.mjs';
|
|
15
16
|
import { buildReel } from './reel.mjs';
|
|
16
17
|
import { buildPremium } from './premium.mjs';
|
|
18
|
+
import { buildMontage } from './montage.mjs';
|
|
17
19
|
import { buildDeviceScreenshots } from './screenshots.mjs';
|
|
18
20
|
import { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
|
|
19
21
|
import { runCapture } from './capture/index.mjs';
|
|
@@ -42,9 +44,26 @@ async function open(flags) {
|
|
|
42
44
|
const cfg = await loadConfig(flags.config || DEFAULT_CONFIG);
|
|
43
45
|
registerFonts(cfg.brand.fontPaths);
|
|
44
46
|
const outDir = flags.out ? path.resolve(flags.out) : cfg.out;
|
|
47
|
+
// --clean wipes the output folder first, so a run can't leave stale assets from a removed target/scene
|
|
48
|
+
// behind (every screenshot/video in the folder is then freshly produced by THIS run).
|
|
49
|
+
if (flags.clean && fs.existsSync(outDir)) {
|
|
50
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
51
|
+
console.log(`🧹 cleaned ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
52
|
+
}
|
|
45
53
|
return { cfg, outDir };
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/** Warn when the audio expectation and the config disagree: a video/reel that should carry a music score
|
|
57
|
+
* has none (Apple recommends a single score for continuity), or `music` is set where nothing consumes it. */
|
|
58
|
+
function warnAudio({ videoCount, hasMusic, label }) {
|
|
59
|
+
if (videoCount > 0 && !hasMusic) {
|
|
60
|
+
console.warn(`⚠︎ audio: ${label} ${videoCount === 1 ? 'plays' : 'play'} SILENT — no music. Apple recommends a single music score for continuity; set \`music.path\`.`);
|
|
61
|
+
}
|
|
62
|
+
if (videoCount === 0 && hasMusic) {
|
|
63
|
+
console.warn('⚠︎ audio: `music.path` is set but this run builds no video/reel — the audio is ignored (screenshots have no sound).');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
/** Build one video target (optionally at an overridden device size / scene set / theme). */
|
|
49
68
|
async function buildVideoTarget({ id, scenes, brand, cfg, outFile, size, theme }) {
|
|
50
69
|
const base = videoTarget(id);
|
|
@@ -68,6 +87,29 @@ async function cmdVideo(flags) {
|
|
|
68
87
|
for (const id of targets) {
|
|
69
88
|
await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
|
|
70
89
|
}
|
|
90
|
+
warnAudio({ videoCount: targets.length, hasMusic: !!cfg.music, label: 'videos' });
|
|
91
|
+
console.log('Done.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cmdReel(flags) {
|
|
95
|
+
const { cfg, outDir } = await open(flags);
|
|
96
|
+
const reel = cfg.reel;
|
|
97
|
+
if (!reel?.segments?.length) {
|
|
98
|
+
console.warn('No `reel.segments` in the config — nothing to build. (See README: live-footage reel.)');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const [w, h] = reel.size || [1080, 1920];
|
|
102
|
+
const spec = { w, h, fps: reel.fps || 30, profile: reel.profile || 'high', level: reel.level || '4.1' };
|
|
103
|
+
const outFile = path.join(outDir, `${flags.name || 'reel'}.mp4`);
|
|
104
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
105
|
+
console.log(`zdymak reel • ${reel.segments.length} segment(s) → ${w}×${h}${reel.music ? ', ♪' : ''}`);
|
|
106
|
+
const { totalDur } = await buildMontage({
|
|
107
|
+
segments: reel.segments, brand: cfg.brand, theme: reel.theme, spec, // reel theme (light default), NOT cfg.theme
|
|
108
|
+
music: reel.music, sceneDur: reel.sceneDur, bpm: reel.bpm, beatsPerCut: reel.beatsPerCut,
|
|
109
|
+
transition: reel.transition, xfadeDur: reel.xfadeDur, outFile,
|
|
110
|
+
});
|
|
111
|
+
console.log(` ✓ ${totalDur.toFixed(1)}s → ${path.relative(process.cwd(), outFile)}`);
|
|
112
|
+
warnAudio({ videoCount: 1, hasMusic: !!reel.music, label: 'the reel' });
|
|
71
113
|
console.log('Done.');
|
|
72
114
|
}
|
|
73
115
|
|
|
@@ -79,9 +121,10 @@ async function cmdScreenshots(flags) {
|
|
|
79
121
|
}
|
|
80
122
|
console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
|
|
81
123
|
for (const device of cfg.devices) {
|
|
82
|
-
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
|
|
124
|
+
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
|
|
83
125
|
console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' — no captures found, skipped'}`);
|
|
84
126
|
}
|
|
127
|
+
warnAudio({ videoCount: 0, hasMusic: !!cfg.music, label: 'screenshots' });
|
|
85
128
|
console.log('Done.');
|
|
86
129
|
}
|
|
87
130
|
|
|
@@ -103,10 +146,13 @@ async function cmdBuild(flags) {
|
|
|
103
146
|
}
|
|
104
147
|
}
|
|
105
148
|
if (device.screenshots.length) {
|
|
106
|
-
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
|
|
149
|
+
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
|
|
107
150
|
console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
|
|
108
151
|
}
|
|
109
152
|
}
|
|
153
|
+
const videoCount = (cfg.targets.length && cfg.scenes.length ? cfg.targets.length : 0)
|
|
154
|
+
+ cfg.devices.reduce((a, d) => a + d.videos.length, 0);
|
|
155
|
+
warnAudio({ videoCount, hasMusic: !!cfg.music, label: 'videos' });
|
|
110
156
|
console.log('Done.');
|
|
111
157
|
}
|
|
112
158
|
|
|
@@ -128,17 +174,20 @@ function cmdHelp() {
|
|
|
128
174
|
console.log(`zdymak — premium App Store, Google Play & social videos + screenshots, from your captures
|
|
129
175
|
|
|
130
176
|
Usage:
|
|
131
|
-
zdymak build [--config <path>] [--out <dir>] # everything: videos + per-device screenshots
|
|
132
|
-
zdymak video [--config <path>] [--target <ids>] [--out <dir>]
|
|
133
|
-
zdymak
|
|
177
|
+
zdymak build [--config <path>] [--out <dir>] [--clean] # everything: videos + per-device screenshots
|
|
178
|
+
zdymak video [--config <path>] [--target <ids>] [--out <dir>] [--clean]
|
|
179
|
+
zdymak reel [--config <path>] [--out <dir>] [--clean] # LIVE-FOOTAGE montage from clips/images
|
|
180
|
+
zdymak screenshots [--config <path>] [--out <dir>] [--clean]
|
|
134
181
|
zdymak specs
|
|
135
182
|
zdymak capture --platform ios --bundle <id> --arg <handle> --states <a,b,c> [--suffix -light]
|
|
136
|
-
[--build --project <.xcodeproj> --scheme <name>] [--device <sim>] [--out <dir>]
|
|
183
|
+
[--build --project <.xcodeproj> --scheme <name>] [--device <sim>] [--out <dir>] [--clean]
|
|
137
184
|
# full workflow: start the app, drive each screen by a launch handle, snap store-ready PNGs
|
|
138
185
|
zdymak capture --platform ios|android --name <screen> # single snapshot of the booted device
|
|
139
186
|
zdymak help
|
|
140
187
|
|
|
141
188
|
Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).
|
|
189
|
+
--clean: wipe the output folder first (capture clears stale PNGs but keeps the .dd build cache) — so the
|
|
190
|
+
folder ends up holding ONLY this run's assets, never a stale screenshot from a removed target/scene.
|
|
142
191
|
README.md documents the config (brand, scenes, targets, theme, music, devices); SKILL.md is for agents.`);
|
|
143
192
|
}
|
|
144
193
|
|
|
@@ -149,6 +198,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
149
198
|
switch (cmd) {
|
|
150
199
|
case 'build': await cmdBuild(flags); break;
|
|
151
200
|
case 'video': await cmdVideo(flags); break;
|
|
201
|
+
case 'reel': await cmdReel(flags); break;
|
|
152
202
|
case 'screenshots': await cmdScreenshots(flags); break;
|
|
153
203
|
case 'specs': cmdSpecs(); break;
|
|
154
204
|
case 'capture': await runCapture(flags, rest); break;
|
package/src/config.mjs
CHANGED
|
@@ -23,6 +23,14 @@ const DEFAULT_BRAND = {
|
|
|
23
23
|
name: 'App', tagline: '', endline: '', endsub: '', logo: null, reel: {},
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/** Every top-level config field an app author can set. The doc-sync guard (`scripts/check-docs.mjs`)
|
|
27
|
+
* asserts each appears in README.md/SKILL.md, so adding a field forces a doc line. Keep in sync with the
|
|
28
|
+
* `raw.*` reads below. */
|
|
29
|
+
export const CONFIG_KEYS = [
|
|
30
|
+
'brand', 'screenshotsDir', 'suffix', 'scenes', 'targets', 'sceneDur', 'xfade',
|
|
31
|
+
'timing', 'theme', 'stillTheme', 'music', 'devices', 'reel', 'out',
|
|
32
|
+
];
|
|
33
|
+
|
|
26
34
|
/** Map raw scenes → resolved scenes with absolute image paths (`${dir}/${id}${suffix}.png` or explicit). */
|
|
27
35
|
function resolveScenes(rawScenes, baseDir, dir, suffix) {
|
|
28
36
|
return (rawScenes || []).map((s, i) => {
|
|
@@ -56,9 +64,9 @@ export async function loadConfig(configPath) {
|
|
|
56
64
|
const screenshotsDir = raw.screenshotsDir ? path.resolve(baseDir, raw.screenshotsDir) : baseDir;
|
|
57
65
|
const suffix = raw.suffix ?? '';
|
|
58
66
|
|
|
59
|
-
// Top-level scenes are required UNLESS a `devices` map
|
|
60
|
-
if (!raw.devices && (!Array.isArray(raw.scenes) || raw.scenes.length === 0)) {
|
|
61
|
-
throw new Error('Config error: `scenes
|
|
67
|
+
// Top-level scenes are required UNLESS a `devices` map or a live-footage `reel` supplies the content.
|
|
68
|
+
if (!raw.devices && !raw.reel && (!Array.isArray(raw.scenes) || raw.scenes.length === 0)) {
|
|
69
|
+
throw new Error('Config error: provide `scenes`, a `devices` map, or a `reel` block.');
|
|
62
70
|
}
|
|
63
71
|
const scenes = resolveScenes(raw.scenes, baseDir, screenshotsDir, suffix);
|
|
64
72
|
|
|
@@ -79,8 +87,25 @@ export async function loadConfig(configPath) {
|
|
|
79
87
|
};
|
|
80
88
|
});
|
|
81
89
|
|
|
90
|
+
// Live-footage reel: resolve each segment's clip/image(s) + the music bed relative to the config file.
|
|
91
|
+
const reel = raw.reel
|
|
92
|
+
? {
|
|
93
|
+
...raw.reel,
|
|
94
|
+
music: raw.reel.music?.path
|
|
95
|
+
? { ...raw.reel.music, path: path.resolve(baseDir, raw.reel.music.path) }
|
|
96
|
+
: undefined,
|
|
97
|
+
segments: (raw.reel.segments || []).map((s) => ({
|
|
98
|
+
...s,
|
|
99
|
+
clip: s.clip ? path.resolve(baseDir, s.clip) : undefined,
|
|
100
|
+
image: s.image ? path.resolve(baseDir, s.image) : undefined,
|
|
101
|
+
images: s.images ? s.images.map((p) => path.resolve(baseDir, p)) : undefined,
|
|
102
|
+
})),
|
|
103
|
+
}
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
82
106
|
return {
|
|
83
107
|
brand,
|
|
108
|
+
reel,
|
|
84
109
|
scenes,
|
|
85
110
|
devices,
|
|
86
111
|
music,
|
|
@@ -88,7 +113,8 @@ export async function loadConfig(configPath) {
|
|
|
88
113
|
sceneDur: raw.sceneDur ?? 3.1,
|
|
89
114
|
xfade: raw.xfade ?? 0.32,
|
|
90
115
|
timing: raw.timing, // reel-mode timeline override { coldOpen, scene, endCard, xfade }
|
|
91
|
-
theme: raw.theme, // premium-technique styling override (matte, vignette, label, cuts)
|
|
116
|
+
theme: raw.theme, // premium-technique styling override for VIDEOS (matte, vignette, label, cuts)
|
|
117
|
+
stillTheme: raw.stillTheme, // screenshot-only matte override; falls back to `theme` when unset
|
|
92
118
|
out: path.resolve(baseDir, raw.out || 'store-assets'),
|
|
93
119
|
baseDir,
|
|
94
120
|
};
|
package/src/montage.mjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE-FOOTAGE reel (montage) — uspamin's ad-video technique, generalized and made source-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Composite driven video CLIPS (real motion) or IMAGE sequences on a clean matte — floating rounded screen
|
|
5
|
+
* + soft shadow, a headline caption above it, and a short cross-DISSOLVE between beats (Apple App-Preview
|
|
6
|
+
* style; `transition:'cut'` for beat-matched hard cuts). The matte defaults to LIGHT (consistent with the
|
|
7
|
+
* light store screenshots), and STILLS get a slow never-freezing push-in (a frozen still reads as dead).
|
|
8
|
+
* Unlike the premium/social reels — which Ken-Burns a single screenshot — this shows real motion + premium
|
|
9
|
+
* framing. Optional ducked music.
|
|
10
|
+
*
|
|
11
|
+
* Source-agnostic (mirrors zdymak's two screenshot modes): each segment's `clip` / `image` / `images` can be
|
|
12
|
+
* BROUGHT by the user or CAPTURED by `zdymak capture --record`.
|
|
13
|
+
*
|
|
14
|
+
* Config (a `reel` block; `sceneDur`/`bpm`/`beatsPerCut` set the beat-matched hold):
|
|
15
|
+
* reel: {
|
|
16
|
+
* bpm: 120, beatsPerCut: 4, // hold = beatsPerCut × 60/bpm seconds per segment
|
|
17
|
+
* transition: 'dissolve' | 'cut', xfadeDur: 0.3,
|
|
18
|
+
* theme: { ... }, // matte override; defaults LIGHT + caption on top
|
|
19
|
+
* music: { path, volume, fadeIn, fadeOut, offset },
|
|
20
|
+
* segments: [
|
|
21
|
+
* { clip: './rec/study.mov', caption: { title, sub } }, // real motion (a recording)
|
|
22
|
+
* { images: ['a.png','b.png'], caption: { title, sub } }, // multiple photos / page
|
|
23
|
+
* { image: './shots/welcome.png', caption: { title, sub } }, // one still (gets a slow push-in)
|
|
24
|
+
* ],
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
32
|
+
import { font, hexA, roundRectPath, fillVerticalGradient, radialGlow, wrapLines } from './canvas.mjs';
|
|
33
|
+
|
|
34
|
+
// The reel matte defaults to LIGHT — consistent with the light store screenshots (a dark matte behind
|
|
35
|
+
// light-theme screens reads as inconsistent). Apple's app marketing favours clean, minimal, untextured
|
|
36
|
+
// backgrounds; the screen floats via a soft SHADOW, not a heavy vignette. Override any key via `reel.theme`.
|
|
37
|
+
const REEL_LIGHT_DEFAULT = {
|
|
38
|
+
bgTop: '#eef2ef', // soft, faintly cool light
|
|
39
|
+
bgBottom: '#ffffff',
|
|
40
|
+
glow: null, // → brand.sub, kept very subtle on light
|
|
41
|
+
glowAlpha: 0.05,
|
|
42
|
+
vignette: 0, // no filter-y vignette on a light matte
|
|
43
|
+
inset: 0.8,
|
|
44
|
+
radius: 0.05,
|
|
45
|
+
shadow: 0.24, // the float cue on light
|
|
46
|
+
frame: 'phone', // iPhone bezel around the screen (like the framed screenshots); false = bare rounded screen
|
|
47
|
+
label: false, // plain caption text, no pill (matches the light screenshots)
|
|
48
|
+
pillFill: null,
|
|
49
|
+
pillOpacity: 0.9,
|
|
50
|
+
labelColor: '#0b0b0a', // near-black headline
|
|
51
|
+
subColor: '#52606d', // muted slate subhead
|
|
52
|
+
captionAnchor: 'top', // headline ON TOP, matching the store screenshots
|
|
53
|
+
};
|
|
54
|
+
function resolveReelTheme(brand, theme) {
|
|
55
|
+
const th = { ...REEL_LIGHT_DEFAULT, ...(theme || {}) };
|
|
56
|
+
th.glow = th.glow || brand.sub;
|
|
57
|
+
th.pillFill = th.pillFill || brand.ink;
|
|
58
|
+
return th;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FF = process.env.FFMPEG || 'ffmpeg';
|
|
62
|
+
const FFPROBE = process.env.FFPROBE || 'ffprobe';
|
|
63
|
+
|
|
64
|
+
function ff(args) {
|
|
65
|
+
const r = spawnSync(FF, ['-y', '-loglevel', 'error', ...args], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
66
|
+
if (r.status !== 0) throw new Error(`ffmpeg failed (exit ${r.status})`);
|
|
67
|
+
}
|
|
68
|
+
function probe(file, entries) {
|
|
69
|
+
const r = spawnSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0',
|
|
70
|
+
'-show_entries', entries, '-of', 'default=nw=1:nk=1', file], { encoding: 'utf8' });
|
|
71
|
+
return String(r.stdout || '').trim().split('\n');
|
|
72
|
+
}
|
|
73
|
+
const isVideo = (p) => /\.(mov|mp4|m4v|webm|mkv)$/i.test(p);
|
|
74
|
+
|
|
75
|
+
/** Contain-fit (w,h) into the inset box of the frame, preserving the source aspect. */
|
|
76
|
+
function fit(srcW, srcH, boxW, boxH) {
|
|
77
|
+
const a = srcH / srcW;
|
|
78
|
+
let w = boxW;
|
|
79
|
+
let h = Math.round(boxW * a);
|
|
80
|
+
if (h > boxH) {
|
|
81
|
+
h = boxH;
|
|
82
|
+
w = Math.round(boxH / a);
|
|
83
|
+
}
|
|
84
|
+
return [w % 2 ? w + 1 : w, h % 2 ? h + 1 : h];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Vertical layout: reserve a BAND for the caption (top or bottom); the screen floats in the remaining
|
|
88
|
+
* area, so the caption never sits on the app UI. No caption → a thin symmetric matte all round. */
|
|
89
|
+
function layout(W, H, hasCaption, inset, top) {
|
|
90
|
+
const capBand = Math.round(H * (hasCaption ? 0.2 : 0.06));
|
|
91
|
+
const otherPad = Math.round(H * 0.06);
|
|
92
|
+
const availTop = top ? capBand : otherPad;
|
|
93
|
+
const availH = H - capBand - otherPad;
|
|
94
|
+
const boxW = Math.round(W * inset);
|
|
95
|
+
const capCenterY = top ? Math.round(H * 0.09) : H - Math.round(capBand * 0.56);
|
|
96
|
+
return { availTop, availH, boxW, capCenterY };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Canvas furniture (rendered once / per segment, written as PNGs for ffmpeg overlay) ───────────────────
|
|
100
|
+
function writePng(canvas, file) {
|
|
101
|
+
fs.writeFileSync(file, canvas.toBuffer('image/png'));
|
|
102
|
+
}
|
|
103
|
+
function mattePng(W, H, th) {
|
|
104
|
+
const c = createCanvas(W, H);
|
|
105
|
+
const ctx = c.getContext('2d');
|
|
106
|
+
fillVerticalGradient(ctx, W, H, th.bgTop, th.bgBottom);
|
|
107
|
+
radialGlow(ctx, W * 0.5, H * 0.4, W * 0.85, th.glow, th.glowAlpha);
|
|
108
|
+
return c;
|
|
109
|
+
}
|
|
110
|
+
function vignettePng(W, H, strength) {
|
|
111
|
+
const c = createCanvas(W, H);
|
|
112
|
+
if (strength > 0) {
|
|
113
|
+
const ctx = c.getContext('2d');
|
|
114
|
+
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.32, W / 2, H / 2, Math.max(W, H) * 0.72);
|
|
115
|
+
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
116
|
+
g.addColorStop(1, `rgba(0,0,0,${strength})`);
|
|
117
|
+
ctx.fillStyle = g;
|
|
118
|
+
ctx.fillRect(0, 0, W, H);
|
|
119
|
+
}
|
|
120
|
+
return c;
|
|
121
|
+
}
|
|
122
|
+
/** Opaque grayscale mask (white rounded-rect on black) — its luma becomes the screen's alpha via
|
|
123
|
+
* `alphamerge`, rounding the corners. Must be OPAQUE (not transparent) so ffmpeg reads a clean luma. */
|
|
124
|
+
function maskPng(w, h, radius) {
|
|
125
|
+
const c = createCanvas(w, h);
|
|
126
|
+
const ctx = c.getContext('2d');
|
|
127
|
+
ctx.fillStyle = '#000';
|
|
128
|
+
ctx.fillRect(0, 0, w, h);
|
|
129
|
+
roundRectPath(ctx, 0, 0, w, h, radius);
|
|
130
|
+
ctx.fillStyle = '#fff';
|
|
131
|
+
ctx.fill();
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
/** A soft drop-shadow for the floating screen, at its placed rect, on a transparent full frame. */
|
|
135
|
+
function shadowPng(W, H, x, y, w, h, radius, alpha) {
|
|
136
|
+
const c = createCanvas(W, H);
|
|
137
|
+
const ctx = c.getContext('2d');
|
|
138
|
+
ctx.save();
|
|
139
|
+
ctx.shadowColor = `rgba(0,0,0,${alpha})`;
|
|
140
|
+
ctx.shadowBlur = Math.round(W * 0.045);
|
|
141
|
+
ctx.shadowOffsetY = Math.round(W * 0.012);
|
|
142
|
+
roundRectPath(ctx, x, y, w, h, radius);
|
|
143
|
+
ctx.fillStyle = '#000';
|
|
144
|
+
ctx.fill();
|
|
145
|
+
ctx.restore();
|
|
146
|
+
return c;
|
|
147
|
+
}
|
|
148
|
+
/** iPhone frame BACK plate: drop shadow + black unibody at the body rect (its screen area is then covered
|
|
149
|
+
* by the clip). Proportions mirror `frames.mjs` drawPhoneFrame so the reel matches the framed screenshots. */
|
|
150
|
+
function frameBackPng(W, H, bx, by, bw, bh, bodyRadius, screenW) {
|
|
151
|
+
const c = createCanvas(W, H);
|
|
152
|
+
const ctx = c.getContext('2d');
|
|
153
|
+
ctx.save();
|
|
154
|
+
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
|
155
|
+
ctx.shadowBlur = Math.round(screenW * 0.09);
|
|
156
|
+
ctx.shadowOffsetY = Math.round(screenW * 0.035);
|
|
157
|
+
roundRectPath(ctx, bx, by, bw, bh, bodyRadius);
|
|
158
|
+
ctx.fillStyle = '#0b0b0a';
|
|
159
|
+
ctx.fill();
|
|
160
|
+
ctx.restore();
|
|
161
|
+
return c;
|
|
162
|
+
}
|
|
163
|
+
/** iPhone Dynamic Island, drawn OVER the clip (front plate). */
|
|
164
|
+
function frameFrontPng(W, H, sx, sy, screenW, bezel) {
|
|
165
|
+
const c = createCanvas(W, H);
|
|
166
|
+
const ctx = c.getContext('2d');
|
|
167
|
+
const iw = Math.round(screenW * 0.3);
|
|
168
|
+
const ih = Math.round(screenW * 0.085);
|
|
169
|
+
roundRectPath(ctx, Math.round(sx + (screenW - iw) / 2), Math.round(sy + bezel * 1.1), iw, ih, ih / 2);
|
|
170
|
+
ctx.fillStyle = '#050505';
|
|
171
|
+
ctx.fill();
|
|
172
|
+
return c;
|
|
173
|
+
}
|
|
174
|
+
/** A fully transparent full frame — the front plate when there's no device frame (keeps input indices stable). */
|
|
175
|
+
function transparentPng(W, H) {
|
|
176
|
+
return createCanvas(W, H);
|
|
177
|
+
}
|
|
178
|
+
/** Bottom caption pill + subtitle (Apple launch-montage label) on a transparent full frame. */
|
|
179
|
+
function captionPng(W, H, caption, th, capCenterY) {
|
|
180
|
+
const c = createCanvas(W, H);
|
|
181
|
+
if (!caption || (!caption.title && !caption.sub)) return c;
|
|
182
|
+
const ctx = c.getContext('2d');
|
|
183
|
+
ctx.textAlign = 'center';
|
|
184
|
+
ctx.textBaseline = 'middle';
|
|
185
|
+
const titleSize = Math.round(Math.min(W, H * 0.9) * (th.headlineScale ?? 0.062)); // bolder headline (top listings go big)
|
|
186
|
+
const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
|
|
187
|
+
const cx = W / 2;
|
|
188
|
+
let y = capCenterY;
|
|
189
|
+
if (caption.title && th.label) {
|
|
190
|
+
// pill treatment (dark bed) — only when `label` is on; on a light matte it'd be dark-on-dark
|
|
191
|
+
ctx.font = font(titleSize, 'bold');
|
|
192
|
+
const tw = ctx.measureText(caption.title).width;
|
|
193
|
+
const padX = Math.round(titleSize * 0.7);
|
|
194
|
+
const padY = Math.round(titleSize * 0.42);
|
|
195
|
+
const pillW = tw + padX * 2;
|
|
196
|
+
const pillH = titleSize + padY * 2;
|
|
197
|
+
roundRectPath(ctx, cx - pillW / 2, y - pillH / 2, pillW, pillH, pillH / 2);
|
|
198
|
+
ctx.fillStyle = hexA(th.pillFill, th.pillOpacity);
|
|
199
|
+
ctx.fill();
|
|
200
|
+
ctx.fillStyle = th.labelColor;
|
|
201
|
+
ctx.fillText(caption.title, cx, y + 1);
|
|
202
|
+
y += pillH / 2 + subSize * 1.15;
|
|
203
|
+
} else if (caption.title) {
|
|
204
|
+
// plain bold headline (matches the light store screenshots)
|
|
205
|
+
ctx.font = font(titleSize, 'bold');
|
|
206
|
+
ctx.fillStyle = th.labelColor;
|
|
207
|
+
ctx.fillText(caption.title, cx, y);
|
|
208
|
+
y += titleSize * 0.62 + subSize * 1.15;
|
|
209
|
+
}
|
|
210
|
+
if (caption.sub) {
|
|
211
|
+
ctx.font = font(subSize, 'regular');
|
|
212
|
+
ctx.fillStyle = th.subColor;
|
|
213
|
+
for (const ln of wrapLines(ctx, caption.sub, W * 0.82)) {
|
|
214
|
+
ctx.fillText(ln, cx, y);
|
|
215
|
+
y += subSize * 1.3;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return c;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Per-segment composite: one source (clip or still) floating on the matte, captioned ───────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Composite ONE source (video clip or still image) onto the matte for `dur` seconds → an intermediate mp4.
|
|
224
|
+
* A video plays its real motion (held on the last frame if shorter than `dur`); a still holds.
|
|
225
|
+
*/
|
|
226
|
+
function compositeSource({ src, dur, W, H, fps, th, tmp, idx, sub, lay, mattePath, vignettePath, captionPath }) {
|
|
227
|
+
const vid = isVideo(src);
|
|
228
|
+
const [sw, sh] = probe(src, 'stream=width,height').map(Number); // ffprobe reads video + image dims alike
|
|
229
|
+
// `bleed`: the source FILLS the whole frame (no matte / frame / shadow) — for a compliant App Store App
|
|
230
|
+
// Preview (real footage, full-bleed, no device frame). Otherwise: framed device, or a plain floating screen.
|
|
231
|
+
const bleed = !!th.bleed;
|
|
232
|
+
const framed = !bleed && th.frame && th.frame !== 'none' && th.frame !== false;
|
|
233
|
+
|
|
234
|
+
// Geometry. Framed: fit the SCREEN so the phone BODY (screen + 2×bezel ≈ 6.4%) still fits the box.
|
|
235
|
+
const [fw, fh] = bleed
|
|
236
|
+
? [W, H]
|
|
237
|
+
: framed
|
|
238
|
+
? fit(sw, sh, Math.round(lay.boxW / 1.064), Math.round(lay.availH / 1.064))
|
|
239
|
+
: fit(sw, sh, lay.boxW, lay.availH);
|
|
240
|
+
const bezel = framed ? Math.round(fw * 0.032) : 0;
|
|
241
|
+
const bodyW = fw + bezel * 2;
|
|
242
|
+
const bodyH = fh + bezel * 2;
|
|
243
|
+
const bodyX = bleed ? 0 : Math.round((W - bodyW) / 2);
|
|
244
|
+
const bodyY = bleed ? 0 : Math.round(lay.availTop + (lay.availH - bodyH) / 2);
|
|
245
|
+
const sx = bleed ? 0 : bodyX + bezel; // the screen (clip) top-left
|
|
246
|
+
const sy = bleed ? 0 : bodyY + bezel;
|
|
247
|
+
const screenRadius = bleed ? 0 : framed ? Math.round(fw * 0.135) : Math.round(W * th.radius);
|
|
248
|
+
|
|
249
|
+
const tag = `${idx}${sub != null ? `_${sub}` : ''}`;
|
|
250
|
+
const maskP = path.join(tmp, `mask-${tag}.png`);
|
|
251
|
+
const backP = path.join(tmp, `back-${tag}.png`); // shadow + phone body (framed) OR a plain drop shadow
|
|
252
|
+
const frontP = path.join(tmp, `front-${tag}.png`); // Dynamic Island (framed) OR transparent
|
|
253
|
+
writePng(maskPng(fw, fh, screenRadius), maskP);
|
|
254
|
+
if (framed) {
|
|
255
|
+
writePng(frameBackPng(W, H, bodyX, bodyY, bodyW, bodyH, Math.round(fw * 0.155), fw), backP);
|
|
256
|
+
writePng(frameFrontPng(W, H, sx, sy, fw, bezel), frontP);
|
|
257
|
+
} else if (bleed) {
|
|
258
|
+
writePng(transparentPng(W, H), backP); // no shadow — the source fills the frame
|
|
259
|
+
writePng(transparentPng(W, H), frontP);
|
|
260
|
+
} else {
|
|
261
|
+
writePng(shadowPng(W, H, sx, sy, fw, fh, screenRadius, th.shadow), backP);
|
|
262
|
+
writePng(transparentPng(W, H), frontP);
|
|
263
|
+
}
|
|
264
|
+
const out = path.join(tmp, `seg-${tag}.mp4`);
|
|
265
|
+
|
|
266
|
+
// A video plays its own motion; a STILL gets a slow, never-freezing push-in (a frozen still reads as
|
|
267
|
+
// dead — the Ken-Burns trap Apple avoids by keeping the camera always subtly moving).
|
|
268
|
+
const frames = Math.ceil(dur * fps) + 4;
|
|
269
|
+
const screenFilter = bleed
|
|
270
|
+
? `[1:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1,format=rgba[s]` // cover
|
|
271
|
+
: vid
|
|
272
|
+
? `[1:v]scale=${fw}:${fh},setsar=1,format=rgba[s]`
|
|
273
|
+
: `[1:v]scale=${Math.round(fw * 1.14)}:${Math.round(fh * 1.14)},zoompan=z='min(zoom+0.0008,1.12)':`
|
|
274
|
+
+ `d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${fw}x${fh}:fps=${fps},setsar=1,format=rgba[s]`;
|
|
275
|
+
|
|
276
|
+
// Inputs: 0 matte · 1 src · 2 mask · 3 back(shadow+body) · 4 front(island) · 5 caption · 6 vignette
|
|
277
|
+
const srcIn = vid ? ['-i', src] : ['-loop', '1', '-i', src];
|
|
278
|
+
const args = [
|
|
279
|
+
'-loop', '1', '-i', mattePath,
|
|
280
|
+
...srcIn,
|
|
281
|
+
'-loop', '1', '-i', maskP,
|
|
282
|
+
'-loop', '1', '-i', backP,
|
|
283
|
+
'-loop', '1', '-i', frontP,
|
|
284
|
+
'-loop', '1', '-i', captionPath,
|
|
285
|
+
'-loop', '1', '-i', vignettePath,
|
|
286
|
+
'-filter_complex',
|
|
287
|
+
[
|
|
288
|
+
screenFilter,
|
|
289
|
+
`[s][2:v]alphamerge[scr]`, // round the screen's corners to the phone-screen radius
|
|
290
|
+
`[0:v]scale=${W}:${H},setsar=1[bg]`,
|
|
291
|
+
`[bg][3:v]overlay=0:0[b0]`, // shadow + phone body (framed) / plain shadow
|
|
292
|
+
// the floating screen plays here; eof_action=repeat holds the last frame if the clip is shorter
|
|
293
|
+
`[b0][scr]overlay=${sx}:${sy}:eof_action=repeat[b1]`,
|
|
294
|
+
`[b1][4:v]overlay=0:0[b2]`, // Dynamic Island over the screen (transparent if unframed)
|
|
295
|
+
`[b2][5:v]overlay=0:0[b3]`, // caption
|
|
296
|
+
`[b3][6:v]overlay=0:0,` + // vignette
|
|
297
|
+
`trim=0:${dur.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps},format=yuv420p[v]`,
|
|
298
|
+
].join(';'),
|
|
299
|
+
'-map', '[v]', '-t', dur.toFixed(3),
|
|
300
|
+
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', out,
|
|
301
|
+
];
|
|
302
|
+
ff(args);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Concat several already-composited sub-clips (used for an `images` segment) into one segment clip. */
|
|
307
|
+
function concatClips(clips, tmp, idx) {
|
|
308
|
+
if (clips.length === 1) return clips[0];
|
|
309
|
+
const listFile = path.join(tmp, `list-${idx}.txt`);
|
|
310
|
+
fs.writeFileSync(listFile, clips.map((c) => `file '${c}'`).join('\n'));
|
|
311
|
+
const out = path.join(tmp, `seg-${idx}-joined.mp4`);
|
|
312
|
+
ff(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', out]);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build one live-footage reel. `segments[]` each carry a `clip` | `image` | `images` source + `caption` +
|
|
318
|
+
* optional `palette`. Hard cuts between same-palette neighbours, a short dissolve at a palette change.
|
|
319
|
+
*/
|
|
320
|
+
export async function buildMontage({ segments, brand, theme, spec, music, sceneDur, bpm, beatsPerCut, transition, xfadeDur, outFile }) {
|
|
321
|
+
const th = resolveReelTheme(brand, theme);
|
|
322
|
+
const W = spec.w;
|
|
323
|
+
const H = spec.h;
|
|
324
|
+
const fps = spec.fps || 30;
|
|
325
|
+
const hold = sceneDur || (bpm ? (beatsPerCut || 4) * 60 / bpm : 3.0);
|
|
326
|
+
// Dissolve by default (Apple App-Preview style — restrained, never a jarring hard cut); `transition:'cut'`
|
|
327
|
+
// gives beat-matched hard cuts. `tail` extends each segment so the xfade always has overlap material.
|
|
328
|
+
const D = transition === 'cut' ? 0 : (xfadeDur ?? 0.3);
|
|
329
|
+
const tail = D > 0 ? D + 0.2 : 0;
|
|
330
|
+
|
|
331
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zdymak-montage-'));
|
|
332
|
+
try {
|
|
333
|
+
const mattePath = path.join(tmp, 'matte.png');
|
|
334
|
+
const vignettePath = path.join(tmp, 'vignette.png');
|
|
335
|
+
writePng(mattePng(W, H, th), mattePath);
|
|
336
|
+
writePng(vignettePng(W, H, th.vignette), vignettePath);
|
|
337
|
+
|
|
338
|
+
// 1) composite every segment (a clip, one image, or an image sequence) → an intermediate clip.
|
|
339
|
+
const segClips = [];
|
|
340
|
+
segments.forEach((seg, i) => {
|
|
341
|
+
const hasCap = !!(seg.caption && (seg.caption.title || seg.caption.sub));
|
|
342
|
+
const lay = layout(W, H, hasCap, th.inset, th.captionAnchor === 'top');
|
|
343
|
+
const captionPath = path.join(tmp, `cap-${i}.png`);
|
|
344
|
+
writePng(captionPng(W, H, seg.caption, th, lay.capCenterY), captionPath);
|
|
345
|
+
const sources = seg.images || (seg.clip ? [seg.clip] : seg.image ? [seg.image] : []);
|
|
346
|
+
if (!sources.length) throw new Error(`reel segment[${i}] needs a clip, image, or images`);
|
|
347
|
+
const per = hold / sources.length;
|
|
348
|
+
const subs = sources.map((src, j) => compositeSource({
|
|
349
|
+
// the LAST sub of the segment carries the dissolve tail so the segment has xfade overlap material
|
|
350
|
+
src, dur: per + (j === sources.length - 1 ? tail : 0), W, H, fps, th, tmp,
|
|
351
|
+
idx: i, sub: sources.length > 1 ? j : null, lay, mattePath, vignettePath, captionPath,
|
|
352
|
+
}));
|
|
353
|
+
segClips.push({ file: concatClips(subs, tmp, i), palette: seg.palette ?? null });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// 2) assemble. NORMALIZE every segment first (fps / timebase / format / SAR) so VFR `recordVideo`
|
|
357
|
+
// clips and image stills mix cleanly. All-hard-cut reels CONCAT end-to-end (robust — no fragile
|
|
358
|
+
// near-zero-margin xfade offsets); a palette change uses an xfade dissolve on those boundaries.
|
|
359
|
+
const inputs = [];
|
|
360
|
+
segClips.forEach((s) => inputs.push('-i', s.file));
|
|
361
|
+
|
|
362
|
+
let filter = '';
|
|
363
|
+
segClips.forEach((_, i) => {
|
|
364
|
+
filter += `[${i}:v]fps=${fps},format=yuv420p,setsar=1,settb=AVTB,setpts=PTS-STARTPTS[n${i}];`;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
let totalDur;
|
|
368
|
+
if (segClips.length === 1) {
|
|
369
|
+
filter += '[n0]copy[vout];';
|
|
370
|
+
totalDur = hold;
|
|
371
|
+
} else if (D === 0) {
|
|
372
|
+
// transition:'cut' → robust end-to-end concat (beat-matched hard cuts)
|
|
373
|
+
filter += `${segClips.map((_, i) => `[n${i}]`).join('')}concat=n=${segClips.length}:v=1[vout];`;
|
|
374
|
+
totalDur = segClips.length * hold;
|
|
375
|
+
} else {
|
|
376
|
+
// dissolve → xfade chain; each shows solo for `hold`, then a D-second cross-dissolve into the next
|
|
377
|
+
let cur = 'n0';
|
|
378
|
+
let offset = 0;
|
|
379
|
+
for (let i = 1; i < segClips.length; i++) {
|
|
380
|
+
offset += hold;
|
|
381
|
+
const out = i === segClips.length - 1 ? 'vout' : `x${i}`;
|
|
382
|
+
filter += `[${cur}][n${i}]xfade=transition=fade:duration=${D.toFixed(3)}:offset=${offset.toFixed(3)}[${out}];`;
|
|
383
|
+
cur = out;
|
|
384
|
+
}
|
|
385
|
+
totalDur = segClips.length * hold + tail;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 3) optional music bed (trim/loop to length, fade, volume).
|
|
389
|
+
const audioArgs = [];
|
|
390
|
+
let map = ['-map', '[vout]'];
|
|
391
|
+
if (music?.path) {
|
|
392
|
+
inputs.push('-stream_loop', '-1', '-i', music.path);
|
|
393
|
+
const ai = segClips.length;
|
|
394
|
+
const fadeOut = music.fadeOut ?? 0.6;
|
|
395
|
+
const a = [
|
|
396
|
+
`[${ai}:a]atrim=${(music.offset || 0).toFixed(3)}:${(( music.offset || 0) + totalDur).toFixed(3)},asetpts=PTS-STARTPTS`,
|
|
397
|
+
`volume=${music.volume ?? 0.9}`,
|
|
398
|
+
`afade=t=in:st=0:d=${(music.fadeIn ?? 0.6)}`,
|
|
399
|
+
`afade=t=out:st=${Math.max(0, totalDur - fadeOut).toFixed(3)}:d=${fadeOut}[aout]`,
|
|
400
|
+
].join(',');
|
|
401
|
+
filter += a;
|
|
402
|
+
map = ['-map', '[vout]', '-map', '[aout]'];
|
|
403
|
+
audioArgs.push('-c:a', 'aac', '-b:a', '192k', '-shortest');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
ff([
|
|
407
|
+
...inputs,
|
|
408
|
+
'-filter_complex', filter.replace(/;$/, ''),
|
|
409
|
+
...map,
|
|
410
|
+
'-c:v', 'libx264', '-profile:v', spec.profile || 'high', '-level:v', spec.level || '4.0',
|
|
411
|
+
'-pix_fmt', 'yuv420p', '-crf', '17', '-preset', 'slow', '-r', String(fps), '-movflags', '+faststart',
|
|
412
|
+
...audioArgs,
|
|
413
|
+
outFile,
|
|
414
|
+
]);
|
|
415
|
+
return { outFile, totalDur, warnings: [] };
|
|
416
|
+
} finally {
|
|
417
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
package/src/premium.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const lerp = (a, b, t) => a + (b - a) * t;
|
|
|
27
27
|
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
|
28
28
|
|
|
29
29
|
/** Defaults — brand-driven where a value is null (resolved in buildPremium against `brand`). */
|
|
30
|
-
const DEFAULT_THEME = {
|
|
30
|
+
export const DEFAULT_THEME = {
|
|
31
31
|
bgTop: '#17161c', // matte gradient top
|
|
32
32
|
bgBottom: '#0b0b0a', // matte gradient bottom
|
|
33
33
|
glow: null, // radial brand glow colour → defaults to brand.sub
|
|
@@ -45,8 +45,20 @@ const DEFAULT_THEME = {
|
|
|
45
45
|
cutHard: 0.05, // dissolve seconds when adjacent screens share a palette (≈ hard cut)
|
|
46
46
|
cutSoft: 0.24, // dissolve seconds at a palette shift
|
|
47
47
|
cutThreshold: 42, // RGB-distance above which a boundary counts as a palette shift
|
|
48
|
+
captionAnchor: 'bottom', // 'top' places the caption above the device (the bright store-shot layout)
|
|
49
|
+
fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
/** The `theme`/`stillTheme` options an app author is expected to set — the doc-sync guard
|
|
53
|
+
* (`scripts/check-docs.mjs`) asserts each appears in README.md/SKILL.md, so adding a public knob forces a
|
|
54
|
+
* doc line. (The rest of DEFAULT_THEME — radius, shadow, the pill and cut knobs — are advanced/internal
|
|
55
|
+
* and not enforced.) */
|
|
56
|
+
export const PUBLIC_THEME_KEYS = [
|
|
57
|
+
'bgTop', 'bgBottom', 'glow', 'glowAlpha', 'vignette', 'inset',
|
|
58
|
+
'label', 'labelColor', 'subColor', 'handle', 'captionAnchor', 'fit',
|
|
59
|
+
'headlineScale', 'frame', 'bleed',
|
|
60
|
+
];
|
|
61
|
+
|
|
50
62
|
/** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
|
|
51
63
|
function avgColor(canvas) {
|
|
52
64
|
const { width: w, height: h } = canvas;
|
|
@@ -107,11 +119,13 @@ function drawLabel(ctx, W, H, caption, th, alpha) {
|
|
|
107
119
|
|
|
108
120
|
// Scale type to the SHORTER edge so landscape (Mac) doesn't get huge text, and anchor high enough that
|
|
109
121
|
// the pill + subtitle never clip the bottom on wide aspects.
|
|
110
|
-
const titleSize = Math.round(Math.min(W, H * 0.9) * 0.
|
|
122
|
+
const titleSize = Math.round(Math.min(W, H * 0.9) * (th.headlineScale ?? 0.062)); // bolder headline (top listings go big)
|
|
111
123
|
const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
|
|
112
124
|
const cx = W / 2;
|
|
113
|
-
//
|
|
114
|
-
|
|
125
|
+
// Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);
|
|
126
|
+
// captionAnchor:'top' puts it above the device — the bright store-shot layout.
|
|
127
|
+
const topCaption = th.captionAnchor === 'top';
|
|
128
|
+
let y = topCaption ? H * 0.085 : (W > H ? 0.7 : 0.82) * H;
|
|
115
129
|
|
|
116
130
|
if (caption.title && th.label) {
|
|
117
131
|
ctx.font = font(titleSize, 'bold');
|
|
@@ -167,12 +181,29 @@ export function resolvePremiumTheme(brand, theme) {
|
|
|
167
181
|
return th;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
/** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
|
|
184
|
+
/** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
|
|
185
|
+
* fit 'cover' fills the inset box (crops overflow); fit 'contain' shows the WHOLE capture — the card
|
|
186
|
+
* shrinks to the capture's aspect and floats with matte margins (correct for a Mac window on the matte,
|
|
187
|
+
* where a cover-crop would slice off the title bar / traffic lights). */
|
|
171
188
|
function screenLayer(W, H, img, th) {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
189
|
+
// Reserve headroom for a top caption so the window floats BELOW it (else it covers the headline).
|
|
190
|
+
const topCaption = th.captionAnchor === 'top';
|
|
191
|
+
const bandTop = topCaption ? H * 0.18 : 0;
|
|
192
|
+
const bandH = (topCaption ? 0.8 : 1) * H;
|
|
193
|
+
const boxW = Math.round(W * th.inset);
|
|
194
|
+
const boxH = Math.round(bandH * th.inset);
|
|
195
|
+
const ia = img.height / img.width;
|
|
196
|
+
let dw = boxW;
|
|
197
|
+
let dh = boxH;
|
|
198
|
+
if (th.fit === 'contain') {
|
|
199
|
+
dh = boxW * ia; // fit width, then clamp height so the whole capture fits the box
|
|
200
|
+
if (dh > boxH) {
|
|
201
|
+
dh = boxH;
|
|
202
|
+
dw = boxH / ia;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const dx = Math.round((W - dw) / 2);
|
|
206
|
+
const dy = Math.round(bandTop + (bandH - dh) / 2);
|
|
176
207
|
const radius = Math.round(W * th.radius);
|
|
177
208
|
const layer = createCanvas(W, H);
|
|
178
209
|
const lctx = layer.getContext('2d');
|
|
@@ -180,14 +211,15 @@ function screenLayer(W, H, img, th) {
|
|
|
180
211
|
lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
|
|
181
212
|
lctx.shadowBlur = Math.round(W * 0.05);
|
|
182
213
|
lctx.shadowOffsetY = Math.round(W * 0.012);
|
|
183
|
-
roundRectPath(lctx,
|
|
214
|
+
roundRectPath(lctx, dx, dy, dw, dh, radius);
|
|
184
215
|
lctx.fillStyle = '#000';
|
|
185
216
|
lctx.fill();
|
|
186
217
|
lctx.restore();
|
|
187
218
|
lctx.save();
|
|
188
|
-
roundRectPath(lctx,
|
|
219
|
+
roundRectPath(lctx, dx, dy, dw, dh, radius);
|
|
189
220
|
lctx.clip();
|
|
190
|
-
|
|
221
|
+
// The card now matches the draw aspect, so cover here fills it exactly (no crop for 'contain').
|
|
222
|
+
lctx.drawImage(coverCanvas(img, dw, dh), dx, dy);
|
|
191
223
|
lctx.restore();
|
|
192
224
|
return layer;
|
|
193
225
|
}
|
|
@@ -196,17 +228,22 @@ function screenLayer(W, H, img, th) {
|
|
|
196
228
|
* drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
|
|
197
229
|
export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
|
|
198
230
|
const th = resolvePremiumTheme(brand, theme);
|
|
231
|
+
// Store-shot smart defaults (each overridable via `theme`): headline sits ON TOP; a frameless window
|
|
232
|
+
// (e.g. Mac) shows the WHOLE capture instead of cropping its title bar.
|
|
233
|
+
if (theme?.captionAnchor === undefined) th.captionAnchor = 'top';
|
|
234
|
+
if (!frame && theme?.fit === undefined) th.fit = 'contain';
|
|
199
235
|
const c = createCanvas(W, H);
|
|
200
236
|
const ctx = c.getContext('2d');
|
|
201
237
|
paintMatte(ctx, W, H, th);
|
|
202
238
|
|
|
239
|
+
const topCaption = th.captionAnchor === 'top';
|
|
203
240
|
const drawFrame = frame ? frameFor(frame) : null;
|
|
204
241
|
if (drawFrame) {
|
|
205
|
-
const cy = H * 0.42; //
|
|
242
|
+
const cy = H * (topCaption ? 0.57 : 0.42); // drop the device when the caption sits on top
|
|
206
243
|
if (frame === 'watch') {
|
|
207
244
|
drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
|
|
208
245
|
} else {
|
|
209
|
-
const budgetH = H * 0.64; // vertical room for the device body
|
|
246
|
+
const budgetH = H * (topCaption ? 0.66 : 0.64); // vertical room for the device body
|
|
210
247
|
const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
|
|
211
248
|
drawFrame(ctx, img, W / 2, cy, screenW);
|
|
212
249
|
}
|
package/src/screenshots.mjs
CHANGED
|
@@ -27,8 +27,10 @@ export async function buildDeviceScreenshots({ device, brand, theme, outDir }) {
|
|
|
27
27
|
const spec = IMAGE_TARGETS[shot.target];
|
|
28
28
|
if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
|
|
29
29
|
const [W, H] = targetSize(spec, shot.size);
|
|
30
|
-
const style = shot.style || 'premium';
|
|
31
30
|
const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
|
|
31
|
+
// Infer the style from the target: a framed device (phone/tablet/watch) → 'framed'; a frameless
|
|
32
|
+
// target (Mac/desktop) → 'premium' (window on the matte). Overridable per shot (e.g. Watch → 'bleed').
|
|
33
|
+
const style = shot.style || (frame ? 'framed' : 'premium');
|
|
32
34
|
|
|
33
35
|
// Feature graphic (Play banner) is a single branded image, not a per-scene shot.
|
|
34
36
|
if (spec.graphic) {
|