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/LICENSE +21 -21
- package/README.md +733 -211
- package/SKILL.md +183 -84
- package/bin/zdymak.mjs +3 -3
- package/package.json +66 -40
- package/src/canvas.mjs +116 -116
- package/src/capture/index.mjs +318 -242
- package/src/capture/web.mjs +137 -0
- package/src/cli.mjs +298 -165
- package/src/config.mjs +159 -96
- package/src/destinations.mjs +103 -0
- package/src/easings.mjs +74 -0
- package/src/effects.mjs +335 -0
- package/src/encode.mjs +70 -44
- package/src/fonts.mjs +42 -42
- package/src/frames.mjs +186 -168
- package/src/graphic.mjs +107 -77
- package/src/index.mjs +27 -12
- package/src/montage.mjs +428 -0
- package/src/png.mjs +50 -50
- package/src/premium.mjs +362 -351
- package/src/reel.mjs +366 -245
- package/src/screenshots.mjs +118 -66
- package/src/specs.mjs +123 -88
- package/src/statusbar.mjs +163 -0
- package/src/still.mjs +72 -62
- package/src/transitions.mjs +653 -0
- package/src/validate.mjs +98 -0
- package/src/video.mjs +243 -240
- package/types/index.d.ts +559 -0
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 {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
ctx
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
ctx.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
ctx.
|
|
142
|
-
ctx.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ctx.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
lctx.
|
|
211
|
-
|
|
212
|
-
lctx.
|
|
213
|
-
lctx.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
th.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
lctx.
|
|
288
|
-
|
|
289
|
-
lctx.
|
|
290
|
-
lctx.
|
|
291
|
-
lctx.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}
|