zdymak 0.7.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reel.mjs CHANGED
@@ -1,245 +1,366 @@
1
- /**
2
- * The REEL engine — a device-FRAMED, Apple-style "one phone, many apps" promo video for the web, social,
3
- * and (via YouTube) Google Play. This is intentionally NOT an App Store App Preview: it draws an iPhone
4
- * bezel + brand background + logo bookends, which Apple rejects in the App Preview slot. Use `video.mjs`
5
- * (full-bleed) for App Previews and this for marketing reels.
6
- *
7
- * Composition (ported from a production store-asset pipeline): a captioned phone on a soft brand wash,
8
- * gentle alternating Ken-Burns + cross-dissolves, bookended by a logo cold-open and end-card on a dark
9
- * matte. Silent by default (commercial-music licensing is a hard rule).
10
- */
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import { createCanvas, loadImage } from '@napi-rs/canvas';
14
- import { font, hexA, roundRectPath, fillVerticalGradient, radialGlow, drawCaption, measureCaption } from './canvas.mjs';
15
- import { spawnEncoder } from './encode.mjs';
16
-
17
- const DEVICE_ASPECT = 2.165; // iPhone screen height/width (~19.5:9); screenshots are cover-fit into it.
18
-
19
- /** Default reel palette a calm green/stone brand. Override any key via `brand.reel` in the config. */
20
- const DEFAULT_REEL = {
21
- bgTop: '#FAFAF9', bgBottom: '#DCFCE7', glowLight: '#BBF7D0', // soft light wash for the screen scenes
22
- matteTop: '#052E16', matteBottom: '#0b0b0a', glowDark: '#16A34A', // dark matte for cold-open / end-card
23
- titleColor: '#1C1917', subColor: '#78716C', // captions on the light wash
24
- bookendTitle: '#FAFAF9', bookendSub: '#BBF7D0', // text on the dark matte
25
- };
26
-
27
- const DEFAULT_TIMING = { coldOpen: 1.4, scene: 2.6, endCard: 2.4, xfade: 0.45 };
28
-
29
- const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
30
- const smooth = (t) => t * t * (3 - 2 * t);
31
-
32
- function backgroundLight(ctx, W, H, p) {
33
- fillVerticalGradient(ctx, W, H, p.bgTop, p.bgBottom);
34
- radialGlow(ctx, W * 0.5, H * 0.34, W * 0.85, p.glowLight, 0.5);
35
- }
36
- function backgroundDark(ctx, W, H, p) {
37
- fillVerticalGradient(ctx, W, H, p.matteTop, p.matteBottom);
38
- radialGlow(ctx, W * 0.5, H * 0.42, W * 0.8, p.glowDark, 0.28);
39
- }
40
-
41
- /** Draw a clean modern iPhone (dark unibody, thin bezel, centred Dynamic Island) with `screen` cover-fit
42
- * into the display. Geometry scales with `screenW`. */
43
- function drawPhone(ctx, screen, cx, cy, screenW) {
44
- const screenH = screenW * DEVICE_ASPECT;
45
- const bezel = Math.round(screenW * 0.032);
46
- const bodyW = screenW + bezel * 2;
47
- const bodyH = screenH + bezel * 2;
48
- const bodyX = Math.round(cx - bodyW / 2);
49
- const bodyY = Math.round(cy - bodyH / 2);
50
- const screenX = bodyX + bezel;
51
- const screenY = bodyY + bezel;
52
- const bodyR = Math.round(screenW * 0.155);
53
- const screenR = Math.round(screenW * 0.135);
54
-
55
- ctx.save();
56
- ctx.shadowColor = 'rgba(0,0,0,0.38)';
57
- ctx.shadowBlur = Math.round(screenW * 0.09);
58
- ctx.shadowOffsetY = Math.round(screenW * 0.035);
59
- roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, bodyR);
60
- ctx.fillStyle = '#0b0b0a';
61
- ctx.fill();
62
- ctx.restore();
63
-
64
- ctx.save();
65
- roundRectPath(ctx, bodyX + 1, bodyY + 1, bodyW - 2, bodyH - 2, bodyR - 1);
66
- ctx.lineWidth = 2;
67
- ctx.strokeStyle = 'rgba(255,255,255,0.06)';
68
- ctx.stroke();
69
- ctx.restore();
70
-
71
- ctx.save();
72
- roundRectPath(ctx, screenX, screenY, screenW, screenH, screenR);
73
- ctx.clip();
74
- const sAspect = screen.height / screen.width;
75
- let dw = screenW;
76
- let dh = screenW * sAspect;
77
- if (dh < screenH) {
78
- dh = screenH;
79
- dw = screenH / sAspect;
80
- }
81
- ctx.drawImage(screen, screenX + (screenW - dw) / 2, screenY + (screenH - dh) / 2, dw, dh);
82
- ctx.restore();
83
-
84
- // Dynamic Island pill.
85
- const islandW = Math.round(screenW * 0.3);
86
- const islandH = Math.round(screenW * 0.085);
87
- roundRectPath(ctx, Math.round(screenX + (screenW - islandW) / 2), Math.round(screenY + bezel * 1.1), islandW, islandH, islandH / 2);
88
- ctx.fillStyle = '#050505';
89
- ctx.fill();
90
-
91
- return { bodyY, bodyH };
92
- }
93
-
94
- /** Icon + wordmark centred at (cx, y). */
95
- function drawLockup(ctx, logo, cx, y, iconSize, textSize, name, color) {
96
- ctx.font = font(textSize, 'bold');
97
- ctx.textBaseline = 'middle';
98
- ctx.textAlign = 'left';
99
- const gap = Math.round(iconSize * 0.32);
100
- const textW = ctx.measureText(name).width;
101
- const hasIcon = !!logo;
102
- const total = (hasIcon ? iconSize + gap : 0) + textW;
103
- const x = cx - total / 2;
104
- if (hasIcon) {
105
- ctx.save();
106
- roundRectPath(ctx, x, y - iconSize / 2, iconSize, iconSize, iconSize * 0.22);
107
- ctx.clip();
108
- ctx.drawImage(logo, x, y - iconSize / 2, iconSize, iconSize);
109
- ctx.restore();
110
- }
111
- ctx.fillStyle = color;
112
- ctx.fillText(name, x + (hasIcon ? iconSize + gap : 0), y + textSize * 0.04);
113
- }
114
-
115
- /** A captioned phone on the light brand wash (one reel scene). */
116
- function renderScene(W, H, screen, caption, p, S, { zoom = 1 } = {}) {
117
- const c = createCanvas(W, H);
118
- const ctx = c.getContext('2d');
119
- backgroundLight(ctx, W, H, p);
120
-
121
- const bodyH = H * 0.7;
122
- const screenW = (bodyH / (DEVICE_ASPECT + 0.064)) * zoom;
123
- const cy = H * 0.605;
124
- const { bodyY: phoneTop } = drawPhone(ctx, screen, W / 2, cy, screenW);
125
-
126
- const titleSize = Math.round(84 * S);
127
- const subSize = Math.round(45 * S);
128
- const maxWidth = W * 0.84;
129
- const bandTop = H * 0.07;
130
- const bandH = phoneTop - bandTop - 34 * S;
131
- const probe = measureCaption(ctx, { title: caption.title, subtitle: caption.sub, maxWidth, titleSize, subSize });
132
- drawCaption(ctx, {
133
- title: caption.title,
134
- subtitle: caption.sub,
135
- centerX: W / 2,
136
- top: bandTop + Math.max(0, (bandH - probe) / 2),
137
- maxWidth,
138
- titleSize,
139
- subSize,
140
- titleColor: p.titleColor,
141
- subColor: p.subColor,
142
- });
143
- return c;
144
- }
145
-
146
- function renderColdOpen(W, H, logo, brand, p, S) {
147
- const c = createCanvas(W, H);
148
- const ctx = c.getContext('2d');
149
- backgroundDark(ctx, W, H, p);
150
- drawLockup(ctx, logo, W / 2, H * 0.42, Math.round(200 * S), Math.round(138 * S), brand.name, p.bookendTitle);
151
- if (brand.tagline) {
152
- ctx.textAlign = 'center';
153
- ctx.textBaseline = 'top';
154
- ctx.font = font(Math.round(55 * S), 'regular');
155
- ctx.fillStyle = p.bookendSub;
156
- ctx.fillText(brand.tagline, W / 2, H * 0.42 + 156 * S);
157
- }
158
- return c;
159
- }
160
-
161
- function renderEndCard(W, H, logo, brand, p, S) {
162
- const c = createCanvas(W, H);
163
- const ctx = c.getContext('2d');
164
- backgroundDark(ctx, W, H, p);
165
- drawLockup(ctx, logo, W / 2, H * 0.4, Math.round(180 * S), Math.round(124 * S), brand.name, p.bookendTitle);
166
- ctx.textAlign = 'center';
167
- ctx.textBaseline = 'top';
168
- if (brand.endline) {
169
- ctx.font = font(Math.round(60 * S), 'bold');
170
- ctx.fillStyle = p.bookendTitle;
171
- ctx.fillText(brand.endline, W / 2, H * 0.4 + 142 * S);
172
- }
173
- if (brand.endsub) {
174
- ctx.font = font(Math.round(45 * S), 'regular');
175
- ctx.fillStyle = p.bookendSub;
176
- ctx.fillText(brand.endsub, W / 2, H * 0.4 + 235 * S);
177
- }
178
- return c;
179
- }
180
-
181
- /** Build one device-framed reel. Mirrors buildVideo's signature; `spec` carries w/h/fps/profile/level. */
182
- export async function buildReel({ scenes, spec, brand, outFile, timing, music }) {
183
- if (!scenes?.length) throw new Error('buildReel: no scenes');
184
- const { w: W, h: H, fps } = spec;
185
- const S = W / 1320; // scale the ported (1320-wide) type/stroke constants to any width
186
- const p = { ...DEFAULT_REEL, ...(brand.reel || {}) };
187
- const t = { ...DEFAULT_TIMING, ...(timing || {}) };
188
-
189
- const logo = brand.logo && fs.existsSync(brand.logo) ? await loadImage(brand.logo) : null;
190
-
191
- const clips = [{ canvas: renderColdOpen(W, H, logo, brand, p, S), dur: t.coldOpen }];
192
- for (let i = 0; i < scenes.length; i++) {
193
- const s = scenes[i];
194
- if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
195
- const img = await loadImage(s.image);
196
- clips.push({ canvas: renderScene(W, H, img, { title: s.title, sub: s.sub }, p, S), dur: t.scene });
197
- }
198
- clips.push({ canvas: renderEndCard(W, H, logo, brand, p, S), dur: t.endCard });
199
-
200
- const xfade = t.xfade;
201
- const starts = [0];
202
- let acc = 0;
203
- for (let i = 0; i < clips.length - 1; i++) {
204
- acc += clips[i].dur - xfade;
205
- starts.push(acc);
206
- }
207
- const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
208
- const totalFrames = Math.round(totalDur * fps);
209
-
210
- const frame = createCanvas(W, H);
211
- const fctx = frame.getContext('2d');
212
-
213
- fs.mkdirSync(path.dirname(outFile), { recursive: true });
214
- const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
215
-
216
- for (let k = 0; k < totalFrames; k++) {
217
- const t2 = k / fps;
218
- fctx.clearRect(0, 0, W, H);
219
- fctx.fillStyle = p.matteBottom;
220
- fctx.fillRect(0, 0, W, H);
221
- for (let i = 0; i < clips.length; i++) {
222
- const start = starts[i];
223
- const end = start + clips[i].dur;
224
- if (t2 < start || t2 >= end) continue;
225
- const localT = t2 - start;
226
- const prog = clips[i].dur > 0 ? localT / clips[i].dur : 0;
227
- const zoomIn = i % 2 === 0;
228
- const z = zoomIn ? 1.0 + 0.06 * easeInOut(prog) : 1.06 - 0.06 * easeInOut(prog);
229
- let alpha = 1;
230
- if (i > 0 && localT < xfade) alpha = smooth(localT / xfade);
231
- const dw = W * z;
232
- const dh = H * z;
233
- fctx.globalAlpha = alpha;
234
- fctx.drawImage(clips[i].canvas, (W - dw) / 2, (H - dh) / 2, dw, dh);
235
- fctx.globalAlpha = 1;
236
- }
237
- const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
238
- if (!proc.stdin.write(buf)) {
239
- await new Promise((r) => proc.stdin.once('drain', r));
240
- }
241
- }
242
- proc.stdin.end();
243
- await done;
244
- return { outFile, totalDur, frames: totalFrames, warnings: [] };
245
- }
1
+ /**
2
+ * The REEL engine — a device-FRAMED, Apple-style "one phone, many apps" promo video for the web, social,
3
+ * and (via YouTube) Google Play. This is intentionally NOT an App Store App Preview: it draws an iPhone
4
+ * bezel + brand background + logo bookends, which Apple rejects in the App Preview slot. Use `video.mjs`
5
+ * (full-bleed) for App Previews and this for marketing reels.
6
+ *
7
+ * Composition (ported from a production store-asset pipeline): a captioned phone on a soft brand wash,
8
+ * gentle alternating Ken-Burns + cross-dissolves, bookended by a logo cold-open and end-card on a dark
9
+ * matte. Silent by default (commercial-music licensing is a hard rule).
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
14
+ import { loadCapture } from './statusbar.mjs';
15
+ import { font, hexA, roundRectPath, fillVerticalGradient, radialGlow, drawCaption, measureCaption } from './canvas.mjs';
16
+ import { frameFor } from './frames.mjs';
17
+ import { spawnEncoder } from './encode.mjs';
18
+ import { springSeries } from './easings.mjs';
19
+ import { transitionFor } from './transitions.mjs';
20
+ import { effectFor } from './effects.mjs';
21
+
22
+ /**
23
+ * Cuts come from the transition REGISTRY (`transitions.mjs`) — each scene names how we get INTO it via
24
+ * `cut`, defaulting to a hard cut. Adding a new one is an entry in that table, not a change here.
25
+ */
26
+
27
+ const DEVICE_ASPECT = 2.165; // iPhone screen height/width (~19.5:9); screenshots are cover-fit into it.
28
+
29
+ /** Default reel palette a calm green/stone brand. Override any key via `brand.reel` in the config. */
30
+ const DEFAULT_REEL = {
31
+ bgTop: '#FAFAF9', bgBottom: '#DCFCE7', glowLight: '#BBF7D0', // soft light wash for the screen scenes
32
+ matteTop: '#052E16', matteBottom: '#0b0b0a', glowDark: '#16A34A', // dark matte for cold-open / end-card
33
+ titleColor: '#1C1917', subColor: '#78716C', // captions on the light wash
34
+ bookendTitle: '#FAFAF9', bookendSub: '#BBF7D0', // text on the dark matte
35
+ };
36
+
37
+ const DEFAULT_TIMING = { coldOpen: 1.4, scene: 2.6, endCard: 2.4, xfade: 0.45 };
38
+
39
+ /** Scene hold: beat-matched when `bpm` is given (cuts land on the music), else the fixed `scene` value. */
40
+ const sceneHold = (t) => (t.bpm ? ((t.beatsPerCut || 4) * 60) / t.bpm : t.scene);
41
+
42
+ const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
43
+ const smooth = (t) => t * t * (3 - 2 * t);
44
+
45
+ function backgroundLight(ctx, W, H, p) {
46
+ fillVerticalGradient(ctx, W, H, p.bgTop, p.bgBottom);
47
+ // Two glows, not one: a bright key behind the device's shoulders and a wider, weaker fill low and
48
+ // off-centre. A single centred wash is what makes a matte look like blank paper.
49
+ radialGlow(ctx, W * 0.5, H * 0.30, W * 0.78, p.glowLight, 0.62);
50
+ radialGlow(ctx, W * 0.22, H * 0.86, W * 0.9, p.glowLight, 0.3);
51
+ }
52
+ function backgroundDark(ctx, W, H, p) {
53
+ fillVerticalGradient(ctx, W, H, p.matteTop, p.matteBottom);
54
+ radialGlow(ctx, W * 0.5, H * 0.42, W * 0.8, p.glowDark, 0.28);
55
+ }
56
+
57
+ /** Draw a clean modern iPhone (dark unibody, thin bezel, centred Dynamic Island) with `screen` cover-fit
58
+ * into the display. Geometry scales with `screenW`. */
59
+ function drawPhone(ctx, screen, cx, cy, screenW) {
60
+ const screenH = screenW * DEVICE_ASPECT;
61
+ const bezel = Math.round(screenW * 0.032);
62
+ const bodyW = screenW + bezel * 2;
63
+ const bodyH = screenH + bezel * 2;
64
+ const bodyX = Math.round(cx - bodyW / 2);
65
+ const bodyY = Math.round(cy - bodyH / 2);
66
+ const screenX = bodyX + bezel;
67
+ const screenY = bodyY + bezel;
68
+ const bodyR = Math.round(screenW * 0.155);
69
+ const screenR = Math.round(screenW * 0.135);
70
+
71
+ ctx.save();
72
+ ctx.shadowColor = 'rgba(16,40,26,0.30)';
73
+ ctx.shadowBlur = Math.round(screenW * 0.16);
74
+ ctx.shadowOffsetY = Math.round(screenW * 0.06);
75
+ roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, bodyR);
76
+ ctx.fillStyle = '#0b0b0a';
77
+ ctx.fill();
78
+ ctx.restore();
79
+
80
+ ctx.save();
81
+ roundRectPath(ctx, bodyX + 1, bodyY + 1, bodyW - 2, bodyH - 2, bodyR - 1);
82
+ ctx.lineWidth = 2;
83
+ ctx.strokeStyle = 'rgba(255,255,255,0.06)';
84
+ ctx.stroke();
85
+ ctx.restore();
86
+
87
+ ctx.save();
88
+ roundRectPath(ctx, screenX, screenY, screenW, screenH, screenR);
89
+ ctx.clip();
90
+ if (typeof screen === 'function') {
91
+ screen(ctx, screenX, screenY, screenW, screenH);
92
+ } else {
93
+ const sAspect = screen.height / screen.width;
94
+ let dw = screenW;
95
+ let dh = screenW * sAspect;
96
+ if (dh < screenH) {
97
+ dh = screenH;
98
+ dw = screenH / sAspect;
99
+ }
100
+ ctx.drawImage(screen, screenX + (screenW - dw) / 2, screenY + (screenH - dh) / 2, dw, dh);
101
+ }
102
+ ctx.restore();
103
+
104
+ // Dynamic Island pill.
105
+ const islandW = Math.round(screenW * 0.3);
106
+ const islandH = Math.round(screenW * 0.085);
107
+ roundRectPath(ctx, Math.round(screenX + (screenW - islandW) / 2), Math.round(screenY + bezel * 1.1), islandW, islandH, islandH / 2);
108
+ ctx.fillStyle = '#050505';
109
+ ctx.fill();
110
+
111
+ return { bodyY, bodyH, screen: { x: screenX, y: screenY, w: screenW, h: screenH, r: screenR } };
112
+ }
113
+
114
+ /** Icon + wordmark centred at (cx, y). */
115
+ function drawLockup(ctx, logo, cx, y, iconSize, textSize, name, color) {
116
+ ctx.font = font(textSize, 'bold');
117
+ ctx.textBaseline = 'middle';
118
+ ctx.textAlign = 'left';
119
+ const gap = Math.round(iconSize * 0.32);
120
+ const textW = ctx.measureText(name).width;
121
+ const hasIcon = !!logo;
122
+ const total = (hasIcon ? iconSize + gap : 0) + textW;
123
+ const x = cx - total / 2;
124
+ if (hasIcon) {
125
+ ctx.save();
126
+ roundRectPath(ctx, x, y - iconSize / 2, iconSize, iconSize, iconSize * 0.22);
127
+ ctx.clip();
128
+ ctx.drawImage(logo, x, y - iconSize / 2, iconSize, iconSize);
129
+ ctx.restore();
130
+ }
131
+ ctx.fillStyle = color;
132
+ ctx.fillText(name, x + (hasIcon ? iconSize + gap : 0), y + textSize * 0.04);
133
+ }
134
+
135
+ /**
136
+ * Build one scene as SEPARATE LAYERS so the composite can move content without moving the device.
137
+ *
138
+ * The old engine pre-rendered the whole scene to one canvas and then scaled that canvas 6% in/out per
139
+ * beat — which zoomed the phone body and the headline along with the app, the tell of a cheap
140
+ * slideshow. Real product videos pin the chrome and the type, and move only what's on screen.
141
+ *
142
+ * Returns `{ plate, chrome, content, geo, travel }`:
143
+ * plate — background wash + caption (never moves)
144
+ * chrome — the device body, drawn over the content so the bezel/island/punch-hole stay on top
145
+ * content — the capture at screen width; taller than the viewport when there's below-the-fold UI
146
+ * travel — how many px of real content sit below the fold (0 = nothing to scroll)
147
+ */
148
+ function buildScene(W, H, screen, caption, p, S, { frame, effect, scroll } = {}) {
149
+ const fx = effectFor(effect);
150
+ // 0.72 of the frame, centred at 0.60: enough presence that the product is the subject, while leaving
151
+ // ~17% of height for the caption band above and a clean margin below.
152
+ const bodyH = H * 0.72;
153
+ const screenW = bodyH / (DEVICE_ASPECT + 0.064);
154
+ const cy = H * 0.6;
155
+
156
+ const draw = frame && frame !== 'iphone' && frame !== 'phone' ? frameFor(frame) : drawPhone;
157
+ if (!draw) throw new Error(`reel: unknown frame "${frame}" (try iphone | android | ipad | watch).`);
158
+
159
+ // Measure once with an empty screen: we need the screen rect (to size the content) and the body top
160
+ // (to place the caption above it). Cheap, and keeps the per-frame path free of layout work.
161
+ const probeCanvas = createCanvas(W, H);
162
+ const geo = draw(probeCanvas.getContext('2d'), () => {}, W / 2, cy, screenW, { aspect: DEVICE_ASPECT });
163
+ const sr = geo.screen;
164
+ const phoneTop = geo.bodyY ?? cy - geo.bodyH / 2;
165
+
166
+ // The capture at screen width. Taller than the viewport wherever the screen has below-the-fold UI —
167
+ // that overflow is what scrolls.
168
+ const contentH = Math.round(sr.w * (screen.height / screen.width));
169
+ const content = createCanvas(sr.w, Math.max(contentH, Math.round(sr.h)));
170
+ const contentCtx = content.getContext('2d');
171
+ if (fx.filter) contentCtx.filter = fx.filter; // colour grade lives on the capture, not the matte
172
+ contentCtx.drawImage(screen, 0, 0, sr.w, contentH);
173
+
174
+ const plate = createCanvas(W, H);
175
+ const pctx = plate.getContext('2d');
176
+ backgroundLight(pctx, W, H, p);
177
+ const titleSize = Math.round(84 * S);
178
+ const subSize = Math.round(45 * S);
179
+ const maxWidth = W * 0.84;
180
+ const bandTop = H * 0.07;
181
+ const bandH = phoneTop - bandTop - 34 * S;
182
+ const probe = measureCaption(pctx, { title: caption.title, subtitle: caption.sub, maxWidth, titleSize, subSize });
183
+ drawCaption(pctx, {
184
+ title: caption.title,
185
+ subtitle: caption.sub,
186
+ centerX: W / 2,
187
+ top: bandTop + Math.max(0, (bandH - probe) / 2),
188
+ maxWidth,
189
+ titleSize,
190
+ subSize,
191
+ titleColor: p.titleColor,
192
+ subColor: p.subColor,
193
+ });
194
+
195
+ return { plate, content, draw, screenW, cy, fx, scroll, brand: p, travel: Math.max(0, contentH - Math.round(sr.h)) };
196
+ }
197
+
198
+ /**
199
+ * Composite one scene at progress `prog` (0→1). The device body and the caption are pinned; only the
200
+ * capture moves, scrolling inside the viewport where there's real content below the fold.
201
+ *
202
+ * The device is drawn per frame with the content supplied as a CALLBACK, so the frame keeps its natural
203
+ * paint order body, then clipped screen, then island/punch-hole on top — with no layer surgery.
204
+ */
205
+ function paintScene(ctx, scene, prog, t = 0) {
206
+ const { plate, content, draw, screenW, cy, travel, fx, scroll, brand } = scene;
207
+ ctx.drawImage(plate, 0, 0);
208
+ draw(ctx, (c, x, y, w, h) => {
209
+ // STATIC unless the scene opts in with `scroll: true`. Scrolling every screen parks each one at an
210
+ // arbitrary mid-scroll position — header gone, next content not yet arrived — so a full screen ends
211
+ // up reading as a half-empty one. Motion belongs between scenes, not inside them.
212
+ const reach = scroll ? Math.min(travel, h * 0.33) : 0;
213
+ c.drawImage(content, x, y - (reach ? easeInOut(prog) * reach : 0));
214
+ }, ctx.canvas.width / 2, cy, screenW, { aspect: DEVICE_ASPECT });
215
+ // Overlays (grain, vignette, particles, light) sit over the whole finished frame, film-style.
216
+ fx?.overlay?.(ctx, { W: ctx.canvas.width, H: ctx.canvas.height, t, p: prog, brand });
217
+ }
218
+
219
+ function renderColdOpen(W, H, logo, brand, p, S) {
220
+ const c = createCanvas(W, H);
221
+ const ctx = c.getContext('2d');
222
+ backgroundDark(ctx, W, H, p);
223
+ drawLockup(ctx, logo, W / 2, H * 0.42, Math.round(200 * S), Math.round(138 * S), brand.name, p.bookendTitle);
224
+ if (brand.tagline) {
225
+ ctx.textAlign = 'center';
226
+ ctx.textBaseline = 'top';
227
+ ctx.font = font(Math.round(55 * S), 'regular');
228
+ ctx.fillStyle = p.bookendSub;
229
+ ctx.fillText(brand.tagline, W / 2, H * 0.42 + 156 * S);
230
+ }
231
+ return c;
232
+ }
233
+
234
+ function renderEndCard(W, H, logo, brand, p, S) {
235
+ const c = createCanvas(W, H);
236
+ const ctx = c.getContext('2d');
237
+ backgroundDark(ctx, W, H, p);
238
+ drawLockup(ctx, logo, W / 2, H * 0.4, Math.round(180 * S), Math.round(124 * S), brand.name, p.bookendTitle);
239
+ ctx.textAlign = 'center';
240
+ ctx.textBaseline = 'top';
241
+ if (brand.endline) {
242
+ ctx.font = font(Math.round(60 * S), 'bold');
243
+ ctx.fillStyle = p.bookendTitle;
244
+ ctx.fillText(brand.endline, W / 2, H * 0.4 + 142 * S);
245
+ }
246
+ if (brand.endsub) {
247
+ ctx.font = font(Math.round(45 * S), 'regular');
248
+ ctx.fillStyle = p.bookendSub;
249
+ ctx.fillText(brand.endsub, W / 2, H * 0.4 + 235 * S);
250
+ }
251
+ return c;
252
+ }
253
+
254
+ /** Build one device-framed reel. Mirrors buildVideo's signature; `spec` carries w/h/fps/profile/level. */
255
+ // `theme` is accepted only for the status-bar knobs (`statusBar` / `statusBarTime`) — the reel's own
256
+ // palette comes from `brand.reel`, not from here.
257
+ export async function buildReel({ scenes, spec, brand, outFile, timing, music, theme }) {
258
+ if (!scenes?.length) throw new Error('buildReel: no scenes');
259
+ const { w: W, h: H, fps } = spec;
260
+ const S = W / 1320; // scale the ported (1320-wide) type/stroke constants to any width
261
+ const p = { ...DEFAULT_REEL, ...(brand.reel || {}) };
262
+ const t = { ...DEFAULT_TIMING, ...(timing || {}) };
263
+
264
+ const logo = brand.logo && fs.existsSync(brand.logo) ? await loadImage(brand.logo) : null;
265
+
266
+ // A clip is either a static bookend canvas or a layered scene painted per frame.
267
+ const clips = [{ still: renderColdOpen(W, H, logo, brand, p, S), dur: t.coldOpen }];
268
+ for (let i = 0; i < scenes.length; i++) {
269
+ const s = scenes[i];
270
+ if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
271
+ const img = await loadCapture(s.image, theme, theme?.frame);
272
+ clips.push({
273
+ scene: buildScene(W, H, img, { title: s.title, sub: s.sub }, p, S, { frame: theme?.frame, effect: s.effect || theme?.effect, scroll: !!s.scroll }),
274
+ dur: sceneHold(t),
275
+ // How we get INTO this clip. Bookend-adjacent boundaries always dissolve — a logo card that
276
+ // hard-cuts or slides reads as a slideshow.
277
+ cut: i === 0 ? 'dissolve' : s.cut || 'cut',
278
+ push: !!s.push, // the one earned camera move
279
+ });
280
+ }
281
+ clips.push({ still: renderEndCard(W, H, logo, brand, p, S), dur: t.endCard, cut: 'dissolve' });
282
+
283
+ // Each boundary's overlap is its own cut's length, so a hard cut costs a frame and a dissolve costs
284
+ // its full duration — the old engine charged every boundary the same 0.45s.
285
+ const cutDur = (i) => Math.min(transitionFor(clips[i]?.cut, i).dur, clips[i - 1]?.dur ?? Infinity);
286
+ const starts = [0];
287
+ let acc = 0;
288
+ for (let i = 0; i < clips.length - 1; i++) {
289
+ acc += clips[i].dur - cutDur(i + 1);
290
+ starts.push(acc);
291
+ }
292
+ const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
293
+ const totalFrames = Math.round(totalDur * fps);
294
+
295
+ const frame = createCanvas(W, H);
296
+ const fctx = frame.getContext('2d');
297
+
298
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
299
+ const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
300
+
301
+ // Scratch layers: each clip is painted whole, then transformed as a unit.
302
+ const layerA = createCanvas(W, H);
303
+ const laCtx = layerA.getContext('2d');
304
+ const layerB = createCanvas(W, H);
305
+ const lbCtx = layerB.getContext('2d');
306
+
307
+ // The one earned camera move: an overdamped spring push-in, precomputed once and reused by any scene
308
+ // flagged `push`. Overdamped so it settles without a bounce.
309
+ const PUSH = springSeries(Math.ceil(sceneHold(t) * fps) + 2, fps);
310
+ const pushAt = (localT) => PUSH[Math.min(PUSH.length - 1, Math.max(0, Math.round(localT * fps)))];
311
+
312
+ const paintClip = (ctx, clip, prog, localT) => {
313
+ ctx.clearRect(0, 0, W, H);
314
+ if (clip.still) return void ctx.drawImage(clip.still, 0, 0);
315
+ if (!clip.push) return paintScene(ctx, clip.scene, prog, localT);
316
+ // Scale about the device centre so the caption stays pinned while the product comes forward.
317
+ const z = 1 + 0.05 * pushAt(localT);
318
+ const cx = W / 2;
319
+ const cy = clip.scene.cy;
320
+ ctx.save();
321
+ ctx.translate(cx, cy);
322
+ ctx.scale(z, z);
323
+ ctx.translate(-cx, -cy);
324
+ paintScene(ctx, clip.scene, prog, localT);
325
+ ctx.restore();
326
+ };
327
+
328
+ // The base the frame is cleared to. It must be the SCENE wash, not the dark bookend matte: a card
329
+ // turning edge-on (flip / spin) exposes whatever is behind it, and against `matteBottom` that showed
330
+ // as a black flash in the middle of a light reel. Painted once, blitted per frame.
331
+ const wash = createCanvas(W, H);
332
+ backgroundLight(wash.getContext('2d'), W, H, p);
333
+
334
+ for (let k = 0; k < totalFrames; k++) {
335
+ const t2 = k / fps;
336
+ fctx.clearRect(0, 0, W, H);
337
+ fctx.drawImage(wash, 0, 0);
338
+
339
+ let idx = 0;
340
+ for (let i = 0; i < clips.length; i++) if (t2 >= starts[i]) idx = i;
341
+ const localT = t2 - starts[idx];
342
+ const prog = clips[idx].dur > 0 ? Math.min(1, localT / clips[idx].dur) : 0;
343
+ const dur = cutDur(idx);
344
+ const inCut = idx > 0 && localT < dur;
345
+
346
+ paintClip(lbCtx, clips[idx], prog, localT);
347
+
348
+ if (!inCut) {
349
+ fctx.drawImage(layerB, 0, 0);
350
+ } else {
351
+ const raw = dur > 0 ? localT / dur : 1;
352
+ const prev = clips[idx - 1];
353
+ const prevLocal = t2 - starts[idx - 1];
354
+ paintClip(laCtx, prev, prev.dur > 0 ? Math.min(1, prevLocal / prev.dur) : 1, prevLocal);
355
+ transitionFor(clips[idx].cut, idx).paint(fctx, layerA, layerB, raw, { W, H });
356
+ }
357
+
358
+ const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
359
+ if (!proc.stdin.write(buf)) {
360
+ await new Promise((r) => proc.stdin.once('drain', r));
361
+ }
362
+ }
363
+ proc.stdin.end();
364
+ await done;
365
+ return { outFile, totalDur, frames: totalFrames, warnings: [] };
366
+ }