storyshot 0.3.1 → 0.4.1
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 +44 -12
- package/bin/storyshot.mjs +10 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +23 -7
- package/src/audio.mjs +10 -2
- package/src/build.mjs +2 -2
- package/src/check.mjs +13 -7
- package/src/compose.mjs +182 -51
- package/src/config.mjs +35 -5
- package/src/inspect.mjs +8 -4
- package/src/outputs.mjs +29 -11
- package/src/record.mjs +184 -18
- package/src/setup.mjs +81 -2
package/src/compose.mjs
CHANGED
|
@@ -22,9 +22,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
22
22
|
const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
|
|
23
23
|
// `think: N` (question cards): after the question is read out, N seconds with a countdown bar
|
|
24
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
|
-
|
|
25
|
+
// the end card carries the call to action: long enough to read it (and for a command to type itself out)
|
|
26
|
+
const ctaTime = (c) => (c?.cta ? 0.8 + String(c.cta).length * 0.045 + 1.6 : 0);
|
|
27
|
+
const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
|
|
28
|
+
if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
|
|
26
29
|
const in0 = cfg.intro;
|
|
27
30
|
const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
|
|
31
|
+
if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
|
|
28
32
|
const cards = []; // section cards: { i, t0, t1, card }
|
|
29
33
|
|
|
30
34
|
// --- 1. segments (one per visible step), split into motion / waiting pieces ------------
|
|
@@ -116,17 +120,30 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
116
120
|
const tw = 160, th = 90, rate = 10;
|
|
117
121
|
const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
|
|
118
122
|
'-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
119
|
-
|
|
123
|
+
// plus the share of 8x8 blocks with no detail at all: an empty canvas or image area that is still
|
|
124
|
+
// loading (dark or light) next to toolbars that are already there
|
|
125
|
+
const n = Math.floor(buf.length / (tw * th)), score = [], flat = [];
|
|
120
126
|
for (let f = 0; f < n; f++) {
|
|
121
127
|
let e = 0;
|
|
128
|
+
const blocks = new Float64Array(Math.ceil(tw / 8) * Math.ceil(th / 8));
|
|
122
129
|
for (let y = 0, o = f * tw * th; y < th - 1; y++) for (let x = 0; x < tw - 1; x++) {
|
|
123
|
-
const k = o + y * tw + x;
|
|
124
|
-
e += Math.
|
|
130
|
+
const k = o + y * tw + x, d = Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
|
|
131
|
+
e += d; blocks[Math.floor(y / 8) * Math.ceil(tw / 8) + Math.floor(x / 8)] += d;
|
|
125
132
|
}
|
|
126
133
|
score.push(e / (tw * th));
|
|
134
|
+
flat.push(blocks.filter((b) => b / 64 < 0.6).length / blocks.length);
|
|
127
135
|
}
|
|
128
136
|
const median = [...score].sort((a, b) => a - b)[Math.floor(n / 2)] ?? 0;
|
|
129
137
|
const blank = score.map((e) => e < 3 && e < median * 0.4);
|
|
138
|
+
for (const g of segs) {
|
|
139
|
+
// compared with how the step ends (its content loaded): much emptier = still loading. Only right
|
|
140
|
+
// after the step waited for something (a load), so drawing on an empty canvas isn't flagged.
|
|
141
|
+
const last = Math.min(n - 1, Math.max(0, Math.floor(g.o1 * rate) - 1));
|
|
142
|
+
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]);
|
|
143
|
+
for (let f = Math.ceil(g.o0 * rate); f <= last; f++) {
|
|
144
|
+
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;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
130
147
|
for (const g of segs) {
|
|
131
148
|
let run = 0, from = 0;
|
|
132
149
|
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`); };
|
|
@@ -137,6 +154,39 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
// --- 2c. dead air: stretches where nothing moves and nobody speaks --------------------------------
|
|
158
|
+
{
|
|
159
|
+
const tw = 160, th = 90, rate = 10;
|
|
160
|
+
const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
|
|
161
|
+
'-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
162
|
+
const n = Math.floor(buf.length / (tw * th)), still = [false];
|
|
163
|
+
for (let f = 1; f < n; f++) {
|
|
164
|
+
let changed = 0; // pixels that changed visibly: even the cursor moving changes a few
|
|
165
|
+
for (let k = 0, a = f * tw * th, b = a - tw * th; k < tw * th; k++) if (Math.abs(buf[a + k] - buf[b + k]) > 10) changed++;
|
|
166
|
+
still.push(changed < 4);
|
|
167
|
+
}
|
|
168
|
+
const spoken = [
|
|
169
|
+
...(in0 ? [[0, INTRO]] : []), // cards are their own pictures (the frames under them don't show)
|
|
170
|
+
...cards.map((c) => [c.t0, c.t1]),
|
|
171
|
+
...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + (g.step.sayAt ?? 0.15), g.o0 + (g.step.sayAt ?? 0.15) + voiceLens.steps[g.i]]),
|
|
172
|
+
];
|
|
173
|
+
let from = null;
|
|
174
|
+
const report = (to) => {
|
|
175
|
+
if (from != null && to - from >= 3) {
|
|
176
|
+
const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
|
|
177
|
+
warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? segs.at(-1).i + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
|
|
178
|
+
}
|
|
179
|
+
from = null;
|
|
180
|
+
};
|
|
181
|
+
for (let f = 0; f < n; f++) {
|
|
182
|
+
const t = f / rate;
|
|
183
|
+
const dead = still[f] && !spoken.some(([a, b]) => t >= a && t <= b);
|
|
184
|
+
if (dead && from == null) from = t;
|
|
185
|
+
else if (!dead) report(t);
|
|
186
|
+
}
|
|
187
|
+
report(n / rate);
|
|
188
|
+
}
|
|
189
|
+
|
|
140
190
|
// --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
|
|
141
191
|
const C0 = [W / 2, H / 2];
|
|
142
192
|
const kf = [[0, 1, C0]];
|
|
@@ -172,7 +222,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
172
222
|
if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
|
|
173
223
|
// a point needs no measuring, so it works even if the zoom was added after recording
|
|
174
224
|
const { width: VW, height: VH } = cfg.viewport;
|
|
175
|
-
|
|
225
|
+
// a point names the middle of the subject: give it some area, so captions keep off it too
|
|
226
|
+
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;
|
|
227
|
+
g.zoomBox = { x: px - bw / 2, y: py - bh / 2, width: bw, height: bh };
|
|
176
228
|
} else if (t && 'zoomTarget' in tl.steps[g.i] && JSON.stringify(tl.steps[g.i].zoomTarget) !== JSON.stringify(t)) {
|
|
177
229
|
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)`);
|
|
178
230
|
g.zoomBox = null;
|
|
@@ -182,7 +234,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
182
234
|
const b = g.zoomBox, off = z.offset ?? {};
|
|
183
235
|
const c = [(b.x + b.width / 2 + (off.x ?? 0)) * K, (b.y + b.height / 2 + (off.y ?? 0)) * K];
|
|
184
236
|
const [rin, rout] = Array.isArray(z.ramp) ? z.ramp : [z.ramp ?? 0.5, z.ramp ?? 0.5];
|
|
185
|
-
|
|
237
|
+
// a zoom belongs to its step: an `end` past the step (e.g. after the narration grew it) stops there
|
|
238
|
+
if (z.end != null && g.o0 + z.end > g.o1 + 0.05) warn(`step ${g.i + 1}: zoom end ${z.end}s is past the step's end (${(g.o1 - g.o0).toFixed(1)}s), so it stops there — leave out end to zoom to the step's end`);
|
|
239
|
+
const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o1, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
|
|
186
240
|
const last = kf.at(-1)[0];
|
|
187
241
|
if (s < last) { warn(`zoom in step ${g.i + 1} overlaps the previous zoom, skipped`); continue; }
|
|
188
242
|
kf.push([s, 1, C0], [Math.min(s + rin, e), scale, c], [Math.max(s + rin, e - rout), scale, c], [e, 1, C0]);
|
|
@@ -213,13 +267,27 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
213
267
|
};
|
|
214
268
|
// browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
|
|
215
269
|
const even = (x) => 2 * Math.round(x / 2);
|
|
216
|
-
const FW = cfg.frame ? even(W * 0.
|
|
270
|
+
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;
|
|
217
271
|
const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
|
|
218
272
|
const onScreen = (b, t) => {
|
|
219
273
|
const v = view(t);
|
|
220
274
|
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 };
|
|
221
275
|
};
|
|
222
276
|
|
|
277
|
+
// what the address bar shows over time: the page's URL when each visible moment was recorded
|
|
278
|
+
const addressOf = (u) => { try { const x = new URL(u); return x.host.replace(/^www\./, '') + x.pathname.replace(/\/$/, ''); } catch { return String(u); } };
|
|
279
|
+
const urlAt = (r) => (tl.urls ?? []).filter((x) => x.t <= r).at(-1)?.url ?? cfg.url;
|
|
280
|
+
const addresses = [];
|
|
281
|
+
for (const g of cfg.frame?.window === 'browser' ? segs : []) {
|
|
282
|
+
const r = tl.steps[g.i], changes = (tl.urls ?? []).filter((x) => x.t > r.start && x.t <= r.end);
|
|
283
|
+
for (const [t, u] of [[g.o0, urlAt(r.start)], ...changes.map((x) => [toOut(x.t) ?? g.o0, x.url])]) {
|
|
284
|
+
const text = addressOf(u), last = addresses.at(-1);
|
|
285
|
+
if (last?.text === text) continue;
|
|
286
|
+
if (last) last.t1 = t;
|
|
287
|
+
addresses.push({ text, t0: last ? t : 0, t1: TOTAL });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
223
291
|
// --- 4. captions + end card rendered as PNGs ----------------------------------------------
|
|
224
292
|
const { accent, ink, font } = cfg.theme;
|
|
225
293
|
const fontCss = `font-family:'${font}',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;`;
|
|
@@ -232,8 +300,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
232
300
|
await pg.screenshot({ path: file, omitBackground: transparent });
|
|
233
301
|
};
|
|
234
302
|
const fs1 = Math.round(H * 0.043), MARGIN = Math.round(H * 0.06);
|
|
303
|
+
// in a browser-window frame a top caption sits just inside the page, below the address bar
|
|
304
|
+
const TOP = cfg.frame ? FY + Math.round(H * 0.022) : MARGIN;
|
|
235
305
|
const bubble = (text, n, pos) => `<body style="margin:0;background:transparent">
|
|
236
|
-
<div style="position:absolute;left:0;right:0;${pos === 'top' ?
|
|
306
|
+
<div style="position:absolute;left:0;right:0;${pos === 'top' ? `top:${TOP}` : `bottom:${MARGIN}`}px;display:flex;justify-content:center;padding:0 5%">
|
|
237
307
|
<div id="cap" style="${fontCss}font-size:${fs1}px;font-weight:500;letter-spacing:-.01em;color:#fff;background:${ink}e0;text-align:center;
|
|
238
308
|
padding:${Math.round(fs1 * 0.43)}px ${Math.round(fs1 * 0.87)}px;border-radius:${fs1}px;box-shadow:0 12px 40px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.12)">
|
|
239
309
|
${cfg.stepNumbers ? `<span style="display:inline-flex;align-items:center;justify-content:center;width:1.45em;height:1.45em;margin:-.2em .55em -.2em -.35em;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:.85em;vertical-align:middle">${n}</span>` : ''}${md(text, accent)}</div></div></body>`;
|
|
@@ -258,7 +328,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
258
328
|
const n = prev && prev.text === g.step.caption && Math.abs(prev.t[1] - g.o0) < 1e-6 ? prev.n : captions.length + 1;
|
|
259
329
|
const explicit = g.step.captionPosition, want = explicit ?? cfg.captionPosition;
|
|
260
330
|
const { w, h } = await sizeOf(g.step.caption, n);
|
|
261
|
-
const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ?
|
|
331
|
+
const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? TOP : H - MARGIN - h, w, h });
|
|
262
332
|
const subjects = [];
|
|
263
333
|
for (const m of tl.steps[g.i].marks ?? []) {
|
|
264
334
|
const t = toOut(m.t);
|
|
@@ -280,51 +350,61 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
280
350
|
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`);
|
|
281
351
|
} 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`);
|
|
282
352
|
if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
|
|
283
|
-
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
|
|
353
|
+
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
|
|
284
354
|
}
|
|
285
|
-
for (const [i, c] of captions.entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
|
|
355
|
+
for (const [i, c] of (cfg.captions ? captions : []).entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
|
|
286
356
|
// Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
|
|
287
357
|
// CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
|
|
288
358
|
const u = H / 1080;
|
|
359
|
+
// cards follow the frame: dark backdrops (aurora, dark, blur) get dark cards, so a dark app doesn't
|
|
360
|
+
// cut to a white card; `cards: light | dark` overrides
|
|
361
|
+
const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && ['aurora', 'dark', 'blur'].includes(cfg.frame?.style));
|
|
362
|
+
const CK = darkCards
|
|
363
|
+
? { 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',
|
|
364
|
+
pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
|
|
365
|
+
: { 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',
|
|
366
|
+
pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
|
|
289
367
|
const card = (c, logoPath, { thinkAt = 0 } = {}) => {
|
|
290
368
|
const image = c.imageFile && fs.existsSync(c.imageFile)
|
|
291
369
|
? `<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)">` : '';
|
|
292
|
-
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
|
|
293
|
-
<div style="margin-bottom:${12 * H / 1080}px"
|
|
294
|
-
<div style="height:${8 * H / 1080}px;border-radius:99px;background
|
|
370
|
+
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}">
|
|
371
|
+
<div style="margin-bottom:${12 * H / 1080}px">${cfg.labels.think}</div>
|
|
372
|
+
<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>
|
|
295
373
|
<style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
|
|
296
374
|
const logo = fs.existsSync(logoPath)
|
|
297
|
-
? `<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">` : '';
|
|
375
|
+
? `<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
|
|
298
376
|
const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
|
|
299
377
|
const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
|
|
300
378
|
return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
|
|
301
|
-
background
|
|
379
|
+
background:${CK.bg};
|
|
302
380
|
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
|
|
303
381
|
<style>
|
|
304
382
|
.a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
|
|
305
383
|
.pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
|
|
306
384
|
.rise{animation-name:rise;animation-duration:.6s}
|
|
307
|
-
|
|
385
|
+
${darkCards
|
|
386
|
+
? `.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}`
|
|
387
|
+
: `.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}`}
|
|
308
388
|
.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}
|
|
309
389
|
.caret{display:inline-block;width:.55em;height:1.1em;margin-left:.1em;vertical-align:-.15em;background:${accent};animation:blink 1s steps(1) .8s infinite}
|
|
310
390
|
@keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
|
311
391
|
@keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
|
|
312
|
-
@keyframes sweep{to{background-size:100% 100%}}
|
|
392
|
+
@keyframes sweep{to{background-size:100% ${darkCards ? `${Math.max(2, Math.round(3 * u))}px` : '100%'}}}
|
|
313
393
|
@keyframes type{to{width:${ctaText.length}ch}}
|
|
314
394
|
@keyframes blink{50%{opacity:0}}
|
|
315
395
|
</style>
|
|
316
|
-
${c.kicker ? `<div class="a rise" style="font-size:${30 * u}px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color
|
|
396
|
+
${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>` : ''}
|
|
317
397
|
<div style="display:flex;align-items:center;gap:${22 * u}px">${logo}
|
|
318
|
-
${c.title ? `<span class="a rise" style="animation-delay:.12s;font-size:${92 * u}px;font-weight:600;letter-spacing:-.03em;color:${
|
|
319
|
-
${c.subtitle ? `<div class="a rise" style="animation-delay:.3s;font-size:${44 * u}px;color
|
|
398
|
+
${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>
|
|
399
|
+
${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>` : ''}
|
|
320
400
|
${image}${think}
|
|
321
401
|
${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) =>
|
|
322
|
-
`<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
|
|
402
|
+
`<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}">
|
|
323
403
|
<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>
|
|
324
|
-
<span>${md(b, ink)}</span></li>`).join('')}</ol>` : ''}
|
|
404
|
+
<span>${md(b, darkCards ? accent : ink)}</span></li>`).join('')}</ol>` : ''}
|
|
325
405
|
${!c.cta ? '' : cmd
|
|
326
|
-
? `<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
|
|
327
|
-
: `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font-size:${38 * u}px;font-weight:600;color
|
|
406
|
+
? `<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>`
|
|
407
|
+
: `<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>`}
|
|
328
408
|
</body>`;
|
|
329
409
|
};
|
|
330
410
|
const animate = async (html, seconds, dir) => {
|
|
@@ -343,20 +423,49 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
343
423
|
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}`));
|
|
344
424
|
if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
|
|
345
425
|
if (cfg.frame) {
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
426
|
+
// three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
|
|
427
|
+
// title bar), and the page with rounded bottom corners
|
|
428
|
+
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>')}')`;
|
|
429
|
+
const STYLES = {
|
|
430
|
+
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` },
|
|
431
|
+
studio: { dark: false, bg: `${grain}, radial-gradient(${W * 0.73}px ${H * 0.83}px at 50% 0%, #ffffff, #e9edf3 60%, #d6dce6)` },
|
|
432
|
+
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})` },
|
|
433
|
+
pastel: { dark: false, bg: 'linear-gradient(135deg, #fde2e4 0%, #e2ece9 35%, #dfe7fd 70%, #f0e6ff 100%)' },
|
|
434
|
+
blur: { dark: true, bg: null },
|
|
435
|
+
};
|
|
436
|
+
const st = STYLES[cfg.frame.style], bg = cfg.frame.background ?? st.bg;
|
|
437
|
+
// an app window: the app's icon and name in the title bar instead of an address bar
|
|
438
|
+
let app = null;
|
|
439
|
+
if (cfg.frame.window === 'app') {
|
|
440
|
+
const MIME = { svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', png: 'image/png' };
|
|
441
|
+
const [file, type] = cfg.frame.iconFile ? [cfg.frame.iconFile, MIME[path.extname(cfg.frame.iconFile).slice(1).toLowerCase()] ?? 'image/png']
|
|
442
|
+
: tl.app?.icon ? [path.join(cfg.work, tl.app.icon.file), tl.app.icon.type] : [];
|
|
443
|
+
const icon = file && fs.existsSync(file) ? `data:${type};base64,${fs.readFileSync(file).toString('base64')}` : null;
|
|
444
|
+
app = { icon, title: cfg.frame.title ?? tl.app?.title ?? (() => { try { return new URL(cfg.url).host.replace(/^www\./, ''); } catch { return ''; } })() };
|
|
445
|
+
}
|
|
446
|
+
const R = Math.round(14 * u);
|
|
447
|
+
const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
|
|
448
|
+
const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
|
|
449
|
+
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);
|
|
450
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
|
|
451
|
+
<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>
|
|
452
|
+
<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}">
|
|
353
453
|
<div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
454
|
+
${app ? `<div style="position:absolute;left:0;right:0;top:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:${8 * u}px;font-size:${15 * u}px;font-weight:600;color:${barFg}">
|
|
455
|
+
${app.icon ? `<img src="${app.icon}" style="width:${18 * u}px;height:${18 * u}px;object-fit:contain;border-radius:${4 * u}px">` : ''}<span style="max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(app.title)}</span></div>` : ''}
|
|
456
|
+
</div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
|
|
457
|
+
// the address bar follows the page: one pill per address it shows
|
|
458
|
+
for (const [k, a] of addresses.entries()) {
|
|
459
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
|
|
460
|
+
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;display:flex;align-items:center;justify-content:center">
|
|
461
|
+
<div style="font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px;max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.text)}</div>
|
|
462
|
+
</div></body>`, path.join(cfg.work, `frame-url${k}.png`), true);
|
|
463
|
+
}
|
|
464
|
+
// alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
|
|
465
|
+
await pg.setViewportSize({ width: FW, height: FH });
|
|
466
|
+
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>`);
|
|
467
|
+
await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
|
|
468
|
+
await pg.setViewportSize({ width: W, height: H });
|
|
360
469
|
}
|
|
361
470
|
await browser.close();
|
|
362
471
|
|
|
@@ -367,12 +476,29 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
367
476
|
`:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
|
|
368
477
|
let last = 'v0', idx = 1;
|
|
369
478
|
if (cfg.frame) {
|
|
370
|
-
inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
479
|
+
const still = (f) => inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, f));
|
|
480
|
+
const blur = !cfg.frame.background && cfg.frame.style === 'blur';
|
|
481
|
+
let base;
|
|
482
|
+
if (blur) {
|
|
483
|
+
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`;
|
|
484
|
+
base = 'fbg';
|
|
485
|
+
} else { still('frame-bg.png'); base = `${idx++}:v`; }
|
|
486
|
+
still('frame-chrome.png'); const chrome = idx++;
|
|
487
|
+
still('frame-mask.png'); const mask = idx++;
|
|
488
|
+
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` +
|
|
489
|
+
`[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
|
|
490
|
+
last = 'vf';
|
|
491
|
+
for (const [k, a] of addresses.entries()) {
|
|
492
|
+
inputs.push('-loop', '1', '-t', `${(a.t1 - a.t0 + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `frame-url${k}.png`));
|
|
493
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.t0.toFixed(3)}/TB[u${k}];[${last}][u${k}]overlay=0:0:eof_action=pass:enable='between(t,${a.t0.toFixed(3)},${a.t1.toFixed(3)})'[vu${k}];\n`;
|
|
494
|
+
last = `vu${k}`; idx++;
|
|
495
|
+
}
|
|
374
496
|
}
|
|
375
|
-
|
|
497
|
+
// docs: a second, caption-free copy for the per-step clips and stills (the article carries the
|
|
498
|
+
// text), so crops never have to dodge a caption
|
|
499
|
+
const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
|
|
500
|
+
if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
|
|
501
|
+
for (const [i, c] of (cfg.captions ? captions : []).entries()) {
|
|
376
502
|
const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
|
|
377
503
|
// each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
|
|
378
504
|
// video, once per caption, made long tutorials take minutes to compose)
|
|
@@ -383,7 +509,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
383
509
|
}
|
|
384
510
|
for (const [k, c] of cards.entries()) {
|
|
385
511
|
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
|
|
386
|
-
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.
|
|
512
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
|
|
387
513
|
`[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
|
|
388
514
|
last = `v${idx}`;
|
|
389
515
|
}
|
|
@@ -396,11 +522,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
396
522
|
}
|
|
397
523
|
if (in0) {
|
|
398
524
|
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'intro', '%04d.jpg'));
|
|
399
|
-
graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.
|
|
525
|
+
graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.2).toFixed(3)}:d=0.2:alpha=1[in];\n` +
|
|
400
526
|
`[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
|
|
401
527
|
last = `v${idx}`;
|
|
402
528
|
}
|
|
403
529
|
graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
|
|
530
|
+
if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
|
|
404
531
|
const graphFile = path.join(cfg.work, 'graph.txt');
|
|
405
532
|
fs.writeFileSync(graphFile, graph);
|
|
406
533
|
const silent = path.join(cfg.work, 'video-silent.mp4');
|
|
@@ -409,7 +536,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
409
536
|
const major = Number((execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version n?(\d+)/) ?? [])[1] ?? 7);
|
|
410
537
|
const graphArgs = major >= 7 ? ['-/filter_complex', graphFile] : ['-filter_complex_script', graphFile];
|
|
411
538
|
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, ...graphArgs, '-map', '[out]',
|
|
412
|
-
'-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent
|
|
539
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent,
|
|
540
|
+
...(clean ? ['-map', '[clean]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, clean] : [])]);
|
|
413
541
|
|
|
414
542
|
// --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
|
|
415
543
|
// zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
|
|
@@ -441,10 +569,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
441
569
|
endCard: ec ? VIDEO : null,
|
|
442
570
|
clicks: tl.clicks.map(toOut).filter((t) => t != null),
|
|
443
571
|
whooshes: dedupe([
|
|
444
|
-
|
|
572
|
+
// whoosh on deliberate scene changes (goto) only: a page the app opens after a click, or a
|
|
573
|
+
// results page reloading itself while you wait, is not a scene change
|
|
574
|
+
...(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))))
|
|
575
|
+
.map(toOut).filter((t) => t != null && t > 0.2),
|
|
445
576
|
...segs.filter((g) => g.afterHidden).map((g) => g.o0),
|
|
446
|
-
...(in0 ? [INTRO - 0.
|
|
447
|
-
...cards.flatMap((c) => [c.t0, c.t1 - 0.
|
|
577
|
+
...(in0 ? [INTRO - 0.2] : []),
|
|
578
|
+
...cards.flatMap((c) => [c.t0, c.t1 - 0.15]),
|
|
448
579
|
...(ec ? [VIDEO - 0.25] : []),
|
|
449
580
|
]),
|
|
450
581
|
risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
|
|
@@ -472,8 +603,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
472
603
|
let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
|
|
473
604
|
if (w / h > W / H) h = w * H / W; else w = h * W / H;
|
|
474
605
|
w = Math.min(W, w); h = Math.min(H, h);
|
|
475
|
-
|
|
606
|
+
let cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
476
607
|
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) };
|
|
477
608
|
}
|
|
478
|
-
return { silent, 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 })) };
|
|
609
|
+
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 })) };
|
|
479
610
|
}
|
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,17 @@ import path from 'node:path';
|
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { VOICE_DEFAULTS } from './voice.mjs';
|
|
6
6
|
|
|
7
|
+
// Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
|
|
8
|
+
export const LABELS = {
|
|
9
|
+
en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
|
|
10
|
+
zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
|
|
11
|
+
es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
|
|
12
|
+
pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
|
|
13
|
+
fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
|
|
14
|
+
de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
|
|
15
|
+
ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
|
|
16
|
+
};
|
|
17
|
+
|
|
7
18
|
// Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
|
|
8
19
|
// and in the working directory; variables already set in the environment win.
|
|
9
20
|
export function loadEnv(dir) {
|
|
@@ -19,9 +30,9 @@ export function loadEnv(dir) {
|
|
|
19
30
|
|
|
20
31
|
// What the video is for sets sensible defaults; anything in the YAML overrides them.
|
|
21
32
|
export const PURPOSES = {
|
|
22
|
-
pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame:
|
|
23
|
-
tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame:
|
|
24
|
-
course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame:
|
|
33
|
+
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 },
|
|
34
|
+
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 },
|
|
35
|
+
course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
25
36
|
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
37
|
};
|
|
27
38
|
|
|
@@ -36,6 +47,8 @@ export const PACES = {
|
|
|
36
47
|
slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92 },
|
|
37
48
|
};
|
|
38
49
|
|
|
50
|
+
export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
|
|
51
|
+
|
|
39
52
|
export const VIDEO_DIR = 'videos';
|
|
40
53
|
export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
|
|
41
54
|
// pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
|
|
@@ -97,7 +110,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
97
110
|
});
|
|
98
111
|
|
|
99
112
|
let endCard = raw.end_card === false ? null : raw.end_card ?? null;
|
|
100
|
-
|
|
113
|
+
// auto: as long as its narration, bullets and call to action need, and never under 2.5s
|
|
114
|
+
if (endCard) endCard = { duration: 'auto', ...endCard };
|
|
101
115
|
// An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
|
|
102
116
|
let intro = raw.intro === false ? null : raw.intro ?? null;
|
|
103
117
|
if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
|
|
@@ -110,7 +124,15 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
110
124
|
// follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
|
|
111
125
|
const follow0 = pick('follow');
|
|
112
126
|
const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
|
|
113
|
-
|
|
127
|
+
// frame: false | true | a style name | { style, background: <css>, window, title, icon }. Styles: aurora
|
|
128
|
+
// (dark, grain), studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window).
|
|
129
|
+
// window: browser (an address bar) or app (an installed web app / desktop app: its icon and name in the
|
|
130
|
+
// title bar, no address bar; both are read from the page's web app manifest unless given)
|
|
131
|
+
const styleOf = (f) => (typeof f === 'string' ? f : f?.style) ?? (typeof P.frame === 'string' ? P.frame : 'aurora');
|
|
132
|
+
const frame = frame0 ? { background: null, window: 'browser', ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
|
|
133
|
+
if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
|
|
134
|
+
if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
|
|
135
|
+
if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
|
|
114
136
|
|
|
115
137
|
let voice = null;
|
|
116
138
|
if (raw.voice) {
|
|
@@ -155,6 +177,14 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
155
177
|
steps,
|
|
156
178
|
endCard,
|
|
157
179
|
intro,
|
|
180
|
+
article: raw.article ?? null,
|
|
181
|
+
// captions: false keeps the caption pills off the video (chapters and SRT still use them),
|
|
182
|
+
// e.g. when burned-in subtitles already carry the words
|
|
183
|
+
captions: raw.captions !== false,
|
|
184
|
+
cards: raw.cards ?? 'auto',
|
|
185
|
+
// language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
|
|
186
|
+
lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
|
|
187
|
+
labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
|
|
158
188
|
frame,
|
|
159
189
|
follow,
|
|
160
190
|
music,
|
package/src/inspect.mjs
CHANGED
|
@@ -67,10 +67,12 @@ function collect() {
|
|
|
67
67
|
clean(el.closest('label')?.innerText) || '';
|
|
68
68
|
// a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
|
|
69
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; };
|
|
70
|
+
// styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
|
|
71
|
+
const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
|
|
70
72
|
const interactive = [...document.querySelectorAll(NATIVE)]
|
|
71
|
-
.filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
73
|
+
.filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
72
74
|
.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) }));
|
|
75
|
+
options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
|
|
74
76
|
// custom clickables: outermost cursor:pointer elements that aren't native controls
|
|
75
77
|
const pointer = [];
|
|
76
78
|
for (const el of document.querySelectorAll('body *')) {
|
|
@@ -118,8 +120,10 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
118
120
|
seen.add(key);
|
|
119
121
|
let target = null;
|
|
120
122
|
if (el.kind === 'role') {
|
|
123
|
+
// sliders, inputs and dropdowns: their label is stable, their value (often their name) is not
|
|
124
|
+
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);
|
|
121
125
|
// our name guess can differ from the browser's accessible name, so fall back gracefully
|
|
122
|
-
if (el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
|
|
126
|
+
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);
|
|
123
127
|
if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
|
|
124
128
|
if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
|
|
125
129
|
if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
|
|
@@ -156,7 +160,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
156
160
|
'',
|
|
157
161
|
'## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
|
|
158
162
|
' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
|
|
159
|
-
...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.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)' : ''}`),
|
|
163
|
+
...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)' : ''}`),
|
|
160
164
|
'',
|
|
161
165
|
'## Images with alt text (target: { alt: "..." })',
|
|
162
166
|
...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
|