zdymak 0.3.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 -0
- package/README.md +211 -0
- package/SKILL.md +84 -0
- package/bin/zdymak.mjs +3 -0
- package/package.json +40 -0
- package/src/canvas.mjs +116 -0
- package/src/capture/index.mjs +91 -0
- package/src/cli.mjs +155 -0
- package/src/config.mjs +95 -0
- package/src/encode.mjs +44 -0
- package/src/fonts.mjs +42 -0
- package/src/frames.mjs +168 -0
- package/src/graphic.mjs +77 -0
- package/src/index.mjs +12 -0
- package/src/png.mjs +50 -0
- package/src/premium.mjs +324 -0
- package/src/reel.mjs +245 -0
- package/src/screenshots.mjs +64 -0
- package/src/specs.mjs +88 -0
- package/src/still.mjs +62 -0
- package/src/video.mjs +240 -0
package/src/specs.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store spec matrix — the single source of truth for every store's asset requirements.
|
|
3
|
+
*
|
|
4
|
+
* VERIFY before each release: store forms change. Sources are cited inline; re-check the Apple and
|
|
5
|
+
* Google help pages when a submission bounces on dimensions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Video targets that produce an actual .mp4 file. */
|
|
9
|
+
export const VIDEO_TARGETS = {
|
|
10
|
+
'appstore-preview': {
|
|
11
|
+
store: 'App Store',
|
|
12
|
+
label: 'App Store App Preview (iPhone 6.5" + 6.9")',
|
|
13
|
+
w: 886,
|
|
14
|
+
h: 1920,
|
|
15
|
+
fps: 30,
|
|
16
|
+
codec: 'h264',
|
|
17
|
+
profile: 'high',
|
|
18
|
+
level: '4.0', // Apple requires High @ 4.0 for App Previews
|
|
19
|
+
minSec: 15,
|
|
20
|
+
maxSec: 30,
|
|
21
|
+
slot: 'App Store Connect → your app → (localization) → App Previews. One 886×1920 file fills BOTH the 6.5" and 6.9" slots.',
|
|
22
|
+
// Apple: https://developer.apple.com/help/app-store-connect/reference/app-preview-specifications/
|
|
23
|
+
},
|
|
24
|
+
'play-promo': {
|
|
25
|
+
store: 'Google Play',
|
|
26
|
+
label: 'Google Play promo video (portrait 1080×1920 → upload to YouTube)',
|
|
27
|
+
w: 1080,
|
|
28
|
+
h: 1920,
|
|
29
|
+
fps: 30,
|
|
30
|
+
codec: 'h264',
|
|
31
|
+
profile: 'high',
|
|
32
|
+
level: '4.1', // 1080×1920@30 needs level ≥4.1
|
|
33
|
+
minSec: null,
|
|
34
|
+
maxSec: null,
|
|
35
|
+
slot: 'Play does NOT take a file: upload this to YouTube, then paste the URL in Play Console → Main store listing → Preview video.',
|
|
36
|
+
// Google: https://support.google.com/googleplay/android-developer/answer/9866151
|
|
37
|
+
},
|
|
38
|
+
'social-reel': {
|
|
39
|
+
store: 'Web / Social',
|
|
40
|
+
label: 'Device-framed promo reel (1080×1920) — website, X/IG/TikTok, or YouTube for Play',
|
|
41
|
+
w: 1080,
|
|
42
|
+
h: 1920,
|
|
43
|
+
fps: 30,
|
|
44
|
+
codec: 'h264',
|
|
45
|
+
profile: 'high',
|
|
46
|
+
level: '4.1',
|
|
47
|
+
minSec: null,
|
|
48
|
+
maxSec: null,
|
|
49
|
+
style: 'reel', // ← device bezel + brand background + logo bookends (buildReel, not buildVideo)
|
|
50
|
+
slot: 'For your website, X / Instagram / TikTok, or YouTube (Play listing). NOT the App Store App Preview slot — the device bezel is rejected there.',
|
|
51
|
+
},
|
|
52
|
+
'premium-reel': {
|
|
53
|
+
store: 'Web / Social',
|
|
54
|
+
label: 'Premium Apple-style reel (1080×1920) — matte + vignette + label pills + palette-aware cuts',
|
|
55
|
+
w: 1080,
|
|
56
|
+
h: 1920,
|
|
57
|
+
fps: 30,
|
|
58
|
+
codec: 'h264',
|
|
59
|
+
profile: 'high',
|
|
60
|
+
level: '4.1',
|
|
61
|
+
minSec: null,
|
|
62
|
+
maxSec: null,
|
|
63
|
+
style: 'premium', // ← the Apple editing-vocabulary preset (buildPremium); themeable via `theme`
|
|
64
|
+
slot: 'The "one device, many apps" marketing reel for web/social/YouTube. Full-bleed screens on a brand matte (no bezel) — but the label pill/matte make it a marketing asset, not an App Store App Preview.',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Screenshot / graphic targets that produce still images (v0.2 — dimensions locked in now). */
|
|
69
|
+
export const IMAGE_TARGETS = {
|
|
70
|
+
'appstore-iphone-6.9': { store: 'App Store', w: 1320, h: 2868, alpha: false, format: 'png', label: 'iPhone 6.9" (largest — Apple scales down for smaller iPhones)' },
|
|
71
|
+
'appstore-iphone-6.5': { store: 'App Store', accepts: [[1242, 2688], [1284, 2778]], alpha: false, format: 'png', label: 'iPhone 6.5" (accepts 1242×2688 or 1284×2778)' },
|
|
72
|
+
'appstore-ipad-13': { store: 'App Store', w: 2064, h: 2752, alpha: false, format: 'png', label: 'iPad 13" (largest iPad class)' },
|
|
73
|
+
'appstore-watch': { store: 'App Store', accepts: [[422, 514], [410, 502], [416, 496], [396, 484], [368, 448], [312, 390]], alpha: false, format: 'png', label: 'Apple Watch (any one accepted size; alpha NOT allowed)' },
|
|
74
|
+
'appstore-mac': { store: 'Mac App Store', w: 2880, h: 1800, alpha: false, format: 'png', label: 'Mac (2880×1800, 16:10 landscape — largest required class)' },
|
|
75
|
+
'play-phone': { store: 'Google Play', w: 1080, h: 1920, alpha: false, format: 'png', label: 'Play phone (9:16, min 1080px, no alpha)' },
|
|
76
|
+
'play-tablet': { store: 'Google Play', w: 2560, h: 1440, alpha: false, format: 'png', label: 'Play tablet (16:9, 1080–7680px, no alpha)' },
|
|
77
|
+
'play-feature-graphic': { store: 'Google Play', w: 1024, h: 500, alpha: false, format: 'png', graphic: true, label: 'Play feature graphic (1024×500, no alpha) — brand banner, not a per-scene shot' },
|
|
78
|
+
'play-icon': { store: 'Google Play', w: 512, h: 512, alpha: true, format: 'png', label: 'Play app icon (32-bit PNG, alpha OK)' },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Resolve a video target by id, throwing a helpful error listing valid ids. */
|
|
82
|
+
export function videoTarget(id) {
|
|
83
|
+
const t = VIDEO_TARGETS[id];
|
|
84
|
+
if (!t) {
|
|
85
|
+
throw new Error(`Unknown video target "${id}". Valid: ${Object.keys(VIDEO_TARGETS).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
return t;
|
|
88
|
+
}
|
package/src/still.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Still-image renderers — one store screenshot per scene, in a chosen style. Aspect-agnostic (cover-fit),
|
|
3
|
+
* so the same renderers serve iPhone, iPad, Mac and Watch dimensions.
|
|
4
|
+
*
|
|
5
|
+
* premium — the app screen floats on a brand matte (glow + vignette) with a bottom title pill.
|
|
6
|
+
* bleed — the app screen fills the frame, with an optional lower-third caption. Best for Watch (raw).
|
|
7
|
+
*
|
|
8
|
+
* (Device-FRAMED stills — iPhone/iPad bezel, Mac window, Watch ring — are the next addition; `framed`
|
|
9
|
+
* currently falls back to `bleed`.)
|
|
10
|
+
*/
|
|
11
|
+
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|
12
|
+
import { premiumStill } from './premium.mjs';
|
|
13
|
+
import { drawCaption, hexA } from './canvas.mjs';
|
|
14
|
+
|
|
15
|
+
function coverCanvas(img, W, H) {
|
|
16
|
+
const c = createCanvas(W, H);
|
|
17
|
+
const ctx = c.getContext('2d');
|
|
18
|
+
const ia = img.height / img.width;
|
|
19
|
+
let dw = W;
|
|
20
|
+
let dh = W * ia;
|
|
21
|
+
if (dh < H) {
|
|
22
|
+
dh = H;
|
|
23
|
+
dw = H / ia;
|
|
24
|
+
}
|
|
25
|
+
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bleedStill({ W, H, img, caption, brand }) {
|
|
30
|
+
const c = createCanvas(W, H);
|
|
31
|
+
const ctx = c.getContext('2d');
|
|
32
|
+
ctx.fillStyle = brand.ink;
|
|
33
|
+
ctx.fillRect(0, 0, W, H);
|
|
34
|
+
ctx.drawImage(coverCanvas(img, W, H), 0, 0);
|
|
35
|
+
if (caption.title || caption.sub) {
|
|
36
|
+
const g = ctx.createLinearGradient(0, H * 0.55, 0, H);
|
|
37
|
+
g.addColorStop(0, hexA(brand.ink, 0));
|
|
38
|
+
g.addColorStop(1, hexA(brand.ink, 0.85));
|
|
39
|
+
ctx.fillStyle = g;
|
|
40
|
+
ctx.fillRect(0, H * 0.55, W, H * 0.45);
|
|
41
|
+
drawCaption(ctx, {
|
|
42
|
+
title: caption.title,
|
|
43
|
+
subtitle: caption.sub,
|
|
44
|
+
centerX: W / 2,
|
|
45
|
+
top: H * 0.78,
|
|
46
|
+
maxWidth: W * 0.86,
|
|
47
|
+
titleSize: Math.round(W * 0.066),
|
|
48
|
+
subSize: Math.round(W * 0.036),
|
|
49
|
+
titleColor: brand.title,
|
|
50
|
+
subColor: brand.sub,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Render one still to a canvas. `style`: premium | framed (device bezel) | bleed. */
|
|
57
|
+
export async function renderStill(style, { W, H, imgPath, caption, brand, theme, frame }) {
|
|
58
|
+
const img = await loadImage(imgPath);
|
|
59
|
+
if (style === 'framed') return premiumStill({ W, H, img, caption, brand, theme, frame: frame || 'phone' });
|
|
60
|
+
if (style === 'premium') return premiumStill({ W, H, img, caption, brand, theme });
|
|
61
|
+
return bleedStill({ W, H, img, caption, brand });
|
|
62
|
+
}
|
package/src/video.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The video engine — turns a list of screenshots + captions into a premium, spec-compliant store preview.
|
|
3
|
+
*
|
|
4
|
+
* Motion is deliberately cinematic, not a flat slideshow (technique study of production Apple-style reels):
|
|
5
|
+
* • Camera = an OVER-DAMPED SPRING that eases toward the screen and SETTLES (velocity→0) — a dolly, not
|
|
6
|
+
* a constant-rate Ken-Burns zoom. Each scene MOVES for ~1s then HOLDS ("cut on motion, rest on it").
|
|
7
|
+
* • Motion VARIES per scene (push-in / pull-back / vertical & lateral drift) so no effect repeats.
|
|
8
|
+
* • Captions live OUTSIDE the camera transform — steady while the screen drifts behind them (parallax),
|
|
9
|
+
* and animate in with a small spring rise+fade (kinetic typography).
|
|
10
|
+
*
|
|
11
|
+
* Output is full-bleed (no device bezel — Apple rejects bezels in App Previews) at the target's exact
|
|
12
|
+
* resolution, H.264 High @ the target's level, yuv420p, faststart, silent.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|
17
|
+
import { spawnEncoder } from './encode.mjs';
|
|
18
|
+
|
|
19
|
+
const smooth = (t) => t * t * (3 - 2 * t);
|
|
20
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
21
|
+
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
|
22
|
+
|
|
23
|
+
/** Numerically integrated damped spring 0→1 (semi-implicit Euler) → per-frame lookup array. Over-damped
|
|
24
|
+
* configs glide and settle (camera); lighter damping gives a small overshoot (caption pop). */
|
|
25
|
+
function springSeries(frames, fps, { stiffness = 55, damping = 24, mass = 1.5 } = {}) {
|
|
26
|
+
const dt = 1 / fps;
|
|
27
|
+
let x = 0;
|
|
28
|
+
let v = 0;
|
|
29
|
+
const out = new Array(frames);
|
|
30
|
+
for (let f = 0; f < frames; f++) {
|
|
31
|
+
const a = (stiffness * (1 - x) - damping * v) / mass;
|
|
32
|
+
v += a * dt;
|
|
33
|
+
x += v * dt;
|
|
34
|
+
out[f] = x;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Per-scene camera transform at spring-progress p∈[0,1]. Drifts carry a base zoom so the screen still
|
|
40
|
+
* covers the frame when translated (no empty edge). Gentle by design (≤8% scale, ≤18px). */
|
|
41
|
+
function motion(kind, p) {
|
|
42
|
+
switch (kind) {
|
|
43
|
+
case 'pushInSlow': return { scale: lerp(1.0, 1.08, p), tx: 0, ty: 0 };
|
|
44
|
+
case 'pushIn': return { scale: lerp(1.0, 1.06, p), tx: 0, ty: 0 };
|
|
45
|
+
case 'pullBack': return { scale: lerp(1.08, 1.0, p), tx: 0, ty: 0 };
|
|
46
|
+
case 'pullBackSlow': return { scale: lerp(1.07, 1.0, p), tx: 0, ty: 0 };
|
|
47
|
+
case 'driftUp': return { scale: 1.06, tx: 0, ty: lerp(16, -16, p) };
|
|
48
|
+
case 'driftDown': return { scale: 1.06, tx: 0, ty: lerp(-16, 16, p) };
|
|
49
|
+
case 'driftRight': return { scale: 1.06, tx: lerp(-16, 16, p), ty: 0 };
|
|
50
|
+
case 'driftLeft': return { scale: 1.06, tx: lerp(16, -16, p), ty: 0 };
|
|
51
|
+
case 'still': return { scale: 1.0, tx: 0, ty: 0 };
|
|
52
|
+
default: return { scale: lerp(1.0, 1.06, p), tx: 0, ty: 0 }; // sensible default = gentle push-in
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A rotation of moves so a project that doesn't specify per-scene motion still gets varied rhythm. */
|
|
57
|
+
const AUTO_MOVES = ['pushInSlow', 'driftUp', 'pullBack', 'pushIn', 'driftRight', 'pushIn', 'pullBackSlow'];
|
|
58
|
+
|
|
59
|
+
/** Cover-fit a source image to fill W×H (crop the tiny overflow) — full-bleed, no letterbox. */
|
|
60
|
+
function coverCanvas(img, W, H) {
|
|
61
|
+
const c = createCanvas(W, H);
|
|
62
|
+
const ctx = c.getContext('2d');
|
|
63
|
+
const ia = img.height / img.width;
|
|
64
|
+
let dw = W;
|
|
65
|
+
let dh = W * ia;
|
|
66
|
+
if (dh < H) {
|
|
67
|
+
dh = H;
|
|
68
|
+
dw = H / ia;
|
|
69
|
+
}
|
|
70
|
+
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
71
|
+
return c;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const font = (size, weight = 'bold') => `${weight} ${size}px Brand`;
|
|
75
|
+
|
|
76
|
+
/** Word-wrap `text` to at most `maxWidth`, returning the line array (ctx.font must already be set). */
|
|
77
|
+
function wrapLines(ctx, text, maxWidth) {
|
|
78
|
+
const words = String(text).split(/\s+/);
|
|
79
|
+
const lines = [];
|
|
80
|
+
let line = '';
|
|
81
|
+
for (const w of words) {
|
|
82
|
+
const test = line ? `${line} ${w}` : w;
|
|
83
|
+
if (ctx.measureText(test).width > maxWidth && line) {
|
|
84
|
+
lines.push(line);
|
|
85
|
+
line = w;
|
|
86
|
+
} else {
|
|
87
|
+
line = test;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (line) lines.push(line);
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Draw the legibility scrim + caption at a FIXED position (never touched by the camera transform). */
|
|
95
|
+
function drawCaptionLayer(ctx, W, H, caption, brand, alpha, rise) {
|
|
96
|
+
if (alpha <= 0.001 || (!caption.title && !caption.sub)) return;
|
|
97
|
+
ctx.save();
|
|
98
|
+
ctx.globalAlpha = alpha;
|
|
99
|
+
|
|
100
|
+
// Bottom scrim: transparent → deep ink over the lower half, only where the caption sits.
|
|
101
|
+
const g = ctx.createLinearGradient(0, H * 0.5, 0, H);
|
|
102
|
+
g.addColorStop(0, hexA(brand.ink, 0));
|
|
103
|
+
g.addColorStop(0.55, hexA(brand.ink, 0.55));
|
|
104
|
+
g.addColorStop(1, hexA(brand.ink, 0.9));
|
|
105
|
+
ctx.fillStyle = g;
|
|
106
|
+
ctx.fillRect(0, H * 0.5, W, H * 0.5);
|
|
107
|
+
|
|
108
|
+
const titleSize = Math.round(W * 0.066); // 58 @ 886
|
|
109
|
+
const subSize = Math.round(W * 0.036); // 32 @ 886
|
|
110
|
+
const maxWidth = W * 0.86;
|
|
111
|
+
const cx = W / 2;
|
|
112
|
+
let y = H * 0.75 + rise;
|
|
113
|
+
|
|
114
|
+
ctx.textAlign = 'center';
|
|
115
|
+
ctx.textBaseline = 'top';
|
|
116
|
+
if (caption.title) {
|
|
117
|
+
ctx.font = font(titleSize, 'bold');
|
|
118
|
+
ctx.fillStyle = brand.title;
|
|
119
|
+
for (const line of wrapLines(ctx, caption.title, maxWidth)) {
|
|
120
|
+
ctx.fillText(line, cx, y);
|
|
121
|
+
y += Math.round(titleSize * 1.12);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (caption.sub) {
|
|
125
|
+
y += Math.round(titleSize * 0.18);
|
|
126
|
+
ctx.font = font(subSize, 'regular');
|
|
127
|
+
ctx.fillStyle = brand.sub;
|
|
128
|
+
for (const line of wrapLines(ctx, caption.sub, maxWidth)) {
|
|
129
|
+
ctx.fillText(line, cx, y);
|
|
130
|
+
y += Math.round(subSize * 1.3);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
ctx.restore();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** #rrggbb + alpha → rgba() string. */
|
|
137
|
+
function hexA(hex, a) {
|
|
138
|
+
const n = parseInt(String(hex).replace('#', ''), 16);
|
|
139
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build one preview video.
|
|
145
|
+
* @param {object} o
|
|
146
|
+
* @param {Array} o.scenes [{ image: <path>, title?, sub?, move? }]
|
|
147
|
+
* @param {object} o.spec one VIDEO_TARGETS entry (w,h,fps,profile,level,minSec,maxSec,label)
|
|
148
|
+
* @param {object} o.brand { ink, title, sub } (hex colours)
|
|
149
|
+
* @param {string} o.outFile absolute path to write the .mp4
|
|
150
|
+
* @param {number} [o.sceneDur=3.1] seconds each scene holds
|
|
151
|
+
* @param {number} [o.xfade=0.32] cross-dissolve seconds
|
|
152
|
+
* @returns {Promise<{outFile, totalDur, frames, warnings}>}
|
|
153
|
+
*/
|
|
154
|
+
export async function buildVideo({ scenes, spec, brand, outFile, sceneDur = 3.1, xfade = 0.32, music }) {
|
|
155
|
+
if (!scenes?.length) throw new Error('buildVideo: no scenes');
|
|
156
|
+
const { w: W, h: H, fps } = spec;
|
|
157
|
+
const warnings = [];
|
|
158
|
+
|
|
159
|
+
// Springs: one slow cinematic glide for the camera, one lively rise for captions.
|
|
160
|
+
const camFrames = Math.ceil(sceneDur * fps) + 2;
|
|
161
|
+
const CAM = springSeries(camFrames, fps, { stiffness: 55, damping: 24, mass: 1.5 });
|
|
162
|
+
const CAP = springSeries(Math.ceil(0.9 * fps) + 2, fps, { stiffness: 120, damping: 18, mass: 1 });
|
|
163
|
+
const camAt = (t) => CAM[Math.min(CAM.length - 1, Math.max(0, Math.round(t * fps)))];
|
|
164
|
+
const capAt = (t) => CAP[Math.min(CAP.length - 1, Math.max(0, Math.round(t * fps)))];
|
|
165
|
+
|
|
166
|
+
// Load + full-bleed each screenshot; assign a varied move if the scene didn't specify one.
|
|
167
|
+
const clips = [];
|
|
168
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
169
|
+
const s = scenes[i];
|
|
170
|
+
if (!fs.existsSync(s.image)) throw new Error(`Screenshot not found: ${s.image}`);
|
|
171
|
+
const img = await loadImage(s.image);
|
|
172
|
+
clips.push({
|
|
173
|
+
screen: coverCanvas(img, W, H),
|
|
174
|
+
caption: { title: s.title || '', sub: s.sub || '' },
|
|
175
|
+
move: s.move || AUTO_MOVES[i % AUTO_MOVES.length],
|
|
176
|
+
dur: sceneDur,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const starts = [0];
|
|
181
|
+
let acc = 0;
|
|
182
|
+
for (let i = 0; i < clips.length - 1; i++) {
|
|
183
|
+
acc += clips[i].dur - xfade;
|
|
184
|
+
starts.push(acc);
|
|
185
|
+
}
|
|
186
|
+
const totalDur = starts[starts.length - 1] + clips[clips.length - 1].dur;
|
|
187
|
+
const totalFrames = Math.round(totalDur * fps);
|
|
188
|
+
|
|
189
|
+
if (spec.minSec && totalDur < spec.minSec) {
|
|
190
|
+
warnings.push(`Duration ${totalDur.toFixed(1)}s is under ${spec.store} minimum ${spec.minSec}s — add scenes or raise sceneDur.`);
|
|
191
|
+
}
|
|
192
|
+
if (spec.maxSec && totalDur > spec.maxSec) {
|
|
193
|
+
warnings.push(`Duration ${totalDur.toFixed(1)}s exceeds ${spec.store} maximum ${spec.maxSec}s — drop scenes or lower sceneDur.`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const frame = createCanvas(W, H);
|
|
197
|
+
const fctx = frame.getContext('2d');
|
|
198
|
+
|
|
199
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
200
|
+
const { proc, done } = spawnEncoder({ W, H, fps, spec, outFile, music, totalDur });
|
|
201
|
+
|
|
202
|
+
for (let k = 0; k < totalFrames; k++) {
|
|
203
|
+
const t = k / fps;
|
|
204
|
+
fctx.clearRect(0, 0, W, H);
|
|
205
|
+
fctx.fillStyle = brand.ink;
|
|
206
|
+
fctx.fillRect(0, 0, W, H);
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < clips.length; i++) {
|
|
209
|
+
const start = starts[i];
|
|
210
|
+
const end = start + clips[i].dur;
|
|
211
|
+
if (t < start || t >= end) continue;
|
|
212
|
+
const localT = t - start;
|
|
213
|
+
const clipAlpha = i > 0 && localT < xfade ? smooth(localT / xfade) : 1;
|
|
214
|
+
|
|
215
|
+
// Camera: spring-eased move that settles then holds. Captions are NOT under this transform.
|
|
216
|
+
const { scale, tx, ty } = motion(clips[i].move, camAt(localT));
|
|
217
|
+
fctx.save();
|
|
218
|
+
fctx.globalAlpha = clipAlpha;
|
|
219
|
+
fctx.translate(W / 2 + tx, H / 2 + ty);
|
|
220
|
+
fctx.scale(scale, scale);
|
|
221
|
+
fctx.translate(-W / 2, -H / 2);
|
|
222
|
+
fctx.drawImage(clips[i].screen, 0, 0);
|
|
223
|
+
fctx.restore();
|
|
224
|
+
|
|
225
|
+
// Caption: kinetic rise+fade in over ~0.5s; gentle fade-out before the cut.
|
|
226
|
+
const inA = smooth(clamp01((localT - 0.12) / 0.5));
|
|
227
|
+
const rise = (1 - capAt(Math.max(0, localT - 0.12))) * 26;
|
|
228
|
+
const outA = smooth(clamp01((clips[i].dur - localT) / 0.4));
|
|
229
|
+
drawCaptionLayer(fctx, W, H, clips[i].caption, brand, clipAlpha * inA * outA, rise);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const buf = Buffer.from(fctx.getImageData(0, 0, W, H).data.buffer);
|
|
233
|
+
if (!proc.stdin.write(buf)) {
|
|
234
|
+
await new Promise((r) => proc.stdin.once('drain', r));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
proc.stdin.end();
|
|
238
|
+
await done;
|
|
239
|
+
return { outFile, totalDur, frames: totalFrames, warnings };
|
|
240
|
+
}
|