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/premium.mjs CHANGED
@@ -1,351 +1,362 @@
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
- captionAnchor: 'bottom', // 'top' places the caption above the device (the bright store-shot layout)
49
- fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
50
- };
51
-
52
- /** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
53
- function avgColor(canvas) {
54
- const { width: w, height: h } = canvas;
55
- const data = canvas.getContext('2d').getImageData(0, 0, w, h).data;
56
- let r = 0;
57
- let g = 0;
58
- let b = 0;
59
- let n = 0;
60
- const step = Math.max(1, Math.floor((w * h) / 4000)) * 4; // ~4k samples
61
- for (let i = 0; i < data.length; i += step) {
62
- r += data[i];
63
- g += data[i + 1];
64
- b += data[i + 2];
65
- n++;
66
- }
67
- return [r / n, g / n, b / n];
68
- }
69
- const colorDist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
70
-
71
- /** Full-bleed cover of a source image onto a W×H canvas (crop tiny overflow). */
72
- function coverCanvas(img, W, H) {
73
- const c = createCanvas(W, H);
74
- const ctx = c.getContext('2d');
75
- const ia = img.height / img.width;
76
- let dw = W;
77
- let dh = W * ia;
78
- if (dh < H) {
79
- dh = H;
80
- dw = H / ia;
81
- }
82
- ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
83
- return c;
84
- }
85
-
86
- /** Paint the matte background (gradient + radial brand glow) onto the frame ctx. */
87
- function paintMatte(ctx, W, H, th) {
88
- fillVerticalGradient(ctx, W, H, th.bgTop, th.bgBottom);
89
- radialGlow(ctx, W * 0.5, H * 0.4, W * 0.85, th.glow, th.glowAlpha);
90
- }
91
-
92
- /** Radial vignette (transparent centre → dark edges) over the whole frame. */
93
- function paintVignette(ctx, W, H, strength) {
94
- if (strength <= 0) return;
95
- const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
96
- g.addColorStop(0, 'rgba(0,0,0,0)');
97
- g.addColorStop(1, `rgba(0,0,0,${strength})`);
98
- ctx.fillStyle = g;
99
- ctx.fillRect(0, 0, W, H);
100
- }
101
-
102
- /** Bottom title pill + subtitle (Apple launch-montage label), fixed position, own fade. */
103
- function drawLabel(ctx, W, H, caption, th, alpha) {
104
- if (alpha <= 0.01 || (!caption.title && !caption.sub)) return;
105
- ctx.save();
106
- ctx.globalAlpha = alpha;
107
- ctx.textAlign = 'center';
108
- ctx.textBaseline = 'middle';
109
-
110
- // Scale type to the SHORTER edge so landscape (Mac) doesn't get huge text, and anchor high enough that
111
- // the pill + subtitle never clip the bottom on wide aspects.
112
- const titleSize = Math.round(Math.min(W, H * 0.9) * 0.05);
113
- const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
114
- const cx = W / 2;
115
- // Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);
116
- // captionAnchor:'top' puts it above the device — the bright store-shot layout.
117
- const topCaption = th.captionAnchor === 'top';
118
- let y = topCaption ? H * 0.085 : (W > H ? 0.7 : 0.82) * H;
119
-
120
- if (caption.title && th.label) {
121
- ctx.font = font(titleSize, 'bold');
122
- const tw = ctx.measureText(caption.title).width;
123
- const padX = Math.round(titleSize * 0.7);
124
- const padY = Math.round(titleSize * 0.42);
125
- const pillW = tw + padX * 2;
126
- const pillH = titleSize + padY * 2;
127
- roundRectPath(ctx, cx - pillW / 2, y - pillH / 2, pillW, pillH, pillH / 2);
128
- ctx.fillStyle = hexA(th.pillFill, th.pillOpacity);
129
- ctx.fill();
130
- ctx.fillStyle = th.labelColor;
131
- ctx.fillText(caption.title, cx, y + 1);
132
- y += pillH / 2 + subSize * 1.05;
133
- } else if (caption.title) {
134
- ctx.font = font(titleSize, 'bold');
135
- ctx.fillStyle = th.labelColor;
136
- ctx.fillText(caption.title, cx, y);
137
- y += titleSize * 0.9 + subSize * 1.2;
138
- }
139
-
140
- if (caption.sub) {
141
- ctx.font = font(subSize, 'regular');
142
- ctx.fillStyle = th.subColor;
143
- for (const ln of wrapLines(ctx, caption.sub, W * 0.82)) {
144
- ctx.fillText(ln, cx, y);
145
- y += subSize * 1.3;
146
- }
147
- }
148
- ctx.restore();
149
- }
150
-
151
- /** Persistent top handle (optional brand chrome). */
152
- function drawHandle(ctx, W, H, text, color) {
153
- if (!text) return;
154
- ctx.save();
155
- ctx.textAlign = 'center';
156
- ctx.textBaseline = 'middle';
157
- ctx.font = font(Math.round(W * 0.03), 'semibold');
158
- ctx.fillStyle = hexA(color, 0.9);
159
- ctx.fillText(text, W / 2, H * 0.035);
160
- ctx.restore();
161
- }
162
-
163
- /** Resolve theme defaults against the brand (shared by the video + still renderers). */
164
- export function resolvePremiumTheme(brand, theme) {
165
- const th = { ...DEFAULT_THEME, ...(theme || {}) };
166
- th.glow = th.glow || brand.sub;
167
- th.pillFill = th.pillFill || brand.ink;
168
- th.labelColor = th.labelColor || brand.title;
169
- th.subColor = th.subColor || brand.sub;
170
- th.handle = th.handle ?? brand.handle ?? null;
171
- return th;
172
- }
173
-
174
- /** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
175
- * fit 'cover' fills the inset box (crops overflow); fit 'contain' shows the WHOLE capture — the card
176
- * shrinks to the capture's aspect and floats with matte margins (correct for a Mac window on the matte,
177
- * where a cover-crop would slice off the title bar / traffic lights). */
178
- function screenLayer(W, H, img, th) {
179
- // Reserve headroom for a top caption so the window floats BELOW it (else it covers the headline).
180
- const topCaption = th.captionAnchor === 'top';
181
- const bandTop = topCaption ? H * 0.18 : 0;
182
- const bandH = (topCaption ? 0.8 : 1) * H;
183
- const boxW = Math.round(W * th.inset);
184
- const boxH = Math.round(bandH * th.inset);
185
- const ia = img.height / img.width;
186
- let dw = boxW;
187
- let dh = boxH;
188
- if (th.fit === 'contain') {
189
- dh = boxW * ia; // fit width, then clamp height so the whole capture fits the box
190
- if (dh > boxH) {
191
- dh = boxH;
192
- dw = boxH / ia;
193
- }
194
- }
195
- const dx = Math.round((W - dw) / 2);
196
- const dy = Math.round(bandTop + (bandH - dh) / 2);
197
- const radius = Math.round(W * th.radius);
198
- const layer = createCanvas(W, H);
199
- const lctx = layer.getContext('2d');
200
- lctx.save();
201
- lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
202
- lctx.shadowBlur = Math.round(W * 0.05);
203
- lctx.shadowOffsetY = Math.round(W * 0.012);
204
- roundRectPath(lctx, dx, dy, dw, dh, radius);
205
- lctx.fillStyle = '#000';
206
- lctx.fill();
207
- lctx.restore();
208
- lctx.save();
209
- roundRectPath(lctx, dx, dy, dw, dh, radius);
210
- lctx.clip();
211
- // The card now matches the draw aspect, so cover here fills it exactly (no crop for 'contain').
212
- lctx.drawImage(coverCanvas(img, dw, dh), dx, dy);
213
- lctx.restore();
214
- return layer;
215
- }
216
-
217
- /** Render ONE premium still → canvas. Static, no motion. With `frame` (phone|ipad|watch) the screen is
218
- * drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
219
- export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
220
- const th = resolvePremiumTheme(brand, theme);
221
- // Store-shot smart defaults (each overridable via `theme`): headline sits ON TOP; a frameless window
222
- // (e.g. Mac) shows the WHOLE capture instead of cropping its title bar.
223
- if (theme?.captionAnchor === undefined) th.captionAnchor = 'top';
224
- if (!frame && theme?.fit === undefined) th.fit = 'contain';
225
- const c = createCanvas(W, H);
226
- const ctx = c.getContext('2d');
227
- paintMatte(ctx, W, H, th);
228
-
229
- const topCaption = th.captionAnchor === 'top';
230
- const drawFrame = frame ? frameFor(frame) : null;
231
- if (drawFrame) {
232
- const cy = H * (topCaption ? 0.57 : 0.42); // drop the device when the caption sits on top
233
- if (frame === 'watch') {
234
- drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
235
- } else {
236
- const budgetH = H * (topCaption ? 0.66 : 0.64); // vertical room for the device body
237
- const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
238
- drawFrame(ctx, img, W / 2, cy, screenW);
239
- }
240
- } else {
241
- ctx.drawImage(screenLayer(W, H, img, th), 0, 0);
242
- }
243
-
244
- drawLabel(ctx, W, H, caption, th, 1);
245
- paintVignette(ctx, W, H, th.vignette);
246
- drawHandle(ctx, W, H, th.handle, th.labelColor);
247
- return c;
248
- }
249
-
250
- /** Build one premium-technique video. Signature mirrors buildVideo/buildReel. */
251
- export async function buildPremium({ scenes, spec, brand, theme, outFile, sceneDur = 3.0, music }) {
252
- if (!scenes?.length) throw new Error('buildPremium: no scenes');
253
- const { w: W, h: H, fps } = spec;
254
- const th = { ...DEFAULT_THEME, ...(theme || {}) };
255
- th.glow = th.glow || brand.sub;
256
- th.pillFill = th.pillFill || brand.ink;
257
- th.labelColor = th.labelColor || brand.title;
258
- th.subColor = th.subColor || brand.sub;
259
- th.handle = th.handle ?? brand.handle ?? null;
260
-
261
- const CAM = springSeries(Math.ceil(sceneDur * fps) + 2, fps, { stiffness: 55, damping: 24, mass: 1.5 });
262
- const camAt = (t) => CAM[Math.min(CAM.length - 1, Math.max(0, Math.round(t * fps)))];
263
-
264
- const insetW = Math.round(W * th.inset);
265
- const insetH = Math.round(H * th.inset);
266
- const insetX = Math.round((W - insetW) / 2);
267
- const insetY = Math.round((H - insetH) / 2);
268
- const radius = Math.round(W * th.radius);
269
-
270
- // Pre-render each scene's floating screen (cover-fit into the inset rect) onto its own transparent layer.
271
- const clips = [];
272
- for (const s of scenes) {
273
- if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
274
- const img = await loadImage(s.image);
275
- const layer = createCanvas(W, H);
276
- const lctx = layer.getContext('2d');
277
- // soft drop shadow
278
- lctx.save();
279
- lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
280
- lctx.shadowBlur = Math.round(W * 0.05);
281
- lctx.shadowOffsetY = Math.round(W * 0.012);
282
- roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
283
- lctx.fillStyle = '#000';
284
- lctx.fill();
285
- lctx.restore();
286
- // clip + draw the cover-fit screenshot
287
- lctx.save();
288
- roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
289
- lctx.clip();
290
- lctx.drawImage(coverCanvas(img, insetW, insetH), insetX, insetY);
291
- lctx.restore();
292
- clips.push({ layer, caption: { title: s.title || '', sub: s.sub || '' }, dur: sceneDur, avg: avgColor(layer) });
293
- }
294
-
295
- // Palette-aware per-boundary cut durations.
296
- const xfadeIn = clips.map((c, i) => {
297
- if (i === 0) return 0;
298
- return colorDist(clips[i - 1].avg, c.avg) < th.cutThreshold ? th.cutHard : th.cutSoft;
299
- });
300
- const starts = [0];
301
- for (let i = 1; i < clips.length; i++) starts.push(starts[i - 1] + clips[i - 1].dur - xfadeIn[i]);
302
- const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
303
- const totalFrames = Math.round(totalDur * fps);
304
-
305
- const frame = createCanvas(W, H);
306
- const fctx = frame.getContext('2d');
307
-
308
- fs.mkdirSync(path.dirname(outFile), { recursive: true });
309
- const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
310
-
311
- for (let k = 0; k < totalFrames; k++) {
312
- const t = k / fps;
313
- // Matte + glow every frame (screens float over it).
314
- paintMatte(fctx, W, H, th);
315
-
316
- for (let i = 0; i < clips.length; i++) {
317
- const start = starts[i];
318
- const end = start + clips[i].dur;
319
- if (t < start || t >= end) continue;
320
- const localT = t - start;
321
- const clipAlpha = i > 0 && localT < xfadeIn[i] ? smooth(localT / xfadeIn[i]) : 1;
322
-
323
- // Motion-then-freeze: alternating spring push-in / pull-back, settles then holds.
324
- const p = camAt(localT);
325
- const z = i % 2 === 0 ? lerp(1.0, 1.05, p) : lerp(1.05, 1.0, p);
326
- fctx.save();
327
- fctx.globalAlpha = clipAlpha;
328
- fctx.translate(W / 2, H / 2);
329
- fctx.scale(z, z);
330
- fctx.translate(-W / 2, -H / 2);
331
- fctx.drawImage(clips[i].layer, 0, 0);
332
- fctx.restore();
333
-
334
- // Label (kinetic fade in, gentle fade before the cut) — not under the camera transform.
335
- const inA = smooth(clamp01((localT - 0.1) / 0.45));
336
- const outA = smooth(clamp01((clips[i].dur - localT) / 0.35));
337
- drawLabel(fctx, W, H, clips[i].caption, th, clipAlpha * inA * outA);
338
- }
339
-
340
- paintVignette(fctx, W, H, th.vignette);
341
- drawHandle(fctx, W, H, th.handle, th.labelColor);
342
-
343
- const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
344
- if (!proc.stdin.write(buf)) {
345
- await new Promise((r) => proc.stdin.once('drain', r));
346
- }
347
- }
348
- proc.stdin.end();
349
- await done;
350
- return { outFile, totalDur, frames: totalFrames, warnings: [] };
351
- }
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 { loadCapture } from './statusbar.mjs';
22
+ import { font, hexA, hexRgb, springSeries, roundRectPath, fillVerticalGradient, radialGlow, wrapLines } from './canvas.mjs';
23
+ import { spawnEncoder } from './encode.mjs';
24
+ import { frameFor } from './frames.mjs';
25
+
26
+ const smooth = (t) => t * t * (3 - 2 * t);
27
+ const lerp = (a, b, t) => a + (b - a) * t;
28
+ const clamp01 = (t) => Math.min(1, Math.max(0, t));
29
+
30
+ /** Defaults brand-driven where a value is null (resolved in buildPremium against `brand`). */
31
+ export const DEFAULT_THEME = {
32
+ bgTop: '#17161c', // matte gradient top
33
+ bgBottom: '#0b0b0a', // matte gradient bottom
34
+ glow: null, // radial brand glow colour → defaults to brand.sub
35
+ glowAlpha: 0.16,
36
+ vignette: 0.3, // 0..1 edge darkening strength
37
+ inset: 0.955, // screen fills this fraction of the frame width (thin matte border)
38
+ radius: 0.03, // screen corner radius as a fraction of frame width
39
+ shadow: 0.42, // floating-screen drop-shadow alpha
40
+ label: true, // show the bottom title pill
41
+ pillFill: null, // → brand.ink
42
+ pillOpacity: 0.9,
43
+ labelColor: null, // → brand.title
44
+ subColor: null, // brand.sub
45
+ handle: null, // optional persistent top handle text (e.g. '@yourapp')
46
+ cutHard: 0.05, // dissolve seconds when adjacent screens share a palette (≈ hard cut)
47
+ cutSoft: 0.24, // dissolve seconds at a palette shift
48
+ cutThreshold: 42, // RGB-distance above which a boundary counts as a palette shift
49
+ captionAnchor: 'bottom', // 'top' places the caption above the device (the bright store-shot layout)
50
+ fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
51
+ };
52
+
53
+ /** The `theme`/`stillTheme` options an app author is expected to set — the doc-sync guard
54
+ * (`scripts/check-docs.mjs`) asserts each appears in README.md/SKILL.md, so adding a public knob forces a
55
+ * doc line. (The rest of DEFAULT_THEME — radius, shadow, the pill and cut knobs — are advanced/internal
56
+ * and not enforced.) */
57
+ export const PUBLIC_THEME_KEYS = [
58
+ 'bgTop', 'bgBottom', 'glow', 'glowAlpha', 'vignette', 'inset',
59
+ 'label', 'labelColor', 'subColor', 'handle', 'captionAnchor', 'fit',
60
+ 'headlineScale', 'frame', 'bleed', 'statusBar', 'statusBarTime', 'statusBarCellular', 'anchor',
61
+ ];
62
+
63
+ /** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
64
+ function avgColor(canvas) {
65
+ const { width: w, height: h } = canvas;
66
+ const data = canvas.getContext('2d').getImageData(0, 0, w, h).data;
67
+ let r = 0;
68
+ let g = 0;
69
+ let b = 0;
70
+ let n = 0;
71
+ const step = Math.max(1, Math.floor((w * h) / 4000)) * 4; // ~4k samples
72
+ for (let i = 0; i < data.length; i += step) {
73
+ r += data[i];
74
+ g += data[i + 1];
75
+ b += data[i + 2];
76
+ n++;
77
+ }
78
+ return [r / n, g / n, b / n];
79
+ }
80
+ const colorDist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
81
+
82
+ /** Full-bleed cover of a source image onto a W×H canvas (crop tiny overflow). */
83
+ function coverCanvas(img, W, H) {
84
+ const c = createCanvas(W, H);
85
+ const ctx = c.getContext('2d');
86
+ const ia = img.height / img.width;
87
+ let dw = W;
88
+ let dh = W * ia;
89
+ if (dh < H) {
90
+ dh = H;
91
+ dw = H / ia;
92
+ }
93
+ ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
94
+ return c;
95
+ }
96
+
97
+ /** Paint the matte background (gradient + radial brand glow) onto the frame ctx. */
98
+ function paintMatte(ctx, W, H, th) {
99
+ fillVerticalGradient(ctx, W, H, th.bgTop, th.bgBottom);
100
+ radialGlow(ctx, W * 0.5, H * 0.4, W * 0.85, th.glow, th.glowAlpha);
101
+ }
102
+
103
+ /** Radial vignette (transparent centre dark edges) over the whole frame. */
104
+ function paintVignette(ctx, W, H, strength) {
105
+ if (strength <= 0) return;
106
+ const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
107
+ g.addColorStop(0, 'rgba(0,0,0,0)');
108
+ g.addColorStop(1, `rgba(0,0,0,${strength})`);
109
+ ctx.fillStyle = g;
110
+ ctx.fillRect(0, 0, W, H);
111
+ }
112
+
113
+ /** Bottom title pill + subtitle (Apple launch-montage label), fixed position, own fade. */
114
+ function drawLabel(ctx, W, H, caption, th, alpha) {
115
+ if (alpha <= 0.01 || (!caption.title && !caption.sub)) return;
116
+ ctx.save();
117
+ ctx.globalAlpha = alpha;
118
+ ctx.textAlign = 'center';
119
+ ctx.textBaseline = 'middle';
120
+
121
+ // Scale type to the SHORTER edge so landscape (Mac) doesn't get huge text, and anchor high enough that
122
+ // the pill + subtitle never clip the bottom on wide aspects.
123
+ const titleSize = Math.round(Math.min(W, H * 0.9) * (th.headlineScale ?? 0.062)); // bolder headline (top listings go big)
124
+ const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
125
+ const cx = W / 2;
126
+ // Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);
127
+ // captionAnchor:'top' puts it above the device the bright store-shot layout.
128
+ const topCaption = th.captionAnchor === 'top';
129
+ let y = topCaption ? H * 0.085 : (W > H ? 0.7 : 0.82) * H;
130
+
131
+ if (caption.title && th.label) {
132
+ ctx.font = font(titleSize, 'bold');
133
+ const tw = ctx.measureText(caption.title).width;
134
+ const padX = Math.round(titleSize * 0.7);
135
+ const padY = Math.round(titleSize * 0.42);
136
+ const pillW = tw + padX * 2;
137
+ const pillH = titleSize + padY * 2;
138
+ roundRectPath(ctx, cx - pillW / 2, y - pillH / 2, pillW, pillH, pillH / 2);
139
+ ctx.fillStyle = hexA(th.pillFill, th.pillOpacity);
140
+ ctx.fill();
141
+ ctx.fillStyle = th.labelColor;
142
+ ctx.fillText(caption.title, cx, y + 1);
143
+ y += pillH / 2 + subSize * 1.05;
144
+ } else if (caption.title) {
145
+ ctx.font = font(titleSize, 'bold');
146
+ ctx.fillStyle = th.labelColor;
147
+ ctx.fillText(caption.title, cx, y);
148
+ y += titleSize * 0.9 + subSize * 1.2;
149
+ }
150
+
151
+ if (caption.sub) {
152
+ ctx.font = font(subSize, 'regular');
153
+ ctx.fillStyle = th.subColor;
154
+ for (const ln of wrapLines(ctx, caption.sub, W * 0.82)) {
155
+ ctx.fillText(ln, cx, y);
156
+ y += subSize * 1.3;
157
+ }
158
+ }
159
+ ctx.restore();
160
+ }
161
+
162
+ /** Persistent top handle (optional brand chrome). */
163
+ function drawHandle(ctx, W, H, text, color) {
164
+ if (!text) return;
165
+ ctx.save();
166
+ ctx.textAlign = 'center';
167
+ ctx.textBaseline = 'middle';
168
+ ctx.font = font(Math.round(W * 0.03), 'semibold');
169
+ ctx.fillStyle = hexA(color, 0.9);
170
+ ctx.fillText(text, W / 2, H * 0.035);
171
+ ctx.restore();
172
+ }
173
+
174
+ /** Resolve theme defaults against the brand (shared by the video + still renderers). */
175
+ export function resolvePremiumTheme(brand, theme) {
176
+ const th = { ...DEFAULT_THEME, ...(theme || {}) };
177
+ th.glow = th.glow || brand.sub;
178
+ th.pillFill = th.pillFill || brand.ink;
179
+ th.labelColor = th.labelColor || brand.title;
180
+ th.subColor = th.subColor || brand.sub;
181
+ th.handle = th.handle ?? brand.handle ?? null;
182
+ return th;
183
+ }
184
+
185
+ /** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
186
+ * fit 'cover' fills the inset box (crops overflow); fit 'contain' shows the WHOLE capture — the card
187
+ * shrinks to the capture's aspect and floats with matte margins (correct for a Mac window on the matte,
188
+ * where a cover-crop would slice off the title bar / traffic lights). */
189
+ function screenLayer(W, H, img, th) {
190
+ // Reserve headroom for a top caption so the window floats BELOW it (else it covers the headline).
191
+ const topCaption = th.captionAnchor === 'top';
192
+ const bandTop = topCaption ? H * 0.18 : 0;
193
+ const bandH = (topCaption ? 0.8 : 1) * H;
194
+ const boxW = Math.round(W * th.inset);
195
+ const boxH = Math.round(bandH * th.inset);
196
+ const ia = img.height / img.width;
197
+ let dw = boxW;
198
+ let dh = boxH;
199
+ if (th.fit === 'contain') {
200
+ dh = boxW * ia; // fit width, then clamp height so the whole capture fits the box
201
+ if (dh > boxH) {
202
+ dh = boxH;
203
+ dw = boxH / ia;
204
+ }
205
+ }
206
+ const dx = Math.round((W - dw) / 2);
207
+ const dy = Math.round(bandTop + (bandH - dh) / 2);
208
+ const radius = Math.round(W * th.radius);
209
+ const layer = createCanvas(W, H);
210
+ const lctx = layer.getContext('2d');
211
+ lctx.save();
212
+ lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
213
+ lctx.shadowBlur = Math.round(W * 0.05);
214
+ lctx.shadowOffsetY = Math.round(W * 0.012);
215
+ roundRectPath(lctx, dx, dy, dw, dh, radius);
216
+ lctx.fillStyle = '#000';
217
+ lctx.fill();
218
+ lctx.restore();
219
+ lctx.save();
220
+ roundRectPath(lctx, dx, dy, dw, dh, radius);
221
+ lctx.clip();
222
+ // The card now matches the draw aspect, so cover here fills it exactly (no crop for 'contain').
223
+ lctx.drawImage(coverCanvas(img, dw, dh), dx, dy);
224
+ lctx.restore();
225
+ return layer;
226
+ }
227
+
228
+ /** Render ONE premium still → canvas. Static, no motion. With `frame` (phone|ipad|watch) the screen is
229
+ * drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
230
+ export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
231
+ const th = resolvePremiumTheme(brand, theme);
232
+ // Store-shot smart defaults (each overridable via `theme`): headline sits ON TOP; a frameless window
233
+ // (e.g. Mac) shows the WHOLE capture instead of cropping its title bar.
234
+ if (theme?.captionAnchor === undefined) th.captionAnchor = 'top';
235
+ if (!frame && theme?.fit === undefined) th.fit = 'contain';
236
+ const c = createCanvas(W, H);
237
+ const ctx = c.getContext('2d');
238
+ paintMatte(ctx, W, H, th);
239
+
240
+ const topCaption = th.captionAnchor === 'top';
241
+ const drawFrame = frame ? frameFor(frame) : null;
242
+ if (drawFrame) {
243
+ const cy = H * (topCaption ? 0.57 : 0.42); // drop the device when the caption sits on top
244
+ if (frame === 'watch') {
245
+ drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
246
+ } else {
247
+ const budgetH = H * (topCaption ? 0.66 : 0.64); // vertical room for the device body
248
+ const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
249
+ drawFrame(ctx, img, W / 2, cy, screenW);
250
+ }
251
+ } else {
252
+ ctx.drawImage(screenLayer(W, H, img, th), 0, 0);
253
+ }
254
+
255
+ drawLabel(ctx, W, H, caption, th, 1);
256
+ paintVignette(ctx, W, H, th.vignette);
257
+ drawHandle(ctx, W, H, th.handle, th.labelColor);
258
+ return c;
259
+ }
260
+
261
+ /** Build one premium-technique video. Signature mirrors buildVideo/buildReel. */
262
+ export async function buildPremium({ scenes, spec, brand, theme, outFile, sceneDur = 3.0, music }) {
263
+ if (!scenes?.length) throw new Error('buildPremium: no scenes');
264
+ const { w: W, h: H, fps } = spec;
265
+ const th = { ...DEFAULT_THEME, ...(theme || {}) };
266
+ th.glow = th.glow || brand.sub;
267
+ th.pillFill = th.pillFill || brand.ink;
268
+ th.labelColor = th.labelColor || brand.title;
269
+ th.subColor = th.subColor || brand.sub;
270
+ th.handle = th.handle ?? brand.handle ?? null;
271
+
272
+ const CAM = springSeries(Math.ceil(sceneDur * fps) + 2, fps, { stiffness: 55, damping: 24, mass: 1.5 });
273
+ const camAt = (t) => CAM[Math.min(CAM.length - 1, Math.max(0, Math.round(t * fps)))];
274
+
275
+ const insetW = Math.round(W * th.inset);
276
+ const insetH = Math.round(H * th.inset);
277
+ const insetX = Math.round((W - insetW) / 2);
278
+ const insetY = Math.round((H - insetH) / 2);
279
+ const radius = Math.round(W * th.radius);
280
+
281
+ // Pre-render each scene's floating screen (cover-fit into the inset rect) onto its own transparent layer.
282
+ const clips = [];
283
+ for (const s of scenes) {
284
+ if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
285
+ const img = await loadCapture(s.image, th, th?.frame);
286
+ const layer = createCanvas(W, H);
287
+ const lctx = layer.getContext('2d');
288
+ // soft drop shadow
289
+ lctx.save();
290
+ lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
291
+ lctx.shadowBlur = Math.round(W * 0.05);
292
+ lctx.shadowOffsetY = Math.round(W * 0.012);
293
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
294
+ lctx.fillStyle = '#000';
295
+ lctx.fill();
296
+ lctx.restore();
297
+ // clip + draw the cover-fit screenshot
298
+ lctx.save();
299
+ roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
300
+ lctx.clip();
301
+ lctx.drawImage(coverCanvas(img, insetW, insetH), insetX, insetY);
302
+ lctx.restore();
303
+ clips.push({ layer, caption: { title: s.title || '', sub: s.sub || '' }, dur: sceneDur, avg: avgColor(layer) });
304
+ }
305
+
306
+ // Palette-aware per-boundary cut durations.
307
+ const xfadeIn = clips.map((c, i) => {
308
+ if (i === 0) return 0;
309
+ return colorDist(clips[i - 1].avg, c.avg) < th.cutThreshold ? th.cutHard : th.cutSoft;
310
+ });
311
+ const starts = [0];
312
+ for (let i = 1; i < clips.length; i++) starts.push(starts[i - 1] + clips[i - 1].dur - xfadeIn[i]);
313
+ const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
314
+ const totalFrames = Math.round(totalDur * fps);
315
+
316
+ const frame = createCanvas(W, H);
317
+ const fctx = frame.getContext('2d');
318
+
319
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
320
+ const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
321
+
322
+ for (let k = 0; k < totalFrames; k++) {
323
+ const t = k / fps;
324
+ // Matte + glow every frame (screens float over it).
325
+ paintMatte(fctx, W, H, th);
326
+
327
+ for (let i = 0; i < clips.length; i++) {
328
+ const start = starts[i];
329
+ const end = start + clips[i].dur;
330
+ if (t < start || t >= end) continue;
331
+ const localT = t - start;
332
+ const clipAlpha = i > 0 && localT < xfadeIn[i] ? smooth(localT / xfadeIn[i]) : 1;
333
+
334
+ // Motion-then-freeze: alternating spring push-in / pull-back, settles then holds.
335
+ const p = camAt(localT);
336
+ const z = i % 2 === 0 ? lerp(1.0, 1.05, p) : lerp(1.05, 1.0, p);
337
+ fctx.save();
338
+ fctx.globalAlpha = clipAlpha;
339
+ fctx.translate(W / 2, H / 2);
340
+ fctx.scale(z, z);
341
+ fctx.translate(-W / 2, -H / 2);
342
+ fctx.drawImage(clips[i].layer, 0, 0);
343
+ fctx.restore();
344
+
345
+ // Label (kinetic fade in, gentle fade before the cut) — not under the camera transform.
346
+ const inA = smooth(clamp01((localT - 0.1) / 0.45));
347
+ const outA = smooth(clamp01((clips[i].dur - localT) / 0.35));
348
+ drawLabel(fctx, W, H, clips[i].caption, th, clipAlpha * inA * outA);
349
+ }
350
+
351
+ paintVignette(fctx, W, H, th.vignette);
352
+ drawHandle(fctx, W, H, th.handle, th.labelColor);
353
+
354
+ const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
355
+ if (!proc.stdin.write(buf)) {
356
+ await new Promise((r) => proc.stdin.once('drain', r));
357
+ }
358
+ }
359
+ proc.stdin.end();
360
+ await done;
361
+ return { outFile, totalDur, frames: totalFrames, warnings: [] };
362
+ }