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/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 {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
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
|
-
ctx.save();
|
|
72
|
-
|
|
73
|
-
ctx.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
ctx
|
|
82
|
-
ctx.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ctx
|
|
89
|
-
ctx.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|