zdymak 0.3.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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The PREMIUM technique — the Apple-marketing editing vocabulary (as codified in production
3
+ * "Apple-Vision-Pro-reel" ad pipelines), generalized and fully themeable.
4
+ *
5
+ * Effects applied (all tunable via `theme`, sensible brand-driven defaults if unset):
6
+ * • One-device MATTE — every screen floats, gently inset, on a constant brand matte + soft radial glow,
7
+ * so N different screens read as "one device, many apps" (the Apple ecosystem-reel look).
8
+ * • VIGNETTE — subtle edge darkening for depth.
9
+ * • Motion-then-FREEZE — an over-damped spring dolly that moves ~1s then holds ("cut on motion, rest").
10
+ * • PALETTE-AWARE cuts — a near-hard cut between screens that share a palette, a soft dissolve only at a
11
+ * palette shift (no decorative transitions — the #1 amateur tell).
12
+ * • Label PILL — the scene title on a soft rounded pill (Apple's launch-montage app labels); optional
13
+ * brand HANDLE along the top.
14
+ *
15
+ * Output is full-bleed frame at the target resolution (the app screen itself is inset on the matte, but no
16
+ * device bezel), H.264 High @ the target level, yuv420p, silent.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
21
+ import { font, hexA, hexRgb, springSeries, roundRectPath, fillVerticalGradient, radialGlow, wrapLines } from './canvas.mjs';
22
+ import { spawnEncoder } from './encode.mjs';
23
+ import { frameFor } from './frames.mjs';
24
+
25
+ const smooth = (t) => t * t * (3 - 2 * t);
26
+ const lerp = (a, b, t) => a + (b - a) * t;
27
+ const clamp01 = (t) => Math.min(1, Math.max(0, t));
28
+
29
+ /** Defaults — brand-driven where a value is null (resolved in buildPremium against `brand`). */
30
+ const DEFAULT_THEME = {
31
+ bgTop: '#17161c', // matte gradient top
32
+ bgBottom: '#0b0b0a', // matte gradient bottom
33
+ glow: null, // radial brand glow colour → defaults to brand.sub
34
+ glowAlpha: 0.16,
35
+ vignette: 0.3, // 0..1 edge darkening strength
36
+ inset: 0.955, // screen fills this fraction of the frame width (thin matte border)
37
+ radius: 0.03, // screen corner radius as a fraction of frame width
38
+ shadow: 0.42, // floating-screen drop-shadow alpha
39
+ label: true, // show the bottom title pill
40
+ pillFill: null, // → brand.ink
41
+ pillOpacity: 0.9,
42
+ labelColor: null, // → brand.title
43
+ subColor: null, // → brand.sub
44
+ handle: null, // optional persistent top handle text (e.g. '@yourapp')
45
+ cutHard: 0.05, // dissolve seconds when adjacent screens share a palette (≈ hard cut)
46
+ cutSoft: 0.24, // dissolve seconds at a palette shift
47
+ cutThreshold: 42, // RGB-distance above which a boundary counts as a palette shift
48
+ };
49
+
50
+ /** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
51
+ function avgColor(canvas) {
52
+ const { width: w, height: h } = canvas;
53
+ const data = canvas.getContext('2d').getImageData(0, 0, w, h).data;
54
+ let r = 0;
55
+ let g = 0;
56
+ let b = 0;
57
+ let n = 0;
58
+ const step = Math.max(1, Math.floor((w * h) / 4000)) * 4; // ~4k samples
59
+ for (let i = 0; i < data.length; i += step) {
60
+ r += data[i];
61
+ g += data[i + 1];
62
+ b += data[i + 2];
63
+ n++;
64
+ }
65
+ return [r / n, g / n, b / n];
66
+ }
67
+ const colorDist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
68
+
69
+ /** Full-bleed cover of a source image onto a W×H canvas (crop tiny overflow). */
70
+ function coverCanvas(img, W, H) {
71
+ const c = createCanvas(W, H);
72
+ const ctx = c.getContext('2d');
73
+ const ia = img.height / img.width;
74
+ let dw = W;
75
+ let dh = W * ia;
76
+ if (dh < H) {
77
+ dh = H;
78
+ dw = H / ia;
79
+ }
80
+ ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
81
+ return c;
82
+ }
83
+
84
+ /** Paint the matte background (gradient + radial brand glow) onto the frame ctx. */
85
+ function paintMatte(ctx, W, H, th) {
86
+ fillVerticalGradient(ctx, W, H, th.bgTop, th.bgBottom);
87
+ radialGlow(ctx, W * 0.5, H * 0.4, W * 0.85, th.glow, th.glowAlpha);
88
+ }
89
+
90
+ /** Radial vignette (transparent centre → dark edges) over the whole frame. */
91
+ function paintVignette(ctx, W, H, strength) {
92
+ if (strength <= 0) return;
93
+ const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
94
+ g.addColorStop(0, 'rgba(0,0,0,0)');
95
+ g.addColorStop(1, `rgba(0,0,0,${strength})`);
96
+ ctx.fillStyle = g;
97
+ ctx.fillRect(0, 0, W, H);
98
+ }
99
+
100
+ /** Bottom title pill + subtitle (Apple launch-montage label), fixed position, own fade. */
101
+ function drawLabel(ctx, W, H, caption, th, alpha) {
102
+ if (alpha <= 0.01 || (!caption.title && !caption.sub)) return;
103
+ ctx.save();
104
+ ctx.globalAlpha = alpha;
105
+ ctx.textAlign = 'center';
106
+ ctx.textBaseline = 'middle';
107
+
108
+ // Scale type to the SHORTER edge so landscape (Mac) doesn't get huge text, and anchor high enough that
109
+ // the pill + subtitle never clip the bottom on wide aspects.
110
+ const titleSize = Math.round(Math.min(W, H * 0.9) * 0.05);
111
+ const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
112
+ const cx = W / 2;
113
+ // Anchor higher on wide (landscape) aspects so the pill + subtitle clear the app's own bottom UI.
114
+ let y = (W > H ? 0.7 : 0.82) * H;
115
+
116
+ if (caption.title && th.label) {
117
+ ctx.font = font(titleSize, 'bold');
118
+ const tw = ctx.measureText(caption.title).width;
119
+ const padX = Math.round(titleSize * 0.7);
120
+ const padY = Math.round(titleSize * 0.42);
121
+ const pillW = tw + padX * 2;
122
+ const pillH = titleSize + padY * 2;
123
+ roundRectPath(ctx, cx - pillW / 2, y - pillH / 2, pillW, pillH, pillH / 2);
124
+ ctx.fillStyle = hexA(th.pillFill, th.pillOpacity);
125
+ ctx.fill();
126
+ ctx.fillStyle = th.labelColor;
127
+ ctx.fillText(caption.title, cx, y + 1);
128
+ y += pillH / 2 + subSize * 1.05;
129
+ } else if (caption.title) {
130
+ ctx.font = font(titleSize, 'bold');
131
+ ctx.fillStyle = th.labelColor;
132
+ ctx.fillText(caption.title, cx, y);
133
+ y += titleSize * 0.9 + subSize * 1.2;
134
+ }
135
+
136
+ if (caption.sub) {
137
+ ctx.font = font(subSize, 'regular');
138
+ ctx.fillStyle = th.subColor;
139
+ for (const ln of wrapLines(ctx, caption.sub, W * 0.82)) {
140
+ ctx.fillText(ln, cx, y);
141
+ y += subSize * 1.3;
142
+ }
143
+ }
144
+ ctx.restore();
145
+ }
146
+
147
+ /** Persistent top handle (optional brand chrome). */
148
+ function drawHandle(ctx, W, H, text, color) {
149
+ if (!text) return;
150
+ ctx.save();
151
+ ctx.textAlign = 'center';
152
+ ctx.textBaseline = 'middle';
153
+ ctx.font = font(Math.round(W * 0.03), 'semibold');
154
+ ctx.fillStyle = hexA(color, 0.9);
155
+ ctx.fillText(text, W / 2, H * 0.035);
156
+ ctx.restore();
157
+ }
158
+
159
+ /** Resolve theme defaults against the brand (shared by the video + still renderers). */
160
+ export function resolvePremiumTheme(brand, theme) {
161
+ const th = { ...DEFAULT_THEME, ...(theme || {}) };
162
+ th.glow = th.glow || brand.sub;
163
+ th.pillFill = th.pillFill || brand.ink;
164
+ th.labelColor = th.labelColor || brand.title;
165
+ th.subColor = th.subColor || brand.sub;
166
+ th.handle = th.handle ?? brand.handle ?? null;
167
+ return th;
168
+ }
169
+
170
+ /** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas. */
171
+ function screenLayer(W, H, img, th) {
172
+ const insetW = Math.round(W * th.inset);
173
+ const insetH = Math.round(H * th.inset);
174
+ const insetX = Math.round((W - insetW) / 2);
175
+ const insetY = Math.round((H - insetH) / 2);
176
+ const radius = Math.round(W * th.radius);
177
+ const layer = createCanvas(W, H);
178
+ const lctx = layer.getContext('2d');
179
+ lctx.save();
180
+ lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
181
+ lctx.shadowBlur = Math.round(W * 0.05);
182
+ lctx.shadowOffsetY = Math.round(W * 0.012);
183
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
184
+ lctx.fillStyle = '#000';
185
+ lctx.fill();
186
+ lctx.restore();
187
+ lctx.save();
188
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
189
+ lctx.clip();
190
+ lctx.drawImage(coverCanvas(img, insetW, insetH), insetX, insetY);
191
+ lctx.restore();
192
+ return layer;
193
+ }
194
+
195
+ /** Render ONE premium still → canvas. Static, no motion. With `frame` (phone|ipad|watch) the screen is
196
+ * drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
197
+ export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
198
+ const th = resolvePremiumTheme(brand, theme);
199
+ const c = createCanvas(W, H);
200
+ const ctx = c.getContext('2d');
201
+ paintMatte(ctx, W, H, th);
202
+
203
+ const drawFrame = frame ? frameFor(frame) : null;
204
+ if (drawFrame) {
205
+ const cy = H * 0.42; // sit high so the framed body clears the bottom label
206
+ if (frame === 'watch') {
207
+ drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
208
+ } else {
209
+ const budgetH = H * 0.64; // vertical room for the device body
210
+ const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
211
+ drawFrame(ctx, img, W / 2, cy, screenW);
212
+ }
213
+ } else {
214
+ ctx.drawImage(screenLayer(W, H, img, th), 0, 0);
215
+ }
216
+
217
+ drawLabel(ctx, W, H, caption, th, 1);
218
+ paintVignette(ctx, W, H, th.vignette);
219
+ drawHandle(ctx, W, H, th.handle, th.labelColor);
220
+ return c;
221
+ }
222
+
223
+ /** Build one premium-technique video. Signature mirrors buildVideo/buildReel. */
224
+ export async function buildPremium({ scenes, spec, brand, theme, outFile, sceneDur = 3.0, music }) {
225
+ if (!scenes?.length) throw new Error('buildPremium: no scenes');
226
+ const { w: W, h: H, fps } = spec;
227
+ const th = { ...DEFAULT_THEME, ...(theme || {}) };
228
+ th.glow = th.glow || brand.sub;
229
+ th.pillFill = th.pillFill || brand.ink;
230
+ th.labelColor = th.labelColor || brand.title;
231
+ th.subColor = th.subColor || brand.sub;
232
+ th.handle = th.handle ?? brand.handle ?? null;
233
+
234
+ const CAM = springSeries(Math.ceil(sceneDur * fps) + 2, fps, { stiffness: 55, damping: 24, mass: 1.5 });
235
+ const camAt = (t) => CAM[Math.min(CAM.length - 1, Math.max(0, Math.round(t * fps)))];
236
+
237
+ const insetW = Math.round(W * th.inset);
238
+ const insetH = Math.round(H * th.inset);
239
+ const insetX = Math.round((W - insetW) / 2);
240
+ const insetY = Math.round((H - insetH) / 2);
241
+ const radius = Math.round(W * th.radius);
242
+
243
+ // Pre-render each scene's floating screen (cover-fit into the inset rect) onto its own transparent layer.
244
+ const clips = [];
245
+ for (const s of scenes) {
246
+ if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
247
+ const img = await loadImage(s.image);
248
+ const layer = createCanvas(W, H);
249
+ const lctx = layer.getContext('2d');
250
+ // soft drop shadow
251
+ lctx.save();
252
+ lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
253
+ lctx.shadowBlur = Math.round(W * 0.05);
254
+ lctx.shadowOffsetY = Math.round(W * 0.012);
255
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
256
+ lctx.fillStyle = '#000';
257
+ lctx.fill();
258
+ lctx.restore();
259
+ // clip + draw the cover-fit screenshot
260
+ lctx.save();
261
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
262
+ lctx.clip();
263
+ lctx.drawImage(coverCanvas(img, insetW, insetH), insetX, insetY);
264
+ lctx.restore();
265
+ clips.push({ layer, caption: { title: s.title || '', sub: s.sub || '' }, dur: sceneDur, avg: avgColor(layer) });
266
+ }
267
+
268
+ // Palette-aware per-boundary cut durations.
269
+ const xfadeIn = clips.map((c, i) => {
270
+ if (i === 0) return 0;
271
+ return colorDist(clips[i - 1].avg, c.avg) < th.cutThreshold ? th.cutHard : th.cutSoft;
272
+ });
273
+ const starts = [0];
274
+ for (let i = 1; i < clips.length; i++) starts.push(starts[i - 1] + clips[i - 1].dur - xfadeIn[i]);
275
+ const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
276
+ const totalFrames = Math.round(totalDur * fps);
277
+
278
+ const frame = createCanvas(W, H);
279
+ const fctx = frame.getContext('2d');
280
+
281
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
282
+ const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
283
+
284
+ for (let k = 0; k < totalFrames; k++) {
285
+ const t = k / fps;
286
+ // Matte + glow every frame (screens float over it).
287
+ paintMatte(fctx, W, H, th);
288
+
289
+ for (let i = 0; i < clips.length; i++) {
290
+ const start = starts[i];
291
+ const end = start + clips[i].dur;
292
+ if (t < start || t >= end) continue;
293
+ const localT = t - start;
294
+ const clipAlpha = i > 0 && localT < xfadeIn[i] ? smooth(localT / xfadeIn[i]) : 1;
295
+
296
+ // Motion-then-freeze: alternating spring push-in / pull-back, settles then holds.
297
+ const p = camAt(localT);
298
+ const z = i % 2 === 0 ? lerp(1.0, 1.05, p) : lerp(1.05, 1.0, p);
299
+ fctx.save();
300
+ fctx.globalAlpha = clipAlpha;
301
+ fctx.translate(W / 2, H / 2);
302
+ fctx.scale(z, z);
303
+ fctx.translate(-W / 2, -H / 2);
304
+ fctx.drawImage(clips[i].layer, 0, 0);
305
+ fctx.restore();
306
+
307
+ // Label (kinetic fade in, gentle fade before the cut) — not under the camera transform.
308
+ const inA = smooth(clamp01((localT - 0.1) / 0.45));
309
+ const outA = smooth(clamp01((clips[i].dur - localT) / 0.35));
310
+ drawLabel(fctx, W, H, clips[i].caption, th, clipAlpha * inA * outA);
311
+ }
312
+
313
+ paintVignette(fctx, W, H, th.vignette);
314
+ drawHandle(fctx, W, H, th.handle, th.labelColor);
315
+
316
+ const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
317
+ if (!proc.stdin.write(buf)) {
318
+ await new Promise((r) => proc.stdin.once('drain', r));
319
+ }
320
+ }
321
+ proc.stdin.end();
322
+ await done;
323
+ return { outFile, totalDur, frames: totalFrames, warnings: [] };
324
+ }
package/src/reel.mjs ADDED
@@ -0,0 +1,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 { 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
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Multi-device store-screenshot generator. For each configured device group and each of its screenshot
3
+ * targets, renders one still per scene (in the chosen style, at the target's exact dimensions) and writes a
4
+ * store-safe **no-alpha PNG**. Scenes whose capture is missing for a device are skipped gracefully, so an
5
+ * app configures only the devices it actually ships.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { renderStill } from './still.mjs';
10
+ import { rgbPngBuffer } from './png.mjs';
11
+ import { IMAGE_TARGETS } from './specs.mjs';
12
+ import { inferFrame } from './frames.mjs';
13
+ import { buildFeatureGraphic } from './graphic.mjs';
14
+
15
+ /** Resolve a concrete [w,h] for a screenshot target: explicit override → spec w/h → first accepted size. */
16
+ export function targetSize(spec, override) {
17
+ if (override) return override;
18
+ if (spec.w && spec.h) return [spec.w, spec.h];
19
+ if (spec.accepts?.length) return spec.accepts[0];
20
+ throw new Error('screenshot target has no resolvable size');
21
+ }
22
+
23
+ /** Build every screenshot for one resolved device group → array of written file paths. */
24
+ export async function buildDeviceScreenshots({ device, brand, theme, outDir }) {
25
+ const written = [];
26
+ for (const shot of device.screenshots || []) {
27
+ const spec = IMAGE_TARGETS[shot.target];
28
+ if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
29
+ const [W, H] = targetSize(spec, shot.size);
30
+ const style = shot.style || 'premium';
31
+ const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
32
+
33
+ // Feature graphic (Play banner) is a single branded image, not a per-scene shot.
34
+ if (spec.graphic) {
35
+ const hero = device.scenes.find((s) => fs.existsSync(s.image));
36
+ if (!hero) continue;
37
+ const outFile = path.join(outDir, `${shot.target}.png`);
38
+ await buildFeatureGraphic({ W, H, brand, theme: shot.theme || theme, heroPath: hero.image, outFile, frame: frame || 'android' });
39
+ written.push({ file: outFile, W, H, style: 'graphic' });
40
+ continue;
41
+ }
42
+
43
+ const dir = path.join(outDir, shot.target);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+
46
+ let n = 0;
47
+ for (const scene of device.scenes) {
48
+ if (!fs.existsSync(scene.image)) continue; // graceful: this device lacks this scene's capture
49
+ n++;
50
+ const still = await renderStill(style, {
51
+ W, H,
52
+ imgPath: scene.image,
53
+ caption: { title: scene.title || '', sub: scene.sub || '' },
54
+ brand,
55
+ theme: shot.theme || theme,
56
+ frame,
57
+ });
58
+ const file = path.join(dir, `${String(n).padStart(2, '0')}-${scene.id || n}.png`);
59
+ fs.writeFileSync(file, rgbPngBuffer(still));
60
+ written.push({ file, W, H, style });
61
+ }
62
+ }
63
+ return written;
64
+ }