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/montage.mjs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE-FOOTAGE reel (montage) — uspamin's ad-video technique, generalized and made source-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Composite driven video CLIPS (real motion) or IMAGE sequences on a clean matte — floating rounded screen
|
|
5
|
+
* + soft shadow, a headline caption above it, and a short cross-DISSOLVE between beats (Apple App-Preview
|
|
6
|
+
* style; `transition:'cut'` for beat-matched hard cuts). The matte defaults to LIGHT (consistent with the
|
|
7
|
+
* light store screenshots), and STILLS get a slow never-freezing push-in (a frozen still reads as dead).
|
|
8
|
+
* Unlike the premium/social reels — which Ken-Burns a single screenshot — this shows real motion + premium
|
|
9
|
+
* framing. Optional ducked music.
|
|
10
|
+
*
|
|
11
|
+
* Source-agnostic (mirrors zdymak's two screenshot modes): each segment's `clip` / `image` / `images` can be
|
|
12
|
+
* BROUGHT by the user or CAPTURED by `zdymak capture --record`.
|
|
13
|
+
*
|
|
14
|
+
* Config (a `reel` block; `sceneDur`/`bpm`/`beatsPerCut` set the beat-matched hold):
|
|
15
|
+
* reel: {
|
|
16
|
+
* bpm: 120, beatsPerCut: 4, // hold = beatsPerCut × 60/bpm seconds per segment
|
|
17
|
+
* transition: 'dissolve' | 'cut', xfadeDur: 0.3,
|
|
18
|
+
* theme: { ... }, // matte override; defaults LIGHT + caption on top
|
|
19
|
+
* music: { path, volume, fadeIn, fadeOut, offset },
|
|
20
|
+
* segments: [
|
|
21
|
+
* { clip: './rec/study.mov', caption: { title, sub } }, // real motion (a recording)
|
|
22
|
+
* { images: ['a.png','b.png'], caption: { title, sub } }, // multiple photos / page
|
|
23
|
+
* { image: './shots/welcome.png', caption: { title, sub } }, // one still (gets a slow push-in)
|
|
24
|
+
* ],
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
32
|
+
import { font, hexA, roundRectPath, fillVerticalGradient, radialGlow, wrapLines } from './canvas.mjs';
|
|
33
|
+
|
|
34
|
+
// The reel matte defaults to LIGHT — consistent with the light store screenshots (a dark matte behind
|
|
35
|
+
// light-theme screens reads as inconsistent). Apple's app marketing favours clean, minimal, untextured
|
|
36
|
+
// backgrounds; the screen floats via a soft SHADOW, not a heavy vignette. Override any key via `reel.theme`.
|
|
37
|
+
const REEL_LIGHT_DEFAULT = {
|
|
38
|
+
bgTop: '#eef2ef', // soft, faintly cool light
|
|
39
|
+
bgBottom: '#ffffff',
|
|
40
|
+
glow: null, // → brand.sub, kept very subtle on light
|
|
41
|
+
glowAlpha: 0.05,
|
|
42
|
+
vignette: 0, // no filter-y vignette on a light matte
|
|
43
|
+
inset: 0.8,
|
|
44
|
+
radius: 0.05,
|
|
45
|
+
shadow: 0.24, // the float cue on light
|
|
46
|
+
frame: 'phone', // iPhone bezel around the screen (like the framed screenshots); false = bare rounded screen
|
|
47
|
+
label: false, // plain caption text, no pill (matches the light screenshots)
|
|
48
|
+
pillFill: null,
|
|
49
|
+
pillOpacity: 0.9,
|
|
50
|
+
labelColor: '#0b0b0a', // near-black headline
|
|
51
|
+
subColor: '#52606d', // muted slate subhead
|
|
52
|
+
captionAnchor: 'top', // headline ON TOP, matching the store screenshots
|
|
53
|
+
};
|
|
54
|
+
function resolveReelTheme(brand, theme) {
|
|
55
|
+
const th = { ...REEL_LIGHT_DEFAULT, ...(theme || {}) };
|
|
56
|
+
th.glow = th.glow || brand.sub;
|
|
57
|
+
th.pillFill = th.pillFill || brand.ink;
|
|
58
|
+
return th;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FF = process.env.FFMPEG || 'ffmpeg';
|
|
62
|
+
const FFPROBE = process.env.FFPROBE || 'ffprobe';
|
|
63
|
+
|
|
64
|
+
function ff(args) {
|
|
65
|
+
const r = spawnSync(FF, ['-y', '-loglevel', 'error', ...args], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
66
|
+
if (r.status !== 0) throw new Error(`ffmpeg failed (exit ${r.status})`);
|
|
67
|
+
}
|
|
68
|
+
function probe(file, entries) {
|
|
69
|
+
const r = spawnSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0',
|
|
70
|
+
'-show_entries', entries, '-of', 'default=nw=1:nk=1', file], { encoding: 'utf8' });
|
|
71
|
+
return String(r.stdout || '').trim().split('\n');
|
|
72
|
+
}
|
|
73
|
+
const isVideo = (p) => /\.(mov|mp4|m4v|webm|mkv)$/i.test(p);
|
|
74
|
+
|
|
75
|
+
/** Contain-fit (w,h) into the inset box of the frame, preserving the source aspect. */
|
|
76
|
+
function fit(srcW, srcH, boxW, boxH) {
|
|
77
|
+
const a = srcH / srcW;
|
|
78
|
+
let w = boxW;
|
|
79
|
+
let h = Math.round(boxW * a);
|
|
80
|
+
if (h > boxH) {
|
|
81
|
+
h = boxH;
|
|
82
|
+
w = Math.round(boxH / a);
|
|
83
|
+
}
|
|
84
|
+
return [w % 2 ? w + 1 : w, h % 2 ? h + 1 : h];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Vertical layout: reserve a BAND for the caption (top or bottom); the screen floats in the remaining
|
|
88
|
+
* area, so the caption never sits on the app UI. No caption → a thin symmetric matte all round. */
|
|
89
|
+
function layout(W, H, hasCaption, inset, top) {
|
|
90
|
+
const capBand = Math.round(H * (hasCaption ? 0.2 : 0.06));
|
|
91
|
+
const otherPad = Math.round(H * 0.06);
|
|
92
|
+
const availTop = top ? capBand : otherPad;
|
|
93
|
+
const availH = H - capBand - otherPad;
|
|
94
|
+
const boxW = Math.round(W * inset);
|
|
95
|
+
const capCenterY = top ? Math.round(H * 0.09) : H - Math.round(capBand * 0.56);
|
|
96
|
+
return { availTop, availH, boxW, capCenterY };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Canvas furniture (rendered once / per segment, written as PNGs for ffmpeg overlay) ───────────────────
|
|
100
|
+
function writePng(canvas, file) {
|
|
101
|
+
fs.writeFileSync(file, canvas.toBuffer('image/png'));
|
|
102
|
+
}
|
|
103
|
+
function mattePng(W, H, th) {
|
|
104
|
+
const c = createCanvas(W, H);
|
|
105
|
+
const ctx = c.getContext('2d');
|
|
106
|
+
fillVerticalGradient(ctx, W, H, th.bgTop, th.bgBottom);
|
|
107
|
+
radialGlow(ctx, W * 0.5, H * 0.4, W * 0.85, th.glow, th.glowAlpha);
|
|
108
|
+
return c;
|
|
109
|
+
}
|
|
110
|
+
function vignettePng(W, H, strength) {
|
|
111
|
+
const c = createCanvas(W, H);
|
|
112
|
+
if (strength > 0) {
|
|
113
|
+
const ctx = c.getContext('2d');
|
|
114
|
+
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.32, W / 2, H / 2, Math.max(W, H) * 0.72);
|
|
115
|
+
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
116
|
+
g.addColorStop(1, `rgba(0,0,0,${strength})`);
|
|
117
|
+
ctx.fillStyle = g;
|
|
118
|
+
ctx.fillRect(0, 0, W, H);
|
|
119
|
+
}
|
|
120
|
+
return c;
|
|
121
|
+
}
|
|
122
|
+
/** Opaque grayscale mask (white rounded-rect on black) — its luma becomes the screen's alpha via
|
|
123
|
+
* `alphamerge`, rounding the corners. Must be OPAQUE (not transparent) so ffmpeg reads a clean luma. */
|
|
124
|
+
function maskPng(w, h, radius) {
|
|
125
|
+
const c = createCanvas(w, h);
|
|
126
|
+
const ctx = c.getContext('2d');
|
|
127
|
+
ctx.fillStyle = '#000';
|
|
128
|
+
ctx.fillRect(0, 0, w, h);
|
|
129
|
+
roundRectPath(ctx, 0, 0, w, h, radius);
|
|
130
|
+
ctx.fillStyle = '#fff';
|
|
131
|
+
ctx.fill();
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
/** A soft drop-shadow for the floating screen, at its placed rect, on a transparent full frame. */
|
|
135
|
+
function shadowPng(W, H, x, y, w, h, radius, alpha) {
|
|
136
|
+
const c = createCanvas(W, H);
|
|
137
|
+
const ctx = c.getContext('2d');
|
|
138
|
+
ctx.save();
|
|
139
|
+
ctx.shadowColor = `rgba(0,0,0,${alpha})`;
|
|
140
|
+
ctx.shadowBlur = Math.round(W * 0.045);
|
|
141
|
+
ctx.shadowOffsetY = Math.round(W * 0.012);
|
|
142
|
+
roundRectPath(ctx, x, y, w, h, radius);
|
|
143
|
+
ctx.fillStyle = '#000';
|
|
144
|
+
ctx.fill();
|
|
145
|
+
ctx.restore();
|
|
146
|
+
return c;
|
|
147
|
+
}
|
|
148
|
+
/** iPhone frame BACK plate: drop shadow + black unibody at the body rect (its screen area is then covered
|
|
149
|
+
* by the clip). Proportions mirror `frames.mjs` drawPhoneFrame so the reel matches the framed screenshots. */
|
|
150
|
+
function frameBackPng(W, H, bx, by, bw, bh, bodyRadius, screenW) {
|
|
151
|
+
const c = createCanvas(W, H);
|
|
152
|
+
const ctx = c.getContext('2d');
|
|
153
|
+
ctx.save();
|
|
154
|
+
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
|
155
|
+
ctx.shadowBlur = Math.round(screenW * 0.09);
|
|
156
|
+
ctx.shadowOffsetY = Math.round(screenW * 0.035);
|
|
157
|
+
roundRectPath(ctx, bx, by, bw, bh, bodyRadius);
|
|
158
|
+
ctx.fillStyle = '#0b0b0a';
|
|
159
|
+
ctx.fill();
|
|
160
|
+
ctx.restore();
|
|
161
|
+
return c;
|
|
162
|
+
}
|
|
163
|
+
/** iPhone Dynamic Island, drawn OVER the clip (front plate). */
|
|
164
|
+
function frameFrontPng(W, H, sx, sy, screenW, bezel) {
|
|
165
|
+
const c = createCanvas(W, H);
|
|
166
|
+
const ctx = c.getContext('2d');
|
|
167
|
+
const iw = Math.round(screenW * 0.3);
|
|
168
|
+
const ih = Math.round(screenW * 0.085);
|
|
169
|
+
roundRectPath(ctx, Math.round(sx + (screenW - iw) / 2), Math.round(sy + bezel * 1.1), iw, ih, ih / 2);
|
|
170
|
+
ctx.fillStyle = '#050505';
|
|
171
|
+
ctx.fill();
|
|
172
|
+
return c;
|
|
173
|
+
}
|
|
174
|
+
/** A fully transparent full frame — the front plate when there's no device frame (keeps input indices stable). */
|
|
175
|
+
function transparentPng(W, H) {
|
|
176
|
+
return createCanvas(W, H);
|
|
177
|
+
}
|
|
178
|
+
/** Bottom caption pill + subtitle (Apple launch-montage label) on a transparent full frame. */
|
|
179
|
+
function captionPng(W, H, caption, th, capCenterY) {
|
|
180
|
+
const c = createCanvas(W, H);
|
|
181
|
+
if (!caption || (!caption.title && !caption.sub)) return c;
|
|
182
|
+
const ctx = c.getContext('2d');
|
|
183
|
+
ctx.textAlign = 'center';
|
|
184
|
+
ctx.textBaseline = 'middle';
|
|
185
|
+
const titleSize = Math.round(Math.min(W, H * 0.9) * (th.headlineScale ?? 0.062)); // bolder headline (top listings go big)
|
|
186
|
+
const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
|
|
187
|
+
const cx = W / 2;
|
|
188
|
+
let y = capCenterY;
|
|
189
|
+
if (caption.title && th.label) {
|
|
190
|
+
// pill treatment (dark bed) — only when `label` is on; on a light matte it'd be dark-on-dark
|
|
191
|
+
ctx.font = font(titleSize, 'bold');
|
|
192
|
+
const tw = ctx.measureText(caption.title).width;
|
|
193
|
+
const padX = Math.round(titleSize * 0.7);
|
|
194
|
+
const padY = Math.round(titleSize * 0.42);
|
|
195
|
+
const pillW = tw + padX * 2;
|
|
196
|
+
const pillH = titleSize + padY * 2;
|
|
197
|
+
roundRectPath(ctx, cx - pillW / 2, y - pillH / 2, pillW, pillH, pillH / 2);
|
|
198
|
+
ctx.fillStyle = hexA(th.pillFill, th.pillOpacity);
|
|
199
|
+
ctx.fill();
|
|
200
|
+
ctx.fillStyle = th.labelColor;
|
|
201
|
+
ctx.fillText(caption.title, cx, y + 1);
|
|
202
|
+
y += pillH / 2 + subSize * 1.15;
|
|
203
|
+
} else if (caption.title) {
|
|
204
|
+
// plain bold headline (matches the light store screenshots)
|
|
205
|
+
ctx.font = font(titleSize, 'bold');
|
|
206
|
+
ctx.fillStyle = th.labelColor;
|
|
207
|
+
ctx.fillText(caption.title, cx, y);
|
|
208
|
+
y += titleSize * 0.62 + subSize * 1.15;
|
|
209
|
+
}
|
|
210
|
+
if (caption.sub) {
|
|
211
|
+
ctx.font = font(subSize, 'regular');
|
|
212
|
+
ctx.fillStyle = th.subColor;
|
|
213
|
+
for (const ln of wrapLines(ctx, caption.sub, W * 0.82)) {
|
|
214
|
+
ctx.fillText(ln, cx, y);
|
|
215
|
+
y += subSize * 1.3;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return c;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Per-segment composite: one source (clip or still) floating on the matte, captioned ───────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Composite ONE source (video clip or still image) onto the matte for `dur` seconds → an intermediate mp4.
|
|
224
|
+
* A video plays its real motion (held on the last frame if shorter than `dur`); a still holds.
|
|
225
|
+
*/
|
|
226
|
+
function compositeSource({ src, dur, W, H, fps, th, tmp, idx, sub, lay, mattePath, vignettePath, captionPath }) {
|
|
227
|
+
const vid = isVideo(src);
|
|
228
|
+
const [sw, sh] = probe(src, 'stream=width,height').map(Number); // ffprobe reads video + image dims alike
|
|
229
|
+
// `bleed`: the source FILLS the whole frame (no matte / frame / shadow) — for a compliant App Store App
|
|
230
|
+
// Preview (real footage, full-bleed, no device frame). Otherwise: framed device, or a plain floating screen.
|
|
231
|
+
const bleed = !!th.bleed;
|
|
232
|
+
const framed = !bleed && th.frame && th.frame !== 'none' && th.frame !== false;
|
|
233
|
+
|
|
234
|
+
// Geometry. Framed: fit the SCREEN so the phone BODY (screen + 2×bezel ≈ 6.4%) still fits the box.
|
|
235
|
+
const [fw, fh] = bleed
|
|
236
|
+
? [W, H]
|
|
237
|
+
: framed
|
|
238
|
+
? fit(sw, sh, Math.round(lay.boxW / 1.064), Math.round(lay.availH / 1.064))
|
|
239
|
+
: fit(sw, sh, lay.boxW, lay.availH);
|
|
240
|
+
const bezel = framed ? Math.round(fw * 0.032) : 0;
|
|
241
|
+
const bodyW = fw + bezel * 2;
|
|
242
|
+
const bodyH = fh + bezel * 2;
|
|
243
|
+
const bodyX = bleed ? 0 : Math.round((W - bodyW) / 2);
|
|
244
|
+
const bodyY = bleed ? 0 : Math.round(lay.availTop + (lay.availH - bodyH) / 2);
|
|
245
|
+
const sx = bleed ? 0 : bodyX + bezel; // the screen (clip) top-left
|
|
246
|
+
const sy = bleed ? 0 : bodyY + bezel;
|
|
247
|
+
const screenRadius = bleed ? 0 : framed ? Math.round(fw * 0.135) : Math.round(W * th.radius);
|
|
248
|
+
|
|
249
|
+
const tag = `${idx}${sub != null ? `_${sub}` : ''}`;
|
|
250
|
+
const maskP = path.join(tmp, `mask-${tag}.png`);
|
|
251
|
+
const backP = path.join(tmp, `back-${tag}.png`); // shadow + phone body (framed) OR a plain drop shadow
|
|
252
|
+
const frontP = path.join(tmp, `front-${tag}.png`); // Dynamic Island (framed) OR transparent
|
|
253
|
+
writePng(maskPng(fw, fh, screenRadius), maskP);
|
|
254
|
+
if (framed) {
|
|
255
|
+
writePng(frameBackPng(W, H, bodyX, bodyY, bodyW, bodyH, Math.round(fw * 0.155), fw), backP);
|
|
256
|
+
writePng(frameFrontPng(W, H, sx, sy, fw, bezel), frontP);
|
|
257
|
+
} else if (bleed) {
|
|
258
|
+
writePng(transparentPng(W, H), backP); // no shadow — the source fills the frame
|
|
259
|
+
writePng(transparentPng(W, H), frontP);
|
|
260
|
+
} else {
|
|
261
|
+
writePng(shadowPng(W, H, sx, sy, fw, fh, screenRadius, th.shadow), backP);
|
|
262
|
+
writePng(transparentPng(W, H), frontP);
|
|
263
|
+
}
|
|
264
|
+
const out = path.join(tmp, `seg-${tag}.mp4`);
|
|
265
|
+
|
|
266
|
+
// A video plays its own motion; a STILL gets a slow, never-freezing push-in (a frozen still reads as
|
|
267
|
+
// dead — the Ken-Burns trap Apple avoids by keeping the camera always subtly moving).
|
|
268
|
+
const frames = Math.ceil(dur * fps) + 4;
|
|
269
|
+
const screenFilter = bleed
|
|
270
|
+
? `[1:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1,format=rgba[s]` // cover
|
|
271
|
+
: vid
|
|
272
|
+
? `[1:v]scale=${fw}:${fh},setsar=1,format=rgba[s]`
|
|
273
|
+
: `[1:v]scale=${Math.round(fw * 1.14)}:${Math.round(fh * 1.14)},zoompan=z='min(zoom+0.0008,1.12)':`
|
|
274
|
+
+ `d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${fw}x${fh}:fps=${fps},setsar=1,format=rgba[s]`;
|
|
275
|
+
|
|
276
|
+
// Inputs: 0 matte · 1 src · 2 mask · 3 back(shadow+body) · 4 front(island) · 5 caption · 6 vignette
|
|
277
|
+
const srcIn = vid ? ['-i', src] : ['-loop', '1', '-i', src];
|
|
278
|
+
const args = [
|
|
279
|
+
'-loop', '1', '-i', mattePath,
|
|
280
|
+
...srcIn,
|
|
281
|
+
'-loop', '1', '-i', maskP,
|
|
282
|
+
'-loop', '1', '-i', backP,
|
|
283
|
+
'-loop', '1', '-i', frontP,
|
|
284
|
+
'-loop', '1', '-i', captionPath,
|
|
285
|
+
'-loop', '1', '-i', vignettePath,
|
|
286
|
+
'-filter_complex',
|
|
287
|
+
[
|
|
288
|
+
screenFilter,
|
|
289
|
+
`[s][2:v]alphamerge[scr]`, // round the screen's corners to the phone-screen radius
|
|
290
|
+
`[0:v]scale=${W}:${H},setsar=1[bg]`,
|
|
291
|
+
`[bg][3:v]overlay=0:0[b0]`, // shadow + phone body (framed) / plain shadow
|
|
292
|
+
// the floating screen plays here; eof_action=repeat holds the last frame if the clip is shorter
|
|
293
|
+
`[b0][scr]overlay=${sx}:${sy}:eof_action=repeat[b1]`,
|
|
294
|
+
`[b1][4:v]overlay=0:0[b2]`, // Dynamic Island over the screen (transparent if unframed)
|
|
295
|
+
`[b2][5:v]overlay=0:0[b3]`, // caption
|
|
296
|
+
`[b3][6:v]overlay=0:0,` + // vignette
|
|
297
|
+
`trim=0:${dur.toFixed(3)},setpts=PTS-STARTPTS,fps=${fps},format=yuv420p[v]`,
|
|
298
|
+
].join(';'),
|
|
299
|
+
'-map', '[v]', '-t', dur.toFixed(3),
|
|
300
|
+
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', out,
|
|
301
|
+
];
|
|
302
|
+
ff(args);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Concat several already-composited sub-clips (used for an `images` segment) into one segment clip. */
|
|
307
|
+
function concatClips(clips, tmp, idx) {
|
|
308
|
+
if (clips.length === 1) return clips[0];
|
|
309
|
+
const listFile = path.join(tmp, `list-${idx}.txt`);
|
|
310
|
+
fs.writeFileSync(listFile, clips.map((c) => `file '${c}'`).join('\n'));
|
|
311
|
+
const out = path.join(tmp, `seg-${idx}-joined.mp4`);
|
|
312
|
+
ff(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', out]);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build one live-footage reel. `segments[]` each carry a `clip` | `image` | `images` source + `caption`.
|
|
318
|
+
* Transitions are uniform across the montage (`transition: 'cut'` for beat-matched hard cuts, else a
|
|
319
|
+
* dissolve) — per-boundary control lives in the reel engine's `cut` vocabulary, not here.
|
|
320
|
+
*/
|
|
321
|
+
export async function buildMontage({ segments, brand, theme, spec, music, sceneDur, bpm, beatsPerCut, transition, xfadeDur, outFile }) {
|
|
322
|
+
const th = resolveReelTheme(brand, theme);
|
|
323
|
+
const W = spec.w;
|
|
324
|
+
const H = spec.h;
|
|
325
|
+
const fps = spec.fps || 30;
|
|
326
|
+
const hold = sceneDur || (bpm ? (beatsPerCut || 4) * 60 / bpm : 3.0);
|
|
327
|
+
// Dissolve by default (Apple App-Preview style — restrained, never a jarring hard cut); `transition:'cut'`
|
|
328
|
+
// gives beat-matched hard cuts. `tail` extends each segment so the xfade always has overlap material.
|
|
329
|
+
const D = transition === 'cut' ? 0 : (xfadeDur ?? 0.3);
|
|
330
|
+
const tail = D > 0 ? D + 0.2 : 0;
|
|
331
|
+
|
|
332
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zdymak-montage-'));
|
|
333
|
+
try {
|
|
334
|
+
const mattePath = path.join(tmp, 'matte.png');
|
|
335
|
+
const vignettePath = path.join(tmp, 'vignette.png');
|
|
336
|
+
writePng(mattePng(W, H, th), mattePath);
|
|
337
|
+
writePng(vignettePng(W, H, th.vignette), vignettePath);
|
|
338
|
+
|
|
339
|
+
// 1) composite every segment (a clip, one image, or an image sequence) → an intermediate clip.
|
|
340
|
+
const segClips = [];
|
|
341
|
+
for (const [i, seg] of segments.entries()) {
|
|
342
|
+
for (const f of [seg.clip, seg.image, ...(seg.images || [])].filter(Boolean)) {
|
|
343
|
+
if (!fs.existsSync(f)) {
|
|
344
|
+
throw new Error(`reel segment[${i}]: file not found — ${f}
|
|
345
|
+
(a missing clip otherwise surfaces as an ffprobe/NaN error deep in the encode)`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
segments.forEach((seg, i) => {
|
|
350
|
+
const hasCap = !!(seg.caption && (seg.caption.title || seg.caption.sub));
|
|
351
|
+
const lay = layout(W, H, hasCap, th.inset, th.captionAnchor === 'top');
|
|
352
|
+
const captionPath = path.join(tmp, `cap-${i}.png`);
|
|
353
|
+
writePng(captionPng(W, H, seg.caption, th, lay.capCenterY), captionPath);
|
|
354
|
+
const sources = seg.images || (seg.clip ? [seg.clip] : seg.image ? [seg.image] : []);
|
|
355
|
+
if (!sources.length) throw new Error(`reel segment[${i}] needs a clip, image, or images`);
|
|
356
|
+
const per = hold / sources.length;
|
|
357
|
+
const subs = sources.map((src, j) => compositeSource({
|
|
358
|
+
// the LAST sub of the segment carries the dissolve tail so the segment has xfade overlap material
|
|
359
|
+
src, dur: per + (j === sources.length - 1 ? tail : 0), W, H, fps, th, tmp,
|
|
360
|
+
idx: i, sub: sources.length > 1 ? j : null, lay, mattePath, vignettePath, captionPath,
|
|
361
|
+
}));
|
|
362
|
+
segClips.push({ file: concatClips(subs, tmp, i), palette: seg.palette ?? null });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// 2) assemble. NORMALIZE every segment first (fps / timebase / format / SAR) so VFR `recordVideo`
|
|
366
|
+
// clips and image stills mix cleanly. All-hard-cut reels CONCAT end-to-end (robust — no fragile
|
|
367
|
+
// near-zero-margin xfade offsets); a palette change uses an xfade dissolve on those boundaries.
|
|
368
|
+
const inputs = [];
|
|
369
|
+
segClips.forEach((s) => inputs.push('-i', s.file));
|
|
370
|
+
|
|
371
|
+
let filter = '';
|
|
372
|
+
segClips.forEach((_, i) => {
|
|
373
|
+
filter += `[${i}:v]fps=${fps},format=yuv420p,setsar=1,settb=AVTB,setpts=PTS-STARTPTS[n${i}];`;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
let totalDur;
|
|
377
|
+
if (segClips.length === 1) {
|
|
378
|
+
filter += '[n0]copy[vout];';
|
|
379
|
+
totalDur = hold;
|
|
380
|
+
} else if (D === 0) {
|
|
381
|
+
// transition:'cut' → robust end-to-end concat (beat-matched hard cuts)
|
|
382
|
+
filter += `${segClips.map((_, i) => `[n${i}]`).join('')}concat=n=${segClips.length}:v=1[vout];`;
|
|
383
|
+
totalDur = segClips.length * hold;
|
|
384
|
+
} else {
|
|
385
|
+
// dissolve → xfade chain; each shows solo for `hold`, then a D-second cross-dissolve into the next
|
|
386
|
+
let cur = 'n0';
|
|
387
|
+
let offset = 0;
|
|
388
|
+
for (let i = 1; i < segClips.length; i++) {
|
|
389
|
+
offset += hold;
|
|
390
|
+
const out = i === segClips.length - 1 ? 'vout' : `x${i}`;
|
|
391
|
+
filter += `[${cur}][n${i}]xfade=transition=fade:duration=${D.toFixed(3)}:offset=${offset.toFixed(3)}[${out}];`;
|
|
392
|
+
cur = out;
|
|
393
|
+
}
|
|
394
|
+
totalDur = segClips.length * hold + tail;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 3) optional music bed (trim/loop to length, fade, volume).
|
|
398
|
+
const audioArgs = [];
|
|
399
|
+
let map = ['-map', '[vout]'];
|
|
400
|
+
if (music?.path) {
|
|
401
|
+
inputs.push('-stream_loop', '-1', '-i', music.path);
|
|
402
|
+
const ai = segClips.length;
|
|
403
|
+
const fadeOut = music.fadeOut ?? 0.6;
|
|
404
|
+
const a = [
|
|
405
|
+
`[${ai}:a]atrim=${(music.offset || 0).toFixed(3)}:${(( music.offset || 0) + totalDur).toFixed(3)},asetpts=PTS-STARTPTS`,
|
|
406
|
+
`volume=${music.volume ?? 0.9}`,
|
|
407
|
+
`afade=t=in:st=0:d=${(music.fadeIn ?? 0.6)}`,
|
|
408
|
+
`afade=t=out:st=${Math.max(0, totalDur - fadeOut).toFixed(3)}:d=${fadeOut}[aout]`,
|
|
409
|
+
].join(',');
|
|
410
|
+
filter += a;
|
|
411
|
+
map = ['-map', '[vout]', '-map', '[aout]'];
|
|
412
|
+
audioArgs.push('-c:a', 'aac', '-b:a', '192k', '-shortest');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
ff([
|
|
416
|
+
...inputs,
|
|
417
|
+
'-filter_complex', filter.replace(/;$/, ''),
|
|
418
|
+
...map,
|
|
419
|
+
'-c:v', 'libx264', '-profile:v', spec.profile || 'high', '-level:v', spec.level || '4.0',
|
|
420
|
+
'-pix_fmt', 'yuv420p', '-crf', '17', '-preset', 'slow', '-r', String(fps), '-movflags', '+faststart',
|
|
421
|
+
...audioArgs,
|
|
422
|
+
outFile,
|
|
423
|
+
]);
|
|
424
|
+
return { outFile, totalDur, warnings: [] };
|
|
425
|
+
} finally {
|
|
426
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
}
|
package/src/png.mjs
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Encode a canvas as a **lossless RGB PNG with no alpha channel** (PNG colour type 2).
|
|
3
|
-
*
|
|
4
|
-
* App Store Connect rejects any screenshot carrying an alpha channel ("Images can't contain alpha channels
|
|
5
|
-
* or transparencies"), and Google Play wants "24-bit PNG (no alpha)" too. @napi-rs/canvas always emits
|
|
6
|
-
* RGBA, so we re-encode from the raw pixels here. Dependency-free (Node `zlib`), lossless (deflate level 9
|
|
7
|
-
* over the exact RGB samples). Every still fills an opaque background, so dropping the (fully-opaque) alpha
|
|
8
|
-
* is a pure flatten with zero visual change.
|
|
9
|
-
*/
|
|
10
|
-
import zlib from 'node:zlib';
|
|
11
|
-
|
|
12
|
-
export function rgbPngBuffer(canvas) {
|
|
13
|
-
const w = canvas.width;
|
|
14
|
-
const h = canvas.height;
|
|
15
|
-
const { data } = canvas.getContext('2d').getImageData(0, 0, w, h); // RGBA, row-major, 8-bit
|
|
16
|
-
|
|
17
|
-
// PNG scanlines with filter type 0 (none): [0, R,G,B, R,G,B, …] per row.
|
|
18
|
-
const raw = Buffer.allocUnsafe(h * (1 + w * 3));
|
|
19
|
-
let p = 0;
|
|
20
|
-
for (let y = 0; y < h; y++) {
|
|
21
|
-
raw[p++] = 0;
|
|
22
|
-
let src = y * w * 4;
|
|
23
|
-
for (let x = 0; x < w; x++) {
|
|
24
|
-
raw[p++] = data[src];
|
|
25
|
-
raw[p++] = data[src + 1];
|
|
26
|
-
raw[p++] = data[src + 2];
|
|
27
|
-
src += 4;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
const idat = zlib.deflateSync(raw, { level: 9 });
|
|
31
|
-
|
|
32
|
-
const chunk = (type, body) => {
|
|
33
|
-
const len = Buffer.alloc(4);
|
|
34
|
-
len.writeUInt32BE(body.length, 0);
|
|
35
|
-
const typeBuf = Buffer.from(type, 'ascii');
|
|
36
|
-
const crc = Buffer.alloc(4);
|
|
37
|
-
crc.writeUInt32BE(zlib.crc32(Buffer.concat([typeBuf, body])) >>> 0, 0);
|
|
38
|
-
return Buffer.concat([len, typeBuf, body, crc]);
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const ihdr = Buffer.alloc(13);
|
|
42
|
-
ihdr.writeUInt32BE(w, 0);
|
|
43
|
-
ihdr.writeUInt32BE(h, 4);
|
|
44
|
-
ihdr[8] = 8; // bit depth
|
|
45
|
-
ihdr[9] = 2; // colour type 2 = truecolour (RGB), no alpha
|
|
46
|
-
// bytes 10–12 (compression, filter, interlace) stay 0
|
|
47
|
-
|
|
48
|
-
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
49
|
-
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
50
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Encode a canvas as a **lossless RGB PNG with no alpha channel** (PNG colour type 2).
|
|
3
|
+
*
|
|
4
|
+
* App Store Connect rejects any screenshot carrying an alpha channel ("Images can't contain alpha channels
|
|
5
|
+
* or transparencies"), and Google Play wants "24-bit PNG (no alpha)" too. @napi-rs/canvas always emits
|
|
6
|
+
* RGBA, so we re-encode from the raw pixels here. Dependency-free (Node `zlib`), lossless (deflate level 9
|
|
7
|
+
* over the exact RGB samples). Every still fills an opaque background, so dropping the (fully-opaque) alpha
|
|
8
|
+
* is a pure flatten with zero visual change.
|
|
9
|
+
*/
|
|
10
|
+
import zlib from 'node:zlib';
|
|
11
|
+
|
|
12
|
+
export function rgbPngBuffer(canvas) {
|
|
13
|
+
const w = canvas.width;
|
|
14
|
+
const h = canvas.height;
|
|
15
|
+
const { data } = canvas.getContext('2d').getImageData(0, 0, w, h); // RGBA, row-major, 8-bit
|
|
16
|
+
|
|
17
|
+
// PNG scanlines with filter type 0 (none): [0, R,G,B, R,G,B, …] per row.
|
|
18
|
+
const raw = Buffer.allocUnsafe(h * (1 + w * 3));
|
|
19
|
+
let p = 0;
|
|
20
|
+
for (let y = 0; y < h; y++) {
|
|
21
|
+
raw[p++] = 0;
|
|
22
|
+
let src = y * w * 4;
|
|
23
|
+
for (let x = 0; x < w; x++) {
|
|
24
|
+
raw[p++] = data[src];
|
|
25
|
+
raw[p++] = data[src + 1];
|
|
26
|
+
raw[p++] = data[src + 2];
|
|
27
|
+
src += 4;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const idat = zlib.deflateSync(raw, { level: 9 });
|
|
31
|
+
|
|
32
|
+
const chunk = (type, body) => {
|
|
33
|
+
const len = Buffer.alloc(4);
|
|
34
|
+
len.writeUInt32BE(body.length, 0);
|
|
35
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
36
|
+
const crc = Buffer.alloc(4);
|
|
37
|
+
crc.writeUInt32BE(zlib.crc32(Buffer.concat([typeBuf, body])) >>> 0, 0);
|
|
38
|
+
return Buffer.concat([len, typeBuf, body, crc]);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ihdr = Buffer.alloc(13);
|
|
42
|
+
ihdr.writeUInt32BE(w, 0);
|
|
43
|
+
ihdr.writeUInt32BE(h, 4);
|
|
44
|
+
ihdr[8] = 8; // bit depth
|
|
45
|
+
ihdr[9] = 2; // colour type 2 = truecolour (RGB), no alpha
|
|
46
|
+
// bytes 10–12 (compression, filter, interlace) stay 0
|
|
47
|
+
|
|
48
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
49
|
+
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
50
|
+
}
|