zdymak 0.7.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 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
- # from a checkout (until published to npm):
33
- npm i --prefix /path/to/zdymak
34
- npm link --prefix /path/to/zdymak # gives you the `zdymak` command
35
- # …or just call it directly:
36
- node /path/to/zdymak/bin/zdymak.mjs <command>
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` | Premium-technique styling (matte, `vignette`, `inset`, `handle`, cut timings). Brand-driven defaults. |
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, in the same styles (premium / bleed), at each store's exact dimensions, as **no-alpha PNGs**
116
- (App Store & Play reject alpha). Each device points at its own captures; scenes with no matching capture
117
- are **skipped cleanly**, so an app lists only the devices it actually ships:
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', style: 'premium' }] },
122
- ipad: { capturesDir: './shots/ipad', suffix: '', screenshots: [{ target: 'appstore-ipad-13', style: 'premium' }] },
123
- mac: { capturesDir: './shots/mac', suffix: '', screenshots: [{ target: 'appstore-mac', style: 'premium' }] },
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', style: 'framed' }, // Android punch-hole frame
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
- The **`framed`** style picks the right bezel per target — iPhone (Dynamic Island), Android (punch-hole),
136
- iPad/tablet, or Watch ring — and Mac stays plain (its captures are already windowed). **`play-feature-graphic`**
137
- is special: one 1024×500 brand banner (logo + tagline + a tilted hero device), not a per-scene screenshot.
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. 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.
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.7.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
  },
@@ -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
- // Pin Apple's canonical marketing status bar: 9:41, full signal/wifi, FULL battery but NOT charging
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
- spawnSync('xcrun', ['simctl', 'status_bar', udid, 'override', '--time', '9:41',
122
- '--batteryState', 'discharging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
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
- console.log(`▶︎ Driving ${states.length} screens via "${flags.arg} <id>" on ${flags.bundle}…`);
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
- sh('xcrun', ['simctl', 'launch', udid, flags.bundle, flags.arg, st]);
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
- const out = path.join(outDir, `${st}${suffix}.png`);
130
- sh('xcrun', ['simctl', 'io', udid, 'screenshot', out]);
131
- await stripAlpha(out);
132
- console.log(` ✓ ${st}${suffix}.png`);
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
 
@@ -82,6 +124,7 @@ async function cmdScreenshots(flags) {
82
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
 
@@ -107,6 +150,9 @@ async function cmdBuild(flags) {
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 screenshots [--config <path>] [--out <dir>]
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 supplies its own per-device scenes.
60
- if (!raw.devices && (!Array.isArray(raw.scenes) || raw.scenes.length === 0)) {
61
- throw new Error('Config error: `scenes` must be a non-empty array (or use a `devices` map).');
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,
@@ -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
@@ -49,6 +49,16 @@ const DEFAULT_THEME = {
49
49
  fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
50
50
  };
51
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
+
52
62
  /** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
53
63
  function avgColor(canvas) {
54
64
  const { width: w, height: h } = canvas;
@@ -109,7 +119,7 @@ function drawLabel(ctx, W, H, caption, th, alpha) {
109
119
 
110
120
  // Scale type to the SHORTER edge so landscape (Mac) doesn't get huge text, and anchor high enough that
111
121
  // the pill + subtitle never clip the bottom on wide aspects.
112
- const titleSize = Math.round(Math.min(W, H * 0.9) * 0.05);
122
+ const titleSize = Math.round(Math.min(W, H * 0.9) * (th.headlineScale ?? 0.062)); // bolder headline (top listings go big)
113
123
  const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
114
124
  const cx = W / 2;
115
125
  // Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);