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/effects.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EFFECT REGISTRY — per-scene looks for the reel.
|
|
3
|
+
*
|
|
4
|
+
* An effect has two optional halves, either of which may be omitted:
|
|
5
|
+
*
|
|
6
|
+
* { label, filter, overlay(ctx, { W, H, t, p }) }
|
|
7
|
+
*
|
|
8
|
+
* filter — a CSS filter string applied to the CAPTURE as it's drawn (colour grading)
|
|
9
|
+
* overlay — drawn over the finished frame (vignette, grain, particles, light)
|
|
10
|
+
*
|
|
11
|
+
* `t` is elapsed seconds within the scene and `p` its 0→1 progress, so overlays can animate. Everything
|
|
12
|
+
* random here is derived from a hashed index, never `Math.random()` — a re-render must be identical to
|
|
13
|
+
* the last one or you can't diff two versions of a video.
|
|
14
|
+
*
|
|
15
|
+
* ── Where these belong ──────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* Effects are a REEL/social-ad feature and are deliberately not applied to store screenshots. Google
|
|
17
|
+
* requires Play screenshots to show the app interface unaltered, and a graded or grain-covered UI
|
|
18
|
+
* misrepresents what the user will see. Grade your ads, not your store listing.
|
|
19
|
+
*
|
|
20
|
+
* Ported from uspamin's collage effects (CSS filters + React overlay panes) onto Skia canvas ops.
|
|
21
|
+
*/
|
|
22
|
+
import { hexA } from './canvas.mjs';
|
|
23
|
+
import { softPeak } from './easings.mjs';
|
|
24
|
+
|
|
25
|
+
/** Deterministic pseudo-random in [0,1). */
|
|
26
|
+
const rnd = (n) => {
|
|
27
|
+
const x = Math.sin(n * 12.9898) * 43758.5453;
|
|
28
|
+
return x - Math.floor(x);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Radial gradient helper. */
|
|
32
|
+
function radial(ctx, cx, cy, r, stops) {
|
|
33
|
+
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
34
|
+
for (const [at, colour] of stops) g.addColorStop(at, colour);
|
|
35
|
+
return g;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Draw an overlay pass with a blend mode + alpha, then restore. */
|
|
39
|
+
function blended(ctx, mode, alpha, draw) {
|
|
40
|
+
ctx.save();
|
|
41
|
+
ctx.globalCompositeOperation = mode;
|
|
42
|
+
ctx.globalAlpha = alpha;
|
|
43
|
+
draw(ctx);
|
|
44
|
+
ctx.restore();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A field of drifting particles — the shared engine behind snow / sparkles / hearts / confetti. */
|
|
48
|
+
function particles(ctx, { W, H, t }, { count, seed = 0, speed, size, drift = 0.4, draw }) {
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
const s = i + seed;
|
|
51
|
+
const x0 = rnd(s) * W;
|
|
52
|
+
const phase = rnd(s + 101);
|
|
53
|
+
// Floor-mod, NOT `%`: JS remainder keeps the dividend's sign, so an upward field (negative speed)
|
|
54
|
+
// wraps into [-1.2, 0) and the particle leaves the frame forever instead of re-entering from below.
|
|
55
|
+
const cycle = ((((rnd(s + 7) + (t * speed) / H + phase) % 1.2) + 1.2) % 1.2);
|
|
56
|
+
const y = cycle * H - H * 0.1;
|
|
57
|
+
const x = x0 + Math.sin(t * 0.6 + phase * Math.PI * 2) * W * drift * 0.06;
|
|
58
|
+
draw(ctx, x, y, size * (0.6 + rnd(s + 31) * 0.8), s);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Accent colour for effects that tint: the consuming project's brand, not zdymak's own greens. */
|
|
63
|
+
const accent = (o, fallback = '#ffffff') => o?.brand?.sub || fallback;
|
|
64
|
+
|
|
65
|
+
export const EFFECTS = {
|
|
66
|
+
none: { label: 'No effect' },
|
|
67
|
+
|
|
68
|
+
// ── Colour grades (filter only — no per-frame cost) ────────────────────────────────────────────
|
|
69
|
+
bw: { label: 'Black & white', filter: 'grayscale(1) contrast(1.05)' },
|
|
70
|
+
sepia: { label: 'Sepia', filter: 'sepia(0.75) contrast(1.02)' },
|
|
71
|
+
cool: { label: 'Cool cast', filter: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)' },
|
|
72
|
+
vibrant: { label: 'Vibrant', filter: 'saturate(1.35) contrast(1.06)' },
|
|
73
|
+
'soft-faded': {
|
|
74
|
+
label: 'Soft faded film',
|
|
75
|
+
filter: 'contrast(0.92) brightness(1.06) saturate(0.9)',
|
|
76
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'source-over', 0.06, (c) => {
|
|
77
|
+
c.fillStyle = '#fff8f0';
|
|
78
|
+
c.fillRect(0, 0, W, H);
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
'warm-film': {
|
|
82
|
+
label: 'Warm film',
|
|
83
|
+
filter: 'sepia(0.22) saturate(1.12) contrast(1.03)',
|
|
84
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'soft-light', 0.35, (c) => {
|
|
85
|
+
c.fillStyle = '#ffb367';
|
|
86
|
+
c.fillRect(0, 0, W, H);
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
duotone: {
|
|
90
|
+
label: 'Duotone',
|
|
91
|
+
filter: 'grayscale(1) contrast(1.1)',
|
|
92
|
+
overlay(ctx, { W, H }) {
|
|
93
|
+
blended(ctx, 'multiply', 0.55, (c) => { c.fillStyle = '#2b1e5c'; c.fillRect(0, 0, W, H); });
|
|
94
|
+
blended(ctx, 'screen', 0.35, (c) => { c.fillStyle = '#22c55e'; c.fillRect(0, 0, W, H); });
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// ── Optical ────────────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
vignette: {
|
|
100
|
+
label: 'Vignette — darkens the corners',
|
|
101
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'source-over', 1, (c) => {
|
|
102
|
+
c.fillStyle = radial(c, W / 2, H / 2, Math.hypot(W, H) * 0.62, [
|
|
103
|
+
[0.55, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0.45)'],
|
|
104
|
+
]);
|
|
105
|
+
c.fillRect(0, 0, W, H);
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'soft-glow': {
|
|
110
|
+
label: 'Soft glow — light blooms out of the highlights',
|
|
111
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'screen', 0.18, (c) => {
|
|
112
|
+
c.fillStyle = radial(c, W / 2, H * 0.42, Math.hypot(W, H) * 0.5, [
|
|
113
|
+
[0, 'rgba(255,255,255,0.9)'], [1, 'rgba(255,255,255,0)'],
|
|
114
|
+
]);
|
|
115
|
+
c.fillRect(0, 0, W, H);
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
'dreamy-haze': {
|
|
120
|
+
label: 'Dreamy haze - soft-focus bloom',
|
|
121
|
+
filter: 'brightness(1.04) saturate(1.05)',
|
|
122
|
+
overlay(ctx) {
|
|
123
|
+
// A real soft focus, not a second radial white wash: `soft-glow` already owns that, and two ids
|
|
124
|
+
// rendering the same overlay is how a library gets padded rather than useful.
|
|
125
|
+
blended(ctx, 'screen', 0.2, (c) => {
|
|
126
|
+
c.filter = 'blur(26px)';
|
|
127
|
+
c.drawImage(c.canvas, 0, 0);
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
bokeh: {
|
|
133
|
+
label: 'Bokeh — drifting out-of-focus lights',
|
|
134
|
+
overlay: (ctx, o) => blended(ctx, 'screen', 0.5, (c) => {
|
|
135
|
+
c.filter = 'blur(6px)';
|
|
136
|
+
particles(c, o, {
|
|
137
|
+
count: 18, seed: 11, speed: -6, size: o.W * 0.05, drift: 1,
|
|
138
|
+
draw: (cc, x, y, r, s) => {
|
|
139
|
+
cc.fillStyle = hexA(s % 3 === 0 ? accent(o) : '#ffffff', 0.5 + rnd(s) * 0.4);
|
|
140
|
+
cc.beginPath();
|
|
141
|
+
cc.arc(x, y, r, 0, Math.PI * 2);
|
|
142
|
+
cc.fill();
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
glare: {
|
|
149
|
+
label: 'Glare — a static lens streak',
|
|
150
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'screen', 0.35, (c) => {
|
|
151
|
+
const g = c.createLinearGradient(W * 0.1, 0, W * 0.5, H);
|
|
152
|
+
g.addColorStop(0, 'rgba(255,255,255,0)');
|
|
153
|
+
g.addColorStop(0.5, 'rgba(255,255,255,0.55)');
|
|
154
|
+
g.addColorStop(1, 'rgba(255,255,255,0)');
|
|
155
|
+
c.fillStyle = g;
|
|
156
|
+
c.fillRect(0, 0, W, H);
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
floodlight: {
|
|
161
|
+
label: 'Floodlight — a warm pool of stage light',
|
|
162
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'screen', 0.4, (c) => {
|
|
163
|
+
c.fillStyle = radial(c, W / 2, H * 0.35, Math.hypot(W, H) * 0.55, [
|
|
164
|
+
[0, 'rgba(255,214,160,0.8)'], [1, 'rgba(255,214,160,0)'],
|
|
165
|
+
]);
|
|
166
|
+
c.fillRect(0, 0, W, H);
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
'light-leak': {
|
|
171
|
+
label: 'Light leak — warm bleed from the edge',
|
|
172
|
+
overlay: (ctx, { W, H, t }) => blended(ctx, 'screen', 0.4 + 0.15 * Math.sin(t * 0.8), (c) => {
|
|
173
|
+
const g = c.createLinearGradient(W, 0, W * 0.45, H * 0.5);
|
|
174
|
+
g.addColorStop(0, 'rgba(255,170,90,0.75)');
|
|
175
|
+
g.addColorStop(1, 'rgba(255,170,90,0)');
|
|
176
|
+
c.fillStyle = g;
|
|
177
|
+
c.fillRect(0, 0, W, H);
|
|
178
|
+
}),
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// ── Film texture ───────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
'film-grain': {
|
|
183
|
+
label: 'Film grain',
|
|
184
|
+
overlay(ctx, { W, H, t }) {
|
|
185
|
+
// Coarse procedural grain: a sparse field of 2px specks, re-seeded per frame so it shimmers the
|
|
186
|
+
// way real grain does. Cheaper than a turbulence texture and deterministic per frame index.
|
|
187
|
+
const frame = Math.round(t * 60); // finer than any output fps, so no frame reuses a pattern
|
|
188
|
+
blended(ctx, 'overlay', 0.5, (c) => {
|
|
189
|
+
for (let i = 0; i < 1400; i++) {
|
|
190
|
+
const s = i + frame * 1400;
|
|
191
|
+
c.fillStyle = rnd(s + 5) > 0.5 ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)';
|
|
192
|
+
c.fillRect(rnd(s) * W, rnd(s + 2) * H, 2, 2);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
scanlines: {
|
|
199
|
+
label: 'Scanlines — CRT texture',
|
|
200
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'overlay', 0.22, (c) => {
|
|
201
|
+
c.fillStyle = '#000000';
|
|
202
|
+
for (let y = 0; y < H; y += 4) c.fillRect(0, y, W, 2);
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
'dust-scratches': {
|
|
207
|
+
label: 'Dust & scratches — projected-print wear',
|
|
208
|
+
overlay(ctx, { W, H, t }) {
|
|
209
|
+
const frame = Math.round(t * 12);
|
|
210
|
+
blended(ctx, 'screen', 0.4, (c) => {
|
|
211
|
+
for (let i = 0; i < 3; i++) {
|
|
212
|
+
const s = i + frame * 13;
|
|
213
|
+
if (rnd(s) > 0.55) continue;
|
|
214
|
+
const x = rnd(s + 1) * W;
|
|
215
|
+
c.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
216
|
+
c.lineWidth = 1 + rnd(s + 2);
|
|
217
|
+
c.beginPath();
|
|
218
|
+
c.moveTo(x, rnd(s + 3) * H * 0.4);
|
|
219
|
+
c.lineTo(x + (rnd(s + 4) - 0.5) * 12, H * (0.5 + rnd(s + 5) * 0.5));
|
|
220
|
+
c.stroke();
|
|
221
|
+
}
|
|
222
|
+
for (let i = 0; i < 14; i++) {
|
|
223
|
+
const s = i + frame * 29;
|
|
224
|
+
c.fillStyle = 'rgba(255,255,255,0.5)';
|
|
225
|
+
c.fillRect(rnd(s) * W, rnd(s + 1) * H, 2, 2);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
'camera-shake': {
|
|
232
|
+
label: 'Camera shake - handheld energy',
|
|
233
|
+
overlay(ctx, { t }) {
|
|
234
|
+
// Deterministic per frame index, so a re-render is identical. The one energy tool every ad editor
|
|
235
|
+
// reaches for, and the only obvious hole in this set.
|
|
236
|
+
const f = Math.round(t * 60);
|
|
237
|
+
const dx = (rnd(f) - 0.5) * ctx.canvas.width * 0.012;
|
|
238
|
+
const dy = (rnd(f + 51) - 0.5) * ctx.canvas.width * 0.012;
|
|
239
|
+
ctx.save();
|
|
240
|
+
ctx.globalCompositeOperation = 'copy';
|
|
241
|
+
ctx.drawImage(ctx.canvas, dx, dy);
|
|
242
|
+
ctx.restore();
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
letterbox: {
|
|
247
|
+
label: 'Letterbox - cinematic bars',
|
|
248
|
+
overlay: (ctx, { W, H }) => blended(ctx, 'source-over', 1, (c) => {
|
|
249
|
+
const bar = Math.round(H * 0.055);
|
|
250
|
+
c.fillStyle = '#000000';
|
|
251
|
+
c.fillRect(0, 0, W, bar);
|
|
252
|
+
c.fillRect(0, H - bar, W, bar);
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// ── Particles ──────────────────────────────────────────────────────────────────────────────────
|
|
257
|
+
'falling-snow': {
|
|
258
|
+
label: 'Falling snow',
|
|
259
|
+
overlay: (ctx, o) => blended(ctx, 'source-over', 0.85, (c) => particles(c, o, {
|
|
260
|
+
count: 60, seed: 3, speed: 55, size: o.W * 0.006, drift: 1,
|
|
261
|
+
draw: (cc, x, y, r) => { cc.fillStyle = 'rgba(255,255,255,0.9)'; cc.beginPath(); cc.arc(x, y, r, 0, Math.PI * 2); cc.fill(); },
|
|
262
|
+
})),
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
'sparkles-fireflies': {
|
|
266
|
+
label: 'Sparkles / fireflies',
|
|
267
|
+
overlay: (ctx, o) => blended(ctx, 'screen', 0.9, (c) => particles(c, o, {
|
|
268
|
+
count: 34, seed: 21, speed: -18, size: o.W * 0.008, drift: 1.6,
|
|
269
|
+
draw: (cc, x, y, r, s) => {
|
|
270
|
+
const tw = 0.35 + 0.65 * softPeak(((o.t * 0.7 + rnd(s)) % 1));
|
|
271
|
+
cc.fillStyle = hexA('#fff7c2', tw);
|
|
272
|
+
cc.beginPath();
|
|
273
|
+
cc.arc(x, y, r, 0, Math.PI * 2);
|
|
274
|
+
cc.fill();
|
|
275
|
+
},
|
|
276
|
+
})),
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
'heart-drift': {
|
|
280
|
+
label: 'Drifting hearts',
|
|
281
|
+
overlay: (ctx, o) => blended(ctx, 'source-over', 0.8, (c) => particles(c, o, {
|
|
282
|
+
count: 16, seed: 41, speed: -26, size: o.W * 0.016, drift: 1.4,
|
|
283
|
+
draw: (cc, x, y, r) => {
|
|
284
|
+
cc.fillStyle = 'rgba(244,114,182,0.85)';
|
|
285
|
+
cc.beginPath();
|
|
286
|
+
for (let i = 0; i <= 24; i++) {
|
|
287
|
+
const a = (i / 24) * Math.PI * 2;
|
|
288
|
+
const hx = 16 * Math.pow(Math.sin(a), 3);
|
|
289
|
+
const hy = -(13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a));
|
|
290
|
+
const px = x + (hx * r) / 16;
|
|
291
|
+
const py = y + (hy * r) / 16;
|
|
292
|
+
if (i === 0) cc.moveTo(px, py); else cc.lineTo(px, py);
|
|
293
|
+
}
|
|
294
|
+
cc.closePath();
|
|
295
|
+
cc.fill();
|
|
296
|
+
},
|
|
297
|
+
})),
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
'confetti-drift': {
|
|
301
|
+
label: 'Drifting confetti',
|
|
302
|
+
overlay: (ctx, o) => blended(ctx, 'source-over', 0.9, (c) => particles(c, o, {
|
|
303
|
+
count: 40, seed: 61, speed: 42, size: o.W * 0.012, drift: 1.8,
|
|
304
|
+
draw: (cc, x, y, r, s) => {
|
|
305
|
+
const colours = [accent(o, '#22c55e'), '#f59e0b', '#38bdf8', '#f472b6', '#facc15'];
|
|
306
|
+
cc.save();
|
|
307
|
+
cc.translate(x, y);
|
|
308
|
+
cc.rotate(rnd(s + 9) * Math.PI * 2 + o.t * 2);
|
|
309
|
+
cc.fillStyle = colours[s % colours.length];
|
|
310
|
+
cc.fillRect(-r / 2, -r / 4, r, r / 2);
|
|
311
|
+
cc.restore();
|
|
312
|
+
},
|
|
313
|
+
})),
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
'clouds-drift': {
|
|
317
|
+
label: 'Drifting clouds',
|
|
318
|
+
overlay: (ctx, o) => blended(ctx, 'screen', 0.28, (c) => {
|
|
319
|
+
c.filter = 'blur(18px)';
|
|
320
|
+
particles(c, o, {
|
|
321
|
+
count: 10, seed: 77, speed: -9, size: o.W * 0.16, drift: 2.2,
|
|
322
|
+
draw: (cc, x, y, r) => { cc.fillStyle = 'rgba(255,255,255,0.6)'; cc.beginPath(); cc.arc(x, y, r, 0, Math.PI * 2); cc.fill(); },
|
|
323
|
+
});
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Resolve an effect id, with a helpful error listing what's available. */
|
|
329
|
+
export function effectFor(id) {
|
|
330
|
+
const e = EFFECTS[id || 'none'];
|
|
331
|
+
if (!e) throw new Error(`Unknown effect "${id}". Available: ${Object.keys(EFFECTS).join(', ')}.`);
|
|
332
|
+
return e;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const EFFECT_IDS = Object.keys(EFFECTS);
|
package/src/encode.mjs
CHANGED
|
@@ -1,44 +1,70 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared H.264 encoder — one place every video builder (bleed / reel / premium) pipes raw RGBA frames to.
|
|
3
|
-
* Handles the store-safe encode (High profile at the target level, yuv420p, faststart) and the optional
|
|
4
|
-
* MUSIC bed with the same knobs across all styles: offset into the track, fade-in / fade-out, volume.
|
|
5
|
-
*
|
|
6
|
-
* music = { path, offset=0, fadeIn=0.6, fadeOut=0.8, volume=1 } (all optional except path)
|
|
7
|
-
*/
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
|
|
11
|
-
/** Start the ffmpeg process reading rawvideo from stdin. Returns { proc, done, hasAudio }. */
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared H.264 encoder — one place every video builder (bleed / reel / premium) pipes raw RGBA frames to.
|
|
3
|
+
* Handles the store-safe encode (High profile at the target level, yuv420p, faststart) and the optional
|
|
4
|
+
* MUSIC bed with the same knobs across all styles: offset into the track, fade-in / fade-out, volume.
|
|
5
|
+
*
|
|
6
|
+
* music = { path, offset=0, fadeIn=0.6, fadeOut=0.8, volume=1 } (all optional except path)
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/** Start the ffmpeg process reading rawvideo from stdin. Returns { proc, done, hasAudio }. */
|
|
12
|
+
/**
|
|
13
|
+
* Smallest H.264 level that fits this frame size + rate (macroblocks and MB/s per Annex A), floored at
|
|
14
|
+
* the target's own level so we never *under*-declare what a store asked for.
|
|
15
|
+
*/
|
|
16
|
+
function h264Level(W, H, fps, floor) {
|
|
17
|
+
const mbs = Math.ceil(W / 16) * Math.ceil(H / 16);
|
|
18
|
+
const rate = mbs * fps;
|
|
19
|
+
const TABLE = [
|
|
20
|
+
['3.0', 1620, 40500], ['3.1', 3600, 108000], ['3.2', 5120, 216000],
|
|
21
|
+
['4.0', 8192, 245760], ['4.1', 8192, 245760], ['4.2', 8704, 522240],
|
|
22
|
+
['5.0', 22080, 589824], ['5.1', 36864, 983040], ['5.2', 36864, 2073600],
|
|
23
|
+
['6.0', 139264, 4177920],
|
|
24
|
+
];
|
|
25
|
+
const fit = TABLE.find(([, maxMbs, maxRate]) => mbs <= maxMbs && rate <= maxRate);
|
|
26
|
+
const chosen = fit ? fit[0] : '6.2';
|
|
27
|
+
return Number(chosen) > Number(floor || 0) ? chosen : floor;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function spawnEncoder({ W, H, fps, spec, outFile, music, totalDur }) {
|
|
31
|
+
const hasAudio = !!(music && music.path && fs.existsSync(music.path));
|
|
32
|
+
const args = [
|
|
33
|
+
'-y', '-loglevel', 'error',
|
|
34
|
+
'-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', '-',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
if (hasAudio) {
|
|
38
|
+
const offset = Number(music.offset) || 0;
|
|
39
|
+
if (offset > 0) args.push('-ss', String(offset)); // seek INTO the track before it's used as input
|
|
40
|
+
args.push('-i', music.path);
|
|
41
|
+
const vol = music.volume ?? 1;
|
|
42
|
+
const fi = music.fadeIn ?? 0.6;
|
|
43
|
+
const fo = music.fadeOut ?? 0.8;
|
|
44
|
+
const outStart = Math.max(0, totalDur - fo).toFixed(2);
|
|
45
|
+
args.push('-af', `volume=${vol},afade=t=in:st=0:d=${fi},afade=t=out:st=${outStart}:d=${fo}`, '-shortest');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// LEVEL must follow the ACTUAL frame size, not the target's default. A device `size` override (e.g. the
|
|
49
|
+
// Mac reel at 2880×1800) exceeds the base target's level, and libx264 will still stamp the requested
|
|
50
|
+
// level_idc into the SPS — a stream that says it's Level 4.1 while carrying 20k macroblocks. Players
|
|
51
|
+
// mostly cope; strict validators and hardware decoders need not. Recompute, and never go below the spec.
|
|
52
|
+
const level = h264Level(W, H, fps, spec.level);
|
|
53
|
+
args.push(
|
|
54
|
+
'-c:v', 'libx264', '-profile:v', spec.profile, '-level:v', level,
|
|
55
|
+
// CRF 15, not 17: the stores re-encode whatever you upload, so the master needs headroom — flat UI
|
|
56
|
+
// gradients are exactly where banding appears after that second pass.
|
|
57
|
+
'-pix_fmt', 'yuv420p', '-crf', '15', '-maxrate', '12M', '-bufsize', '12M',
|
|
58
|
+
'-preset', 'slow', '-r', String(fps), '-movflags', '+faststart',
|
|
59
|
+
// Apple's App Preview spec: stereo AAC at 256 kbps. `-ac 2` guards a mono source.
|
|
60
|
+
...(hasAudio ? ['-c:a', 'aac', '-b:a', '256k', '-ac', '2'] : ['-an']),
|
|
61
|
+
outFile,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const proc = spawn(process.env.FFMPEG || 'ffmpeg', args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
65
|
+
const done = new Promise((res, rej) => {
|
|
66
|
+
proc.on('close', (code) => (code === 0 ? res() : rej(new Error(`ffmpeg exited ${code}`))));
|
|
67
|
+
proc.on('error', (e) => rej(new Error(`ffmpeg failed to start (${e.message}). Is ffmpeg on PATH or $FFMPEG set?`)));
|
|
68
|
+
});
|
|
69
|
+
return { proc, done, hasAudio };
|
|
70
|
+
}
|
package/src/fonts.mjs
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Font registration for the canvas renderer. Everything renders under one family name — "Brand" — so the
|
|
3
|
-
* engine never branches on which file won. Resolution order:
|
|
4
|
-
* 1. explicit paths the project passes in (`brand.fontPaths` — e.g. your own Inter/brand TTFs)
|
|
5
|
-
* 2. the host system font: San Francisco on macOS (the Apple system face — ideal for store assets),
|
|
6
|
-
* Segoe UI on Windows, DejaVu Sans on Linux.
|
|
7
|
-
* No font is bundled, so there is nothing to license or ship; macOS (where you build App Store assets)
|
|
8
|
-
* always resolves to SF.
|
|
9
|
-
*/
|
|
10
|
-
import { GlobalFonts } from '@napi-rs/canvas';
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
|
|
13
|
-
const SYSTEM_CANDIDATES = [
|
|
14
|
-
'/System/Library/Fonts/SFNS.ttf', // macOS — San Francisco (variable, all weights)
|
|
15
|
-
'/System/Library/Fonts/SFNSDisplay.ttf',
|
|
16
|
-
'C:/Windows/Fonts/segoeui.ttf', // Windows — Segoe UI
|
|
17
|
-
'C:/Windows/Fonts/segoeuib.ttf',
|
|
18
|
-
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
|
|
19
|
-
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Register the "Brand" family. `fontPaths` (optional) are tried first, in order.
|
|
24
|
-
* Returns true if at least one face registered (else the canvas default is used, with a warning).
|
|
25
|
-
*/
|
|
26
|
-
export function registerFonts(fontPaths = []) {
|
|
27
|
-
let any = false;
|
|
28
|
-
for (const p of [...fontPaths, ...SYSTEM_CANDIDATES]) {
|
|
29
|
-
if (p && fs.existsSync(p)) {
|
|
30
|
-
try {
|
|
31
|
-
GlobalFonts.registerFromPath(p, 'Brand');
|
|
32
|
-
any = true;
|
|
33
|
-
} catch {
|
|
34
|
-
/* skip unreadable */
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (!any) {
|
|
39
|
-
console.warn('[zdymak] No brand/system font found — captions use the canvas default. Pass brand.fontPaths to fix.');
|
|
40
|
-
}
|
|
41
|
-
return any;
|
|
42
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Font registration for the canvas renderer. Everything renders under one family name — "Brand" — so the
|
|
3
|
+
* engine never branches on which file won. Resolution order:
|
|
4
|
+
* 1. explicit paths the project passes in (`brand.fontPaths` — e.g. your own Inter/brand TTFs)
|
|
5
|
+
* 2. the host system font: San Francisco on macOS (the Apple system face — ideal for store assets),
|
|
6
|
+
* Segoe UI on Windows, DejaVu Sans on Linux.
|
|
7
|
+
* No font is bundled, so there is nothing to license or ship; macOS (where you build App Store assets)
|
|
8
|
+
* always resolves to SF.
|
|
9
|
+
*/
|
|
10
|
+
import { GlobalFonts } from '@napi-rs/canvas';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
|
|
13
|
+
const SYSTEM_CANDIDATES = [
|
|
14
|
+
'/System/Library/Fonts/SFNS.ttf', // macOS — San Francisco (variable, all weights)
|
|
15
|
+
'/System/Library/Fonts/SFNSDisplay.ttf',
|
|
16
|
+
'C:/Windows/Fonts/segoeui.ttf', // Windows — Segoe UI
|
|
17
|
+
'C:/Windows/Fonts/segoeuib.ttf',
|
|
18
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
|
|
19
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register the "Brand" family. `fontPaths` (optional) are tried first, in order.
|
|
24
|
+
* Returns true if at least one face registered (else the canvas default is used, with a warning).
|
|
25
|
+
*/
|
|
26
|
+
export function registerFonts(fontPaths = []) {
|
|
27
|
+
let any = false;
|
|
28
|
+
for (const p of [...fontPaths, ...SYSTEM_CANDIDATES]) {
|
|
29
|
+
if (p && fs.existsSync(p)) {
|
|
30
|
+
try {
|
|
31
|
+
GlobalFonts.registerFromPath(p, 'Brand');
|
|
32
|
+
any = true;
|
|
33
|
+
} catch {
|
|
34
|
+
/* skip unreadable */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!any) {
|
|
39
|
+
console.warn('[zdymak] No brand/system font found — captions use the canvas default. Pass brand.fontPaths to fix.');
|
|
40
|
+
}
|
|
41
|
+
return any;
|
|
42
|
+
}
|