storyshot 0.1.3 → 0.4.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/README.md +110 -20
- package/bin/storyshot.mjs +10 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +94 -15
- package/src/audio.mjs +7 -1
- package/src/build.mjs +4 -3
- package/src/check.mjs +39 -8
- package/src/compose.mjs +236 -31
- package/src/config.mjs +50 -12
- package/src/init.mjs +7 -1
- package/src/inspect.mjs +12 -5
- package/src/mcp.mjs +1 -1
- package/src/outputs.mjs +89 -17
- package/src/record.mjs +361 -21
- package/src/setup.mjs +81 -2
- package/src/voice.mjs +3 -1
package/src/compose.mjs
CHANGED
|
@@ -18,14 +18,21 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
18
18
|
const { width: W, height: H, fps: FPS } = cfg.output;
|
|
19
19
|
const K = tl.scale; // css px -> output px
|
|
20
20
|
const ec0 = cfg.endCard;
|
|
21
|
-
|
|
21
|
+
// a card with bullets needs time to read them: they appear one by one, then hold
|
|
22
|
+
const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
|
|
23
|
+
// `think: N` (question cards): after the question is read out, N seconds with a countdown bar
|
|
24
|
+
const cardLen = (c, voice, min) => (c.duration === 'auto' || c.duration == null ? Math.max(min, (voice ?? 0) + 0.9, readTime(c)) + (c.think ?? 0) : c.duration);
|
|
25
|
+
const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(1.0, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0)) : ec0.duration;
|
|
26
|
+
const in0 = cfg.intro;
|
|
27
|
+
const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
|
|
28
|
+
const cards = []; // section cards: { i, t0, t1, card }
|
|
22
29
|
|
|
23
30
|
// --- 1. segments (one per visible step), split into motion / waiting pieces ------------
|
|
24
31
|
// Waiting (page loads, processing) is squeezed hard; cursor motion keeps a natural pace.
|
|
25
32
|
// `duration: auto` plays motion in real time and makes the step as long as its narration
|
|
26
33
|
// needs: extra time first shows more of the waiting, then holds the last frame.
|
|
27
34
|
const segs = [], pieces = [];
|
|
28
|
-
let acc =
|
|
35
|
+
let acc = INTRO, afterHidden = false; // the intro card plays over the first frame
|
|
29
36
|
for (const [i, s] of cfg.steps.entries()) {
|
|
30
37
|
const r = tl.steps[i];
|
|
31
38
|
if (s.hidden) { afterHidden = segs.length > 0; continue; }
|
|
@@ -45,10 +52,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
45
52
|
const Wt = parts.filter((p) => p[2]).reduce((x, p) => x + p[1] - p[0], 0);
|
|
46
53
|
let D, waitBudget, motionBudget, hold = 0;
|
|
47
54
|
if (s.duration === 'auto') {
|
|
48
|
-
const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + (s.sayAt ?? 0.15) +
|
|
55
|
+
const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + (s.sayAt ?? 0.15) + cfg.pace.tail : 0;
|
|
49
56
|
waitBudget = Math.min(Wt, cfg.maxWait);
|
|
50
57
|
motionBudget = M;
|
|
51
|
-
|
|
58
|
+
// a silent step holds its result too (docs GIFs: long enough to see it before the loop restarts)
|
|
59
|
+
const quiet = s.say ? 0 : cfg.clips.length ? 1.5 : cfg.pace.tail;
|
|
60
|
+
D = Math.max(M + waitBudget + quiet, voiceNeed, 0.8);
|
|
52
61
|
let extra = D - M - waitBudget;
|
|
53
62
|
const stretch = Math.min(extra, Wt - waitBudget);
|
|
54
63
|
waitBudget += stretch; extra -= stretch;
|
|
@@ -58,6 +67,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
58
67
|
waitBudget = M + Wt <= D ? Wt * D / (M + Wt) : M === 0 ? D : Math.min(Wt, Math.max(D - M, 0.15 * D));
|
|
59
68
|
motionBudget = D - waitBudget;
|
|
60
69
|
}
|
|
70
|
+
if (s.card) {
|
|
71
|
+
// a section card plays over the step's first frame, before the step
|
|
72
|
+
const C = cardLen(s.card, voiceLens.cards?.[i], 2.4);
|
|
73
|
+
pieces.push({ raw: [r.start, r.start], o0: acc, o1: acc + C });
|
|
74
|
+
cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
|
|
75
|
+
acc += C;
|
|
76
|
+
}
|
|
61
77
|
const g = { i, step: s, o0: acc, o1: acc + D, zoomBox: r.zoomBox, afterHidden, hold,
|
|
62
78
|
motionSpeed: M && motionBudget ? M / motionBudget : 1, waitSpeed: Wt && waitBudget ? Wt / waitBudget : 1 };
|
|
63
79
|
for (const [a, b, w] of parts) {
|
|
@@ -89,7 +105,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
89
105
|
const t = n / FPS;
|
|
90
106
|
while (pi + 1 < pieces.length && t >= pieces[pi].o1) pi++;
|
|
91
107
|
const p = pieces[pi];
|
|
92
|
-
const r = p.raw[0] + Math.min(1, (t - p.o0) / Math.max(1e-6, p.o1 - p.o0)) * (p.raw[1] - p.raw[0]);
|
|
108
|
+
const r = p.raw[0] + Math.min(1, Math.max(0, (t - p.o0) / Math.max(1e-6, p.o1 - p.o0))) * (p.raw[1] - p.raw[0]);
|
|
93
109
|
while (fi + 1 < tl.frames.length && tl.frames[fi + 1].t <= r) fi++;
|
|
94
110
|
fs.linkSync(path.join(cfg.work, 'raw', tl.frames[fi].file), path.join(SEQ, `${String(n).padStart(5, '0')}.jpg`));
|
|
95
111
|
}
|
|
@@ -100,17 +116,30 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
100
116
|
const tw = 160, th = 90, rate = 10;
|
|
101
117
|
const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
|
|
102
118
|
'-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
103
|
-
|
|
119
|
+
// plus the share of 8x8 blocks with no detail at all: an empty canvas or image area that is still
|
|
120
|
+
// loading (dark or light) next to toolbars that are already there
|
|
121
|
+
const n = Math.floor(buf.length / (tw * th)), score = [], flat = [];
|
|
104
122
|
for (let f = 0; f < n; f++) {
|
|
105
123
|
let e = 0;
|
|
124
|
+
const blocks = new Float64Array(Math.ceil(tw / 8) * Math.ceil(th / 8));
|
|
106
125
|
for (let y = 0, o = f * tw * th; y < th - 1; y++) for (let x = 0; x < tw - 1; x++) {
|
|
107
|
-
const k = o + y * tw + x;
|
|
108
|
-
e += Math.
|
|
126
|
+
const k = o + y * tw + x, d = Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
|
|
127
|
+
e += d; blocks[Math.floor(y / 8) * Math.ceil(tw / 8) + Math.floor(x / 8)] += d;
|
|
109
128
|
}
|
|
110
129
|
score.push(e / (tw * th));
|
|
130
|
+
flat.push(blocks.filter((b) => b / 64 < 0.6).length / blocks.length);
|
|
111
131
|
}
|
|
112
132
|
const median = [...score].sort((a, b) => a - b)[Math.floor(n / 2)] ?? 0;
|
|
113
133
|
const blank = score.map((e) => e < 3 && e < median * 0.4);
|
|
134
|
+
for (const g of segs) {
|
|
135
|
+
// compared with how the step ends (its content loaded): much emptier = still loading. Only right
|
|
136
|
+
// after the step waited for something (a load), so drawing on an empty canvas isn't flagged.
|
|
137
|
+
const last = Math.min(n - 1, Math.max(0, Math.floor(g.o1 * rate) - 1));
|
|
138
|
+
const loading = (tl.steps[g.i].waits ?? []).map(([a, b]) => [toOut(a), toOut(b)]).filter(([a]) => a != null).map(([a, b]) => [a, (b ?? a) + 1.5]);
|
|
139
|
+
for (let f = Math.ceil(g.o0 * rate); f <= last; f++) {
|
|
140
|
+
if (flat[f] > 0.55 && flat[f] - flat[last] > 0.25 && loading.some(([a, b]) => f / rate >= a - 0.1 && f / rate <= b)) blank[f] = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
114
143
|
for (const g of segs) {
|
|
115
144
|
let run = 0, from = 0;
|
|
116
145
|
const report = (to) => { if (run / rate >= 0.2) warn(`step ${g.i + 1}: ${(run / rate).toFixed(1)}s of near-blank frames at ${from.toFixed(1)}–${to.toFixed(1)}s (the page is still loading) — move that load into a hidden step, or wait for the loaded content before the step starts`); };
|
|
@@ -124,13 +153,41 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
124
153
|
// --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
|
|
125
154
|
const C0 = [W / 2, H / 2];
|
|
126
155
|
const kf = [[0, 1, C0]];
|
|
156
|
+
// follow camera: steps without a zoom of their own zoom in on the boxes their actions touched
|
|
157
|
+
// (clicks, typing, marks), and consecutive followed steps pan from one subject to the next
|
|
158
|
+
const { width: VW0, height: VH0 } = cfg.viewport;
|
|
159
|
+
const followOf = (g) => {
|
|
160
|
+
if (!cfg.follow || g.step.zoom || g.step.follow === false) return null;
|
|
161
|
+
const bs = (tl.steps[g.i].marks ?? []).map((m) => m.box).filter((b) => b && b.width < VW0 * 0.9);
|
|
162
|
+
if (!bs.length) return null;
|
|
163
|
+
const u = bs.reduce((a, b) => ({ x0: Math.min(a.x0, b.x), y0: Math.min(a.y0, b.y), x1: Math.max(a.x1, b.x + b.width), y1: Math.max(a.y1, b.y + b.height) }), { x0: 1e9, y0: 1e9, x1: -1e9, y1: -1e9 });
|
|
164
|
+
const w = u.x1 - u.x0 + 160, h = u.y1 - u.y0 + 140; // some context around the subject
|
|
165
|
+
const scale = Math.max(1, Math.min(cfg.follow.scale, VW0 / w, VH0 / h));
|
|
166
|
+
if (scale < 1.15) return null;
|
|
167
|
+
return { box: { x: u.x0, y: u.y0, width: u.x1 - u.x0, height: u.y1 - u.y0 }, scale };
|
|
168
|
+
};
|
|
169
|
+
let followEnd = -1;
|
|
127
170
|
for (const g of segs) {
|
|
171
|
+
const f = followOf(g);
|
|
172
|
+
if (f) {
|
|
173
|
+
g.zoomBox = f.box;
|
|
174
|
+
const c = [(f.box.x + f.box.width / 2) * K, (f.box.y + f.box.height / 2) * K];
|
|
175
|
+
const move = Math.min(0.7, (g.o1 - g.o0) / 3);
|
|
176
|
+
g.zoomPeak = g.o0 + move + 0.1;
|
|
177
|
+
if (Math.abs(followEnd - g.o0) < 1e-3 && kf.at(-1)[1] === 1) kf.pop(); // pan straight from the previous subject
|
|
178
|
+
else kf.push([g.o0, 1, C0]);
|
|
179
|
+
kf.push([g.o0 + move, f.scale, c], [g.o1 - 0.6, f.scale, c], [g.o1, 1, C0]);
|
|
180
|
+
followEnd = g.o1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
128
183
|
const z = g.step.zoom;
|
|
129
184
|
const t = z?.target;
|
|
130
185
|
if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
|
|
131
186
|
// a point needs no measuring, so it works even if the zoom was added after recording
|
|
132
187
|
const { width: VW, height: VH } = cfg.viewport;
|
|
133
|
-
|
|
188
|
+
// a point names the middle of the subject: give it some area, so captions keep off it too
|
|
189
|
+
const px = t.x <= 1 ? t.x * VW : t.x, py = t.y <= 1 ? t.y * VH : t.y, bw = VW * 0.12, bh = VH * 0.1;
|
|
190
|
+
g.zoomBox = { x: px - bw / 2, y: py - bh / 2, width: bw, height: bh };
|
|
134
191
|
} else if (t && 'zoomTarget' in tl.steps[g.i] && JSON.stringify(tl.steps[g.i].zoomTarget) !== JSON.stringify(t)) {
|
|
135
192
|
warn(`step ${g.i + 1}: zoom target was added or changed after recording, so its position is unknown — re-render with mode "render" (or use a point target)`);
|
|
136
193
|
g.zoomBox = null;
|
|
@@ -169,9 +226,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
169
226
|
const z = z0 + (z1 - z0) * e, cx = c0[0] + (c1[0] - c0[0]) * e, cy = c0[1] + (c1[1] - c0[1]) * e;
|
|
170
227
|
return { z, x0: Math.min(Math.max(cx - W / z / 2, 0), W - W / z), y0: Math.min(Math.max(cy - H / z / 2, 0), H - H / z) };
|
|
171
228
|
};
|
|
229
|
+
// browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
|
|
230
|
+
const even = (x) => 2 * Math.round(x / 2);
|
|
231
|
+
const FW = cfg.frame ? even(W * 0.82) : W, FH = cfg.frame ? even(H * 0.82) : H, BAR = cfg.frame ? Math.round(H * 0.034) : 0;
|
|
232
|
+
const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
|
|
172
233
|
const onScreen = (b, t) => {
|
|
173
234
|
const v = view(t);
|
|
174
|
-
return { x: (b.x * K - v.x0) * v.z, y: (b.y * K - v.y0) * v.z, w: b.width * K * v.z, h: b.height * K * v.z };
|
|
235
|
+
return { x: FX + (b.x * K - v.x0) * v.z * F, y: FY + (b.y * K - v.y0) * v.z * F, w: b.width * K * v.z * F, h: b.height * K * v.z * F };
|
|
175
236
|
};
|
|
176
237
|
|
|
177
238
|
// --- 4. captions + end card rendered as PNGs ----------------------------------------------
|
|
@@ -227,28 +288,112 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
227
288
|
const other = want === 'top' ? 'bottom' : 'top';
|
|
228
289
|
let pos = want;
|
|
229
290
|
const hit = hits(want);
|
|
230
|
-
if (hit &&
|
|
291
|
+
if (hit && cfg.subtitles === 'burn' && other === 'bottom') {
|
|
292
|
+
warn(`step ${g.i + 1}: the caption covers ${hit.what}, and the bottom is kept for the burned-in subtitles — shorten the caption, or change the zoom`);
|
|
293
|
+
} else if (hit && !hits(other)) {
|
|
231
294
|
pos = other;
|
|
232
295
|
warn(`step ${g.i + 1}: the ${want} caption would cover ${hit.what}, so it was moved to the ${other}` + (explicit ? ` (despite captionPosition: ${explicit})` : '') + ` — set captionPosition: ${other} to make this explicit`);
|
|
233
296
|
} else if (hit) warn(`step ${g.i + 1}: the caption covers ${hit.what} at both top and bottom — shorten the caption, or change the zoom`);
|
|
234
297
|
if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
|
|
235
|
-
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
|
|
298
|
+
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
|
|
236
299
|
}
|
|
237
300
|
for (const [i, c] of captions.entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
301
|
+
// Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
|
|
302
|
+
// CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
|
|
303
|
+
const u = H / 1080;
|
|
304
|
+
// cards follow the frame: dark backdrops (aurora, dark, blur) get dark cards, so a dark app doesn't
|
|
305
|
+
// cut to a white card; `cards: light | dark` overrides
|
|
306
|
+
const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && ['aurora', 'dark', 'blur'].includes(cfg.frame?.style));
|
|
307
|
+
const CK = darkCards
|
|
308
|
+
? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
|
|
309
|
+
pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
|
|
310
|
+
: { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
|
|
311
|
+
pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
|
|
312
|
+
const card = (c, logoPath, { thinkAt = 0 } = {}) => {
|
|
313
|
+
const image = c.imageFile && fs.existsSync(c.imageFile)
|
|
314
|
+
? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
|
|
315
|
+
const think = c.think ? `<div class="a rise" style="animation-delay:${thinkAt.toFixed(2)}s;position:absolute;left:50%;bottom:${H * 0.1}px;transform:translateX(-50%);width:${W * 0.3}px;text-align:center;font-size:${26 * H / 1080}px;color:${CK.muted}">
|
|
316
|
+
<div style="margin-bottom:${12 * H / 1080}px">${cfg.lang === 'zh' ? '先想一想' : 'Pause and think'}</div>
|
|
317
|
+
<div style="height:${8 * H / 1080}px;border-radius:99px;background:${CK.track};overflow:hidden"><div style="height:100%;background:${accent};transform-origin:left;animation:drain ${c.think}s linear ${thinkAt.toFixed(2)}s both"></div></div></div>
|
|
318
|
+
<style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
|
|
241
319
|
const logo = fs.existsSync(logoPath)
|
|
242
|
-
? `<img src="data:image/${path.extname(logoPath).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(logoPath).toString('base64')}" style="height:${Math.round(H * 0.09)}px">` : '';
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
320
|
+
? `<img class="a pop" src="data:image/${path.extname(logoPath).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(logoPath).toString('base64')}" style="height:${Math.round(H * 0.09)}px;${darkCards ? `background:#fff;padding:${Math.round(10 * u)}px ${Math.round(16 * u)}px;border-radius:${Math.round(14 * u)}px;box-shadow:0 ${8 * u}px ${24 * u}px rgba(0,0,0,.35)` : ''}">` : ''; // logos captured from a light page often carry its white: give them a plate on dark cards
|
|
321
|
+
const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
|
|
322
|
+
const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
|
|
323
|
+
return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
|
|
324
|
+
background:${CK.bg};
|
|
246
325
|
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
|
|
326
|
+
<style>
|
|
327
|
+
.a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
|
|
328
|
+
.pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
|
|
329
|
+
.rise{animation-name:rise;animation-duration:.6s}
|
|
330
|
+
${darkCards
|
|
331
|
+
? `.hl{color:${accent};background:linear-gradient(${accent},${accent}) no-repeat 0 100%/0% ${Math.max(2, Math.round(3 * u))}px;padding-bottom:${Math.round(4 * u)}px;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}`
|
|
332
|
+
: `.hl{background:linear-gradient(transparent 60%,${accent} 60%) no-repeat 0 0/0% 100%;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}`}
|
|
333
|
+
.type{display:inline-block;overflow:hidden;white-space:nowrap;vertical-align:bottom;width:0;animation:type ${(ctaText.length * 0.045).toFixed(2)}s steps(${ctaText.length || 1}) .8s both}
|
|
334
|
+
.caret{display:inline-block;width:.55em;height:1.1em;margin-left:.1em;vertical-align:-.15em;background:${accent};animation:blink 1s steps(1) .8s infinite}
|
|
335
|
+
@keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
|
336
|
+
@keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
|
|
337
|
+
@keyframes sweep{to{background-size:100% ${darkCards ? `${Math.max(2, Math.round(3 * u))}px` : '100%'}}}
|
|
338
|
+
@keyframes type{to{width:${ctaText.length}ch}}
|
|
339
|
+
@keyframes blink{50%{opacity:0}}
|
|
340
|
+
</style>
|
|
341
|
+
${c.kicker ? `<div class="a rise" style="font-size:${30 * u}px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:${CK.muted}">${esc(c.kicker)}</div>` : ''}
|
|
247
342
|
<div style="display:flex;align-items:center;gap:${22 * u}px">${logo}
|
|
248
|
-
${
|
|
249
|
-
${
|
|
250
|
-
${
|
|
251
|
-
|
|
343
|
+
${c.title ? `<span class="a rise" style="animation-delay:.12s;display:block;max-width:${W * 0.8}px;text-wrap:balance;line-height:1.15;font-size:${(c.title.length > 40 ? 68 : 92) * u}px;font-weight:600;letter-spacing:-.03em;color:${CK.title}">${esc(c.title).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</span>` : ''}</div>
|
|
344
|
+
${c.subtitle ? `<div class="a rise" style="animation-delay:.3s;font-size:${44 * u}px;color:${CK.sub};letter-spacing:-.01em">${esc(c.subtitle).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</div>` : ''}
|
|
345
|
+
${image}${think}
|
|
346
|
+
${c.bullets?.length ? `<ol style="margin:${10 * u}px 0 0;padding:0;list-style:none;text-align:left;display:flex;flex-direction:column;gap:${18 * u}px">${c.bullets.map((b, k) =>
|
|
347
|
+
`<li class="a rise" style="animation-delay:${(0.6 + 0.4 * k).toFixed(2)}s;display:flex;align-items:center;gap:${20 * u}px;font-size:${38 * u}px;color:${CK.item}">
|
|
348
|
+
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:${52 * u}px;height:${52 * u}px;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:${28 * u}px">${k + 1}</span>
|
|
349
|
+
<span>${md(b, darkCards ? accent : ink)}</span></li>`).join('')}</ol>` : ''}
|
|
350
|
+
${!c.cta ? '' : cmd
|
|
351
|
+
? `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font:500 ${36 * u}px ui-monospace,SFMono-Regular,Menlo,monospace;color:${CK.pillFg};background:${CK.pillBg};border:${CK.pillLine};padding:${20 * u}px ${36 * u}px;border-radius:${16 * u}px;text-align:left;box-shadow:0 ${16 * u}px ${40 * u}px ${CK.shadow}"><span style="color:${accent}">$</span> <span class="type">${esc(ctaText)}</span><span class="caret"></span></div>`
|
|
352
|
+
: `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font-size:${38 * u}px;font-weight:600;color:${CK.ctaFg};background:${CK.ctaBg};padding:${20 * u}px ${46 * u}px;border-radius:${16 * u}px">${esc(c.cta)}</div>`}
|
|
353
|
+
</body>`;
|
|
354
|
+
};
|
|
355
|
+
const animate = async (html, seconds, dir) => {
|
|
356
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
357
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
358
|
+
await pg.setContent(`<html><head>${head}</head>${html}</html>`, { waitUntil: 'networkidle' }).catch(() => {});
|
|
359
|
+
await pg.evaluate(() => document.fonts.ready);
|
|
360
|
+
const n = Math.ceil(seconds * FPS) + 1;
|
|
361
|
+
for (let k = 0; k < n; k++) {
|
|
362
|
+
await pg.evaluate((ms) => document.getAnimations().forEach((a) => { a.pause(); a.currentTime = ms; }), (k * 1000) / FPS);
|
|
363
|
+
await pg.screenshot({ path: path.join(dir, `${String(k).padStart(4, '0')}.jpg`), type: 'jpeg', quality: 92 });
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const ec = cfg.endCard;
|
|
367
|
+
if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
|
|
368
|
+
for (const [k, c] of cards.entries()) await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
|
|
369
|
+
if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
|
|
370
|
+
if (cfg.frame) {
|
|
371
|
+
// three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
|
|
372
|
+
// title bar), and the page with rounded bottom corners
|
|
373
|
+
const grain = `url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .09 0"/></filter><rect width="200" height="200" filter="url(#n)"/></svg>')}')`;
|
|
374
|
+
const STYLES = {
|
|
375
|
+
aurora: { dark: true, bg: `${grain}, radial-gradient(${W * 0.47}px ${H * 0.65}px at 20% 20%, #22d3ee99, transparent 60%), radial-gradient(${W * 0.47}px ${H * 0.65}px at 80% 30%, #a78bfa99, transparent 60%), radial-gradient(${W * 0.52}px ${H * 0.74}px at 50% 100%, #34d39988, transparent 60%), #0b1020` },
|
|
376
|
+
studio: { dark: false, bg: `${grain}, radial-gradient(${W * 0.73}px ${H * 0.83}px at 50% 0%, #ffffff, #e9edf3 60%, #d6dce6)` },
|
|
377
|
+
dark: { dark: true, bg: `radial-gradient(${W * 0.7}px ${H * 0.8}px at 15% 0%, ${accent}66, transparent 70%), radial-gradient(${W * 0.7}px ${H * 0.8}px at 100% 100%, #6366f166, transparent 70%), linear-gradient(135deg, #1e293b, ${ink})` },
|
|
378
|
+
pastel: { dark: false, bg: 'linear-gradient(135deg, #fde2e4 0%, #e2ece9 35%, #dfe7fd 70%, #f0e6ff 100%)' },
|
|
379
|
+
blur: { dark: true, bg: null },
|
|
380
|
+
};
|
|
381
|
+
const st = STYLES[cfg.frame.style], bg = cfg.frame.background ?? st.bg;
|
|
382
|
+
const R = Math.round(14 * u), host = new URL(cfg.url).host.replace(/^www\./, '') + new URL(cfg.url).pathname.replace(/\/$/, '');
|
|
383
|
+
const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
|
|
384
|
+
const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
|
|
385
|
+
if (bg) await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg}"></body>`, path.join(cfg.work, 'frame-bg.png'), false);
|
|
386
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
|
|
387
|
+
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${FH + BAR}px;border-radius:${R}px;background:#fff;box-shadow:0 ${30 * u}px ${80 * u}px rgba(0,0,0,${st.dark ? '.5' : '.25'}),0 0 0 1px rgba(255,255,255,.08)"></div>
|
|
388
|
+
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;border-radius:${R}px ${R}px 0 0;background:${barBg};display:flex;align-items:center;border-bottom:1px solid ${line}">
|
|
389
|
+
<div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
|
|
390
|
+
<div style="position:absolute;left:50%;transform:translateX(-50%);font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px">${esc(host)}</div>
|
|
391
|
+
</div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
|
|
392
|
+
// alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
|
|
393
|
+
await pg.setViewportSize({ width: FW, height: FH });
|
|
394
|
+
await pg.setContent(`<body style="margin:0;width:${FW}px;height:${FH}px;background:#000"><div style="width:100%;height:100%;background:#fff;border-radius:0 0 ${R}px ${R}px"></div></body>`);
|
|
395
|
+
await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
|
|
396
|
+
await pg.setViewportSize({ width: W, height: H });
|
|
252
397
|
}
|
|
253
398
|
await browser.close();
|
|
254
399
|
|
|
@@ -258,20 +403,54 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
258
403
|
`zoompan=z='${zExpr}':x='clip(${cxExpr}-iw/zoom/2,0,iw-iw/zoom)':y='clip(${cyExpr}-ih/zoom/2,0,ih-ih/zoom)'` +
|
|
259
404
|
`:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
|
|
260
405
|
let last = 'v0', idx = 1;
|
|
406
|
+
if (cfg.frame) {
|
|
407
|
+
const still = (f) => inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, f));
|
|
408
|
+
const blur = !cfg.frame.background && cfg.frame.style === 'blur';
|
|
409
|
+
let base;
|
|
410
|
+
if (blur) {
|
|
411
|
+
graph += `[v0]split[v0a][v0b];[v0b]scale=${even(W / 12)}:${even(H / 12)},gblur=sigma=4,scale=${W}:${H},eq=brightness=-0.12:saturation=1.3[fbg];\n`;
|
|
412
|
+
base = 'fbg';
|
|
413
|
+
} else { still('frame-bg.png'); base = `${idx++}:v`; }
|
|
414
|
+
still('frame-chrome.png'); const chrome = idx++;
|
|
415
|
+
still('frame-mask.png'); const mask = idx++;
|
|
416
|
+
graph += `[${blur ? 'v0a' : 'v0'}]scale=${FW}:${FH}:flags=lanczos,format=rgba[pg0];[${mask}:v]format=gray,scale=${FW}:${FH}[pm];[pg0][pm]alphamerge[pg];\n` +
|
|
417
|
+
`[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
|
|
418
|
+
last = 'vf';
|
|
419
|
+
}
|
|
420
|
+
// docs: a second, caption-free copy for the per-step clips and stills (the article carries the
|
|
421
|
+
// text), so crops never have to dodge a caption
|
|
422
|
+
const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
|
|
423
|
+
if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
|
|
261
424
|
for (const [i, c] of captions.entries()) {
|
|
262
425
|
const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
426
|
+
// each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
|
|
427
|
+
// video, once per caption, made long tutorials take minutes to compose)
|
|
428
|
+
inputs.push('-loop', '1', '-t', `${(b - a + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `cap${i}.png`));
|
|
429
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.toFixed(3)}/TB,fade=in:st=${a.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${(b - 0.15).toFixed(3)}:d=0.15:alpha=1[c${i}];\n` +
|
|
430
|
+
`[${last}][c${i}]overlay=0:0:eof_action=pass:enable='between(t,${a.toFixed(3)},${b.toFixed(3)})'[v${idx}];\n`;
|
|
266
431
|
last = `v${idx++}`;
|
|
267
432
|
}
|
|
433
|
+
for (const [k, c] of cards.entries()) {
|
|
434
|
+
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
|
|
435
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.25:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.3).toFixed(3)}:d=0.3:alpha=1[sc${k}];\n` +
|
|
436
|
+
`[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
|
|
437
|
+
last = `v${idx}`;
|
|
438
|
+
}
|
|
268
439
|
if (ec) {
|
|
269
440
|
const st = VIDEO - 0.25;
|
|
270
|
-
inputs.push('-
|
|
271
|
-
graph += `[${idx}:v]format=rgba,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n
|
|
441
|
+
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
|
|
442
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${st.toFixed(3)}/TB,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n` +
|
|
443
|
+
`[${last}][ec]overlay=0:0:eof_action=repeat:enable='gte(t,${st.toFixed(3)})'[v${++idx}];\n`;
|
|
444
|
+
last = `v${idx}`;
|
|
445
|
+
}
|
|
446
|
+
if (in0) {
|
|
447
|
+
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'intro', '%04d.jpg'));
|
|
448
|
+
graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.35).toFixed(3)}:d=0.35:alpha=1[in];\n` +
|
|
449
|
+
`[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
|
|
272
450
|
last = `v${idx}`;
|
|
273
451
|
}
|
|
274
452
|
graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
|
|
453
|
+
if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
|
|
275
454
|
const graphFile = path.join(cfg.work, 'graph.txt');
|
|
276
455
|
fs.writeFileSync(graphFile, graph);
|
|
277
456
|
const silent = path.join(cfg.work, 'video-silent.mp4');
|
|
@@ -280,11 +459,15 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
280
459
|
const major = Number((execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version n?(\d+)/) ?? [])[1] ?? 7);
|
|
281
460
|
const graphArgs = major >= 7 ? ['-/filter_complex', graphFile] : ['-filter_complex_script', graphFile];
|
|
282
461
|
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, ...graphArgs, '-map', '[out]',
|
|
283
|
-
'-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent
|
|
462
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent,
|
|
463
|
+
...(clean ? ['-map', '[clean]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, clean] : [])]);
|
|
284
464
|
|
|
285
465
|
// --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
|
|
286
466
|
// zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
|
|
287
467
|
const shots = segs.map((g) => ({ t: Math.min(g.o1 - 0.05, g.zoomPeak ?? g.o0 + (g.o1 - g.o0) * 0.8), label: `step ${g.i + 1} · ${g.o0.toFixed(1)}–${g.o1.toFixed(1)}s${g.step.zoom ? ' · zoom' : ''}`, caption: g.step.caption ?? '' }));
|
|
468
|
+
for (const c of cards) shots.push({ t: c.t0 + (c.t1 - c.t0) * 0.85, label: `card · ${c.t0.toFixed(1)}–${c.t1.toFixed(1)}s`, caption: c.card.title ?? '' });
|
|
469
|
+
shots.sort((a, b) => a.t - b.t);
|
|
470
|
+
if (in0) shots.unshift({ t: INTRO * 0.8, label: `intro · 0.0–${INTRO.toFixed(1)}s`, caption: '' });
|
|
288
471
|
if (ec) shots.push({ t: VIDEO + END / 2, label: `end card · ${VIDEO.toFixed(1)}–${TOTAL.toFixed(1)}s`, caption: '' });
|
|
289
472
|
const cells = shots.map((sh, k) => {
|
|
290
473
|
const f = path.join(cfg.work, `pv${k}.jpg`);
|
|
@@ -309,20 +492,42 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
309
492
|
endCard: ec ? VIDEO : null,
|
|
310
493
|
clicks: tl.clicks.map(toOut).filter((t) => t != null),
|
|
311
494
|
whooshes: dedupe([
|
|
312
|
-
|
|
495
|
+
// whoosh on deliberate scene changes (goto) only: a page the app opens after a click, or a
|
|
496
|
+
// results page reloading itself while you wait, is not a scene change
|
|
497
|
+
...(tl.gotos ?? tl.navs.filter((n) => !tl.clicks.some((c) => n - c >= 0 && n - c < 2) && !tl.steps.some((s) => (s.waits ?? []).some(([a, b]) => n >= a - 0.5 && n <= b + 0.5))))
|
|
498
|
+
.map(toOut).filter((t) => t != null && t > 0.2),
|
|
313
499
|
...segs.filter((g) => g.afterHidden).map((g) => g.o0),
|
|
500
|
+
...(in0 ? [INTRO - 0.3] : []),
|
|
501
|
+
...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
|
|
314
502
|
...(ec ? [VIDEO - 0.25] : []),
|
|
315
503
|
]),
|
|
316
504
|
risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
|
|
317
505
|
chimes: segs.filter((g) => g.step.sfx === 'riser' || g.step.sfx === 'chime').map((g) => g.o1),
|
|
318
506
|
// narration: each line may run until the next one starts
|
|
319
507
|
voice: [
|
|
508
|
+
...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
|
|
509
|
+
...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
|
|
320
510
|
...segs.filter((g) => g.step.say).map((g) => ({ text: g.step.say, start: g.o0 + (g.step.sayAt ?? 0.15), where: `step ${g.i + 1}` })),
|
|
321
511
|
...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
|
|
322
512
|
],
|
|
323
513
|
};
|
|
514
|
+
events.voice.sort((a, b) => a.start - b.start);
|
|
324
515
|
events.voice.forEach((v, k) => { v.until = events.voice[k + 1]?.start ?? TOTAL - 0.1; });
|
|
325
516
|
fs.writeFileSync(path.join(cfg.work, 'events.json'), JSON.stringify(events, null, 1));
|
|
326
517
|
log(segs.map((g) => ` ${g.o0.toFixed(1)}–${g.o1.toFixed(1)}s step ${g.i + 1} motion x${g.motionSpeed.toFixed(1)} waiting x${g.waitSpeed.toFixed(1)}`).join('\n'));
|
|
327
|
-
|
|
518
|
+
// docs clips: the part of the frame each step is about (what it clicked, typed into, marked or
|
|
519
|
+
// noted, with some context), in output px, same aspect ratio as the video
|
|
520
|
+
for (const g of segs) {
|
|
521
|
+
const bs = (tl.steps[g.i].marks ?? []).map((m) => onScreen(m.box, Math.max(g.o0, g.o1 - 0.1))).filter((b) => b.w > 0);
|
|
522
|
+
if (!bs.length) continue;
|
|
523
|
+
const x0 = Math.min(...bs.map((b) => b.x)), y0 = Math.min(...bs.map((b) => b.y));
|
|
524
|
+
const x1 = Math.max(...bs.map((b) => b.x + b.w)), y1 = Math.max(...bs.map((b) => b.y + b.h));
|
|
525
|
+
const pad = H * 0.12;
|
|
526
|
+
let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
|
|
527
|
+
if (w / h > W / H) h = w * H / W; else w = h * W / H;
|
|
528
|
+
w = Math.min(W, w); h = Math.min(H, h);
|
|
529
|
+
let cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
530
|
+
g.crop = { x: even(Math.max(0, Math.min(W - w, cx - w / 2))), y: even(Math.max(0, Math.min(H - h, cy - h / 2))), w: even(w), h: even(h) };
|
|
531
|
+
}
|
|
532
|
+
return { silent, clean, events, preview, warnings, captions, cards: cards.map((c) => ({ t: c.t0, title: [c.card.kicker, c.card.title].filter(Boolean).join(' · ') })), segs: segs.map((g) => ({ i: g.i, o0: g.o0, o1: g.o1, caption: g.step.caption, doc: g.step.doc ?? g.step.say, crop: g.crop })) };
|
|
328
533
|
}
|
package/src/config.mjs
CHANGED
|
@@ -19,14 +19,25 @@ export function loadEnv(dir) {
|
|
|
19
19
|
|
|
20
20
|
// What the video is for sets sensible defaults; anything in the YAML overrides them.
|
|
21
21
|
export const PURPOSES = {
|
|
22
|
-
pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null },
|
|
23
|
-
tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null },
|
|
24
|
-
course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null },
|
|
25
|
-
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: 'gif' },
|
|
22
|
+
pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false },
|
|
23
|
+
tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
24
|
+
course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
25
|
+
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
// Where storyboards and their videos go, and how they're named: <site>-<kind>[-<topic>].yaml →
|
|
29
29
|
// .mp4, so a promo, a tutorial and a lesson for the same app never collide or all read "demo".
|
|
30
|
+
// How fast things happen on screen. Promos are brisk; tutorials and lessons give the viewer time to
|
|
31
|
+
// follow each action and to read its result before the next one starts.
|
|
32
|
+
export const PACES = {
|
|
33
|
+
// cursor ms typing ms/char beat after a click (ms) hold after narration (s) waits shown up to (s) voice speed
|
|
34
|
+
fast: { cursor: 700, type: 55, beat: 0, tail: 0.4, maxWait: 1.0, voiceSpeed: 1 },
|
|
35
|
+
normal: { cursor: 900, type: 70, beat: 350, tail: 0.8, maxWait: 1.5, voiceSpeed: 1 },
|
|
36
|
+
slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92 },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
|
|
40
|
+
|
|
30
41
|
export const VIDEO_DIR = 'videos';
|
|
31
42
|
export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
|
|
32
43
|
// pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
|
|
@@ -76,26 +87,43 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
76
87
|
const P = PURPOSES[purpose];
|
|
77
88
|
if (!P) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
|
|
78
89
|
const pick = (k) => (raw[k] !== undefined ? raw[k] : P[k]);
|
|
90
|
+
if (!PACES[pick('pace')]) throw new Error(`pace must be one of ${Object.keys(PACES).join(', ')}`);
|
|
79
91
|
|
|
80
92
|
const steps = raw.steps.map((s, i) => {
|
|
81
93
|
const duration = s.duration ?? P.duration;
|
|
82
94
|
if (!s.hidden && duration !== 'auto' && !(duration > 0)) {
|
|
83
95
|
throw new Error(`step ${i + 1}: visible steps need a positive \`duration\` in seconds, or \`duration: auto\``);
|
|
84
96
|
}
|
|
85
|
-
|
|
97
|
+
const card = s.card ? { ...s.card, ...(s.card.image ? { imageFile: path.resolve(dir, s.card.image) } : {}) } : undefined;
|
|
98
|
+
return { actions: [], ...s, duration, card };
|
|
86
99
|
});
|
|
87
100
|
|
|
88
101
|
let endCard = raw.end_card === false ? null : raw.end_card ?? null;
|
|
89
102
|
if (endCard) endCard = { duration: endCard.say ? 'auto' : 1.0, ...endCard };
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
// An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
|
|
104
|
+
let intro = raw.intro === false ? null : raw.intro ?? null;
|
|
105
|
+
if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
|
|
106
|
+
for (const card of [endCard, intro]) {
|
|
107
|
+
if (card?.image) card.imageFile = path.resolve(dir, card.image);
|
|
108
|
+
if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
|
|
92
109
|
}
|
|
110
|
+
// Browser-window frame on a backdrop (promos by default): true, false, or { background: <css> }
|
|
111
|
+
const frame0 = pick('frame');
|
|
112
|
+
// follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
|
|
113
|
+
const follow0 = pick('follow');
|
|
114
|
+
const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
|
|
115
|
+
// frame: false | true | a style name | { style, background: <css> }. Styles: aurora (dark, grain),
|
|
116
|
+
// studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window)
|
|
117
|
+
const styleOf = (f) => (typeof f === 'string' ? f : f?.style) ?? (typeof P.frame === 'string' ? P.frame : 'aurora');
|
|
118
|
+
const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
|
|
119
|
+
if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
|
|
93
120
|
|
|
94
121
|
let voice = null;
|
|
95
122
|
if (raw.voice) {
|
|
96
123
|
const provider = raw.voice.provider ?? 'kokoro';
|
|
97
124
|
if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
|
|
98
|
-
|
|
125
|
+
const pace0 = PACES[pick('pace')] ?? PACES.normal;
|
|
126
|
+
voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
|
|
99
127
|
if (draft && provider !== 'kokoro') {
|
|
100
128
|
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
|
|
101
129
|
}
|
|
@@ -103,8 +131,9 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
103
131
|
|
|
104
132
|
const m = pick('music');
|
|
105
133
|
let music = m === false || m === null ? null : { bpm: 120, volume: 1, ...(P.music || {}), ...(raw.music || {}) };
|
|
106
|
-
|
|
107
|
-
|
|
134
|
+
// per-step files for docs: any of gif, webm, mp4, png (annotated still of the step's result)
|
|
135
|
+
const clips = [].concat(pick('clips') ?? []).filter(Boolean);
|
|
136
|
+
for (const c of clips) if (!['gif', 'webm', 'mp4', 'png'].includes(c)) throw new Error('clips must be gif, webm, mp4 or png (or a list of them)');
|
|
108
137
|
if (music?.file) music.file = path.resolve(dir, music.file);
|
|
109
138
|
|
|
110
139
|
return {
|
|
@@ -115,7 +144,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
115
144
|
subtitles: pick('subtitles'),
|
|
116
145
|
chapters: pick('chapters'),
|
|
117
146
|
clips,
|
|
118
|
-
|
|
147
|
+
pace: { name: pick('pace'), ...PACES[pick('pace')] },
|
|
148
|
+
maxWait: raw.maxWait ?? PACES[pick('pace')].maxWait,
|
|
119
149
|
url: raw.url,
|
|
120
150
|
viewport,
|
|
121
151
|
output,
|
|
@@ -124,11 +154,19 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
124
154
|
ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
|
|
125
155
|
storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
|
|
126
156
|
scrollOffset: raw.scrollOffset ?? 40,
|
|
127
|
-
|
|
157
|
+
// burned-in subtitles sit at the bottom, so captions move to the top
|
|
158
|
+
captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
|
|
128
159
|
theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
|
|
129
160
|
setup: raw.setup ?? [],
|
|
130
161
|
steps,
|
|
131
162
|
endCard,
|
|
163
|
+
intro,
|
|
164
|
+
article: raw.article ?? null,
|
|
165
|
+
cards: raw.cards ?? 'auto',
|
|
166
|
+
// language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
|
|
167
|
+
lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
|
|
168
|
+
frame,
|
|
169
|
+
follow,
|
|
132
170
|
music,
|
|
133
171
|
voice,
|
|
134
172
|
sfx: pick('sfx') !== false,
|
package/src/init.mjs
CHANGED
|
@@ -40,7 +40,13 @@ purpose: ${purpose}
|
|
|
40
40
|
out: ${name}.mp4
|
|
41
41
|
${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
${purpose === 'pitch' ? `intro: # title card before the recording (promos; optional for tutorials/lessons)
|
|
44
|
+
title: ${q(page.title.split(/[|—–-]/)[0].trim() || host)}
|
|
45
|
+
subtitle: TODO the one-line promise, with the **key words** in bold
|
|
46
|
+
${PURPOSES[purpose]?.frame ? '# frame: false # the recording sits in a browser window on a backdrop; false = full frame\n' : ''}
|
|
47
|
+
` : purpose === 'tutorial' || purpose === 'course' ? `# intro: { title: TODO, subtitle: TODO } # optional title card (e.g. "How to export a report")
|
|
48
|
+
|
|
49
|
+
` : ''}setup: # runs before recording; clean the frame here${dismiss ? `\n - dismiss: ${tgt(dismiss.target)}` : '\n # - dismiss: { role: button, name: Accept cookies }'}
|
|
44
50
|
|
|
45
51
|
steps:
|
|
46
52
|
- caption: ${q(h1?.text?.slice(0, 50) || page.title || host)}${say('TODO one sentence on what this product does.')}${dur(2.5)}
|
package/src/inspect.mjs
CHANGED
|
@@ -20,7 +20,8 @@ function collect() {
|
|
|
20
20
|
};
|
|
21
21
|
const vis = (el) => {
|
|
22
22
|
const r = el.getBoundingClientRect(), s = getComputedStyle(el);
|
|
23
|
-
|
|
23
|
+
// (parked far off-screen = hidden: a common way to hide form fields)
|
|
24
|
+
return r.width > 2 && r.height > 2 && r.right > 0 && r.left < Math.max(innerWidth, document.documentElement.scrollWidth) && s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05;
|
|
24
25
|
};
|
|
25
26
|
const docScrolls = document.documentElement.scrollHeight > innerHeight + 10;
|
|
26
27
|
let scroller = null;
|
|
@@ -64,9 +65,12 @@ function collect() {
|
|
|
64
65
|
// editors (contenteditable, CodeMirror…): name them by label/placeholder, never by their (long) content
|
|
65
66
|
const editorName = (el) => clean(el.getAttribute('aria-label')) || clean(el.getAttribute('placeholder')) || clean(el.getAttribute('aria-placeholder')) ||
|
|
66
67
|
clean(el.closest('label')?.innerText) || '';
|
|
68
|
+
// a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
|
|
69
|
+
const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
|
|
67
70
|
const interactive = [...document.querySelectorAll(NATIVE)]
|
|
68
71
|
.filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
69
|
-
.map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: el.labels?.[0]
|
|
72
|
+
.map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
|
|
73
|
+
options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(el) }));
|
|
70
74
|
// custom clickables: outermost cursor:pointer elements that aren't native controls
|
|
71
75
|
const pointer = [];
|
|
72
76
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -114,12 +118,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
114
118
|
seen.add(key);
|
|
115
119
|
let target = null;
|
|
116
120
|
if (el.kind === 'role') {
|
|
121
|
+
// sliders, inputs and dropdowns: their label is stable, their value (often their name) is not
|
|
122
|
+
if (['slider', 'textbox', 'combobox', 'searchbox', 'spinbutton'].includes(el.role) && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
|
|
117
123
|
// our name guess can differ from the browser's accessible name, so fall back gracefully
|
|
118
|
-
if (el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
|
|
124
|
+
if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
|
|
119
125
|
if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
|
|
120
126
|
if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
|
|
121
127
|
if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
|
|
122
|
-
if (!target && el.label) target = { label: el.label };
|
|
128
|
+
if (!target && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
|
|
129
|
+
if (!target && el.label) target = await verify(page.getByLabel(el.label), { label: el.label }, el);
|
|
123
130
|
if (!target && el.role === 'link' && el.href) target = `a[href="${el.href}"]`;
|
|
124
131
|
} else {
|
|
125
132
|
if (el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
|
|
@@ -151,7 +158,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
151
158
|
'',
|
|
152
159
|
'## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
|
|
153
160
|
' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
|
|
154
|
-
...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
|
|
161
|
+
...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? ' (slider — `slide: { target: …, to: 0..1 }`)' : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
|
|
155
162
|
'',
|
|
156
163
|
'## Images with alt text (target: { alt: "..." })',
|
|
157
164
|
...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
|