storyshot 0.1.3 → 0.3.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 +71 -11
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +83 -13
- package/src/build.mjs +4 -3
- package/src/check.mjs +31 -6
- package/src/compose.mjs +174 -23
- package/src/config.mjs +40 -12
- package/src/init.mjs +7 -1
- package/src/inspect.mjs +9 -4
- package/src/mcp.mjs +1 -1
- package/src/outputs.mjs +84 -17
- package/src/record.mjs +237 -16
- 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
|
}
|
|
@@ -124,7 +140,33 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
124
140
|
// --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
|
|
125
141
|
const C0 = [W / 2, H / 2];
|
|
126
142
|
const kf = [[0, 1, C0]];
|
|
143
|
+
// follow camera: steps without a zoom of their own zoom in on the boxes their actions touched
|
|
144
|
+
// (clicks, typing, marks), and consecutive followed steps pan from one subject to the next
|
|
145
|
+
const { width: VW0, height: VH0 } = cfg.viewport;
|
|
146
|
+
const followOf = (g) => {
|
|
147
|
+
if (!cfg.follow || g.step.zoom || g.step.follow === false) return null;
|
|
148
|
+
const bs = (tl.steps[g.i].marks ?? []).map((m) => m.box).filter((b) => b && b.width < VW0 * 0.9);
|
|
149
|
+
if (!bs.length) return null;
|
|
150
|
+
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 });
|
|
151
|
+
const w = u.x1 - u.x0 + 160, h = u.y1 - u.y0 + 140; // some context around the subject
|
|
152
|
+
const scale = Math.max(1, Math.min(cfg.follow.scale, VW0 / w, VH0 / h));
|
|
153
|
+
if (scale < 1.15) return null;
|
|
154
|
+
return { box: { x: u.x0, y: u.y0, width: u.x1 - u.x0, height: u.y1 - u.y0 }, scale };
|
|
155
|
+
};
|
|
156
|
+
let followEnd = -1;
|
|
127
157
|
for (const g of segs) {
|
|
158
|
+
const f = followOf(g);
|
|
159
|
+
if (f) {
|
|
160
|
+
g.zoomBox = f.box;
|
|
161
|
+
const c = [(f.box.x + f.box.width / 2) * K, (f.box.y + f.box.height / 2) * K];
|
|
162
|
+
const move = Math.min(0.7, (g.o1 - g.o0) / 3);
|
|
163
|
+
g.zoomPeak = g.o0 + move + 0.1;
|
|
164
|
+
if (Math.abs(followEnd - g.o0) < 1e-3 && kf.at(-1)[1] === 1) kf.pop(); // pan straight from the previous subject
|
|
165
|
+
else kf.push([g.o0, 1, C0]);
|
|
166
|
+
kf.push([g.o0 + move, f.scale, c], [g.o1 - 0.6, f.scale, c], [g.o1, 1, C0]);
|
|
167
|
+
followEnd = g.o1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
128
170
|
const z = g.step.zoom;
|
|
129
171
|
const t = z?.target;
|
|
130
172
|
if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
|
|
@@ -169,9 +211,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
169
211
|
const z = z0 + (z1 - z0) * e, cx = c0[0] + (c1[0] - c0[0]) * e, cy = c0[1] + (c1[1] - c0[1]) * e;
|
|
170
212
|
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
213
|
};
|
|
214
|
+
// browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
|
|
215
|
+
const even = (x) => 2 * Math.round(x / 2);
|
|
216
|
+
const FW = cfg.frame ? even(W * 0.86) : W, FH = cfg.frame ? even(H * 0.86) : H, BAR = cfg.frame ? Math.round(H * 0.034) : 0;
|
|
217
|
+
const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
|
|
172
218
|
const onScreen = (b, t) => {
|
|
173
219
|
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 };
|
|
220
|
+
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
221
|
};
|
|
176
222
|
|
|
177
223
|
// --- 4. captions + end card rendered as PNGs ----------------------------------------------
|
|
@@ -227,7 +273,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
227
273
|
const other = want === 'top' ? 'bottom' : 'top';
|
|
228
274
|
let pos = want;
|
|
229
275
|
const hit = hits(want);
|
|
230
|
-
if (hit &&
|
|
276
|
+
if (hit && cfg.subtitles === 'burn' && other === 'bottom') {
|
|
277
|
+
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`);
|
|
278
|
+
} else if (hit && !hits(other)) {
|
|
231
279
|
pos = other;
|
|
232
280
|
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
281
|
} 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`);
|
|
@@ -235,20 +283,80 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
235
283
|
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
|
|
236
284
|
}
|
|
237
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);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
286
|
+
// Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
|
|
287
|
+
// CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
|
|
288
|
+
const u = H / 1080;
|
|
289
|
+
const card = (c, logoPath, { thinkAt = 0 } = {}) => {
|
|
290
|
+
const image = c.imageFile && fs.existsSync(c.imageFile)
|
|
291
|
+
? `<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:#64748b">
|
|
293
|
+
<div style="margin-bottom:${12 * H / 1080}px">Pause and think</div>
|
|
294
|
+
<div style="height:${8 * H / 1080}px;border-radius:99px;background:#e2e8f0;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
|
+
<style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
|
|
241
296
|
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
|
-
|
|
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">` : '';
|
|
298
|
+
const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
|
|
299
|
+
const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
|
|
300
|
+
return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
|
|
245
301
|
background:radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%);
|
|
246
302
|
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
|
|
303
|
+
<style>
|
|
304
|
+
.a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
|
|
305
|
+
.pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
|
|
306
|
+
.rise{animation-name:rise;animation-duration:.6s}
|
|
307
|
+
.hl{background:linear-gradient(transparent 62%,${accent} 62%) no-repeat 0 0/0% 100%;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}
|
|
308
|
+
.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
|
+
.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
|
+
@keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
|
|
311
|
+
@keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
|
|
312
|
+
@keyframes sweep{to{background-size:100% 100%}}
|
|
313
|
+
@keyframes type{to{width:${ctaText.length}ch}}
|
|
314
|
+
@keyframes blink{50%{opacity:0}}
|
|
315
|
+
</style>
|
|
316
|
+
${c.kicker ? `<div class="a rise" style="font-size:${30 * u}px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#64748b">${esc(c.kicker)}</div>` : ''}
|
|
247
317
|
<div style="display:flex;align-items:center;gap:${22 * u}px">${logo}
|
|
248
|
-
${
|
|
249
|
-
${
|
|
250
|
-
${
|
|
251
|
-
|
|
318
|
+
${c.title ? `<span class="a rise" style="animation-delay:.12s;font-size:${92 * u}px;font-weight:600;letter-spacing:-.03em;color:${ink}">${esc(c.title).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</span>` : ''}</div>
|
|
319
|
+
${c.subtitle ? `<div class="a rise" style="animation-delay:.3s;font-size:${44 * u}px;color:#334155;letter-spacing:-.01em">${esc(c.subtitle).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</div>` : ''}
|
|
320
|
+
${image}${think}
|
|
321
|
+
${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:#1e293b">
|
|
323
|
+
<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>` : ''}
|
|
325
|
+
${!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:#e2e8f0;background:${ink};padding:${20 * u}px ${36 * u}px;border-radius:${16 * u}px;text-align:left;box-shadow:0 ${16 * u}px ${40 * u}px rgba(15,23,42,.25)"><span style="color:${accent}">$</span> <span class="type">${esc(ctaText)}</span><span class="caret"></span></div>`
|
|
327
|
+
: `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font-size:${38 * u}px;font-weight:600;color:#fff;background:${ink};padding:${20 * u}px ${46 * u}px;border-radius:${16 * u}px">${esc(c.cta)}</div>`}
|
|
328
|
+
</body>`;
|
|
329
|
+
};
|
|
330
|
+
const animate = async (html, seconds, dir) => {
|
|
331
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
await pg.setContent(`<html><head>${head}</head>${html}</html>`, { waitUntil: 'networkidle' }).catch(() => {});
|
|
334
|
+
await pg.evaluate(() => document.fonts.ready);
|
|
335
|
+
const n = Math.ceil(seconds * FPS) + 1;
|
|
336
|
+
for (let k = 0; k < n; k++) {
|
|
337
|
+
await pg.evaluate((ms) => document.getAnimations().forEach((a) => { a.pause(); a.currentTime = ms; }), (k * 1000) / FPS);
|
|
338
|
+
await pg.screenshot({ path: path.join(dir, `${String(k).padStart(4, '0')}.jpg`), type: 'jpeg', quality: 92 });
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const ec = cfg.endCard;
|
|
342
|
+
if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
|
|
343
|
+
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
|
+
if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
|
|
345
|
+
if (cfg.frame) {
|
|
346
|
+
// backdrop + window (under the page) and the backdrop again with a rounded hole (over it, for the corners)
|
|
347
|
+
const bg = cfg.frame.background ?? `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})`;
|
|
348
|
+
const R = Math.round(12 * u), host = new URL(cfg.url).host.replace(/^www\./, '') + new URL(cfg.url).pathname.replace(/\/$/, '');
|
|
349
|
+
const dot = (c) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${c};display:inline-block"></span>`;
|
|
350
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg};${fontCss}">
|
|
351
|
+
<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,.45),0 0 0 1px rgba(255,255,255,.08)"></div>
|
|
352
|
+
<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:#e8ebef;display:flex;align-items:center;border-bottom:1px solid #d5d9df">
|
|
353
|
+
<div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
|
|
354
|
+
<div style="position:absolute;left:50%;transform:translateX(-50%);font-size:${15 * u}px;color:#4b5563;background:#fff;border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px">${esc(host)}</div>
|
|
355
|
+
</div></body>`, path.join(cfg.work, 'frame-bg.png'), false);
|
|
356
|
+
const hole = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect x="${FX}" y="${FY - BAR}" width="${FW}" height="${FH + BAR}" rx="${R}"/></svg>`);
|
|
357
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent">
|
|
358
|
+
<div style="position:absolute;inset:0;background:${bg};-webkit-mask:linear-gradient(#000 0 0),url('data:image/svg+xml,${hole}');-webkit-mask-composite:xor;mask-composite:exclude"></div></body>`,
|
|
359
|
+
path.join(cfg.work, 'frame-fg.png'), true);
|
|
252
360
|
}
|
|
253
361
|
await browser.close();
|
|
254
362
|
|
|
@@ -258,17 +366,38 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
258
366
|
`zoompan=z='${zExpr}':x='clip(${cxExpr}-iw/zoom/2,0,iw-iw/zoom)':y='clip(${cyExpr}-ih/zoom/2,0,ih-ih/zoom)'` +
|
|
259
367
|
`:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
|
|
260
368
|
let last = 'v0', idx = 1;
|
|
369
|
+
if (cfg.frame) {
|
|
370
|
+
inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, 'frame-bg.png'),
|
|
371
|
+
'-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, 'frame-fg.png'));
|
|
372
|
+
graph += `[v0]scale=${FW}:${FH}:flags=lanczos[pg];[${idx}:v][pg]overlay=${FX}:${FY}[fb];[fb][${idx + 1}:v]overlay=0:0,format=yuv420p[vf];\n`;
|
|
373
|
+
last = 'vf'; idx += 2;
|
|
374
|
+
}
|
|
261
375
|
for (const [i, c] of captions.entries()) {
|
|
262
376
|
const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
377
|
+
// each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
|
|
378
|
+
// video, once per caption, made long tutorials take minutes to compose)
|
|
379
|
+
inputs.push('-loop', '1', '-t', `${(b - a + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `cap${i}.png`));
|
|
380
|
+
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` +
|
|
381
|
+
`[${last}][c${i}]overlay=0:0:eof_action=pass:enable='between(t,${a.toFixed(3)},${b.toFixed(3)})'[v${idx}];\n`;
|
|
266
382
|
last = `v${idx++}`;
|
|
267
383
|
}
|
|
384
|
+
for (const [k, c] of cards.entries()) {
|
|
385
|
+
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.25:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.3).toFixed(3)}:d=0.3:alpha=1[sc${k}];\n` +
|
|
387
|
+
`[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
|
|
388
|
+
last = `v${idx}`;
|
|
389
|
+
}
|
|
268
390
|
if (ec) {
|
|
269
391
|
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
|
|
392
|
+
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
|
|
393
|
+
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` +
|
|
394
|
+
`[${last}][ec]overlay=0:0:eof_action=repeat:enable='gte(t,${st.toFixed(3)})'[v${++idx}];\n`;
|
|
395
|
+
last = `v${idx}`;
|
|
396
|
+
}
|
|
397
|
+
if (in0) {
|
|
398
|
+
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.35).toFixed(3)}:d=0.35:alpha=1[in];\n` +
|
|
400
|
+
`[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
|
|
272
401
|
last = `v${idx}`;
|
|
273
402
|
}
|
|
274
403
|
graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
|
|
@@ -285,6 +414,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
285
414
|
// --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
|
|
286
415
|
// zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
|
|
287
416
|
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 ?? '' }));
|
|
417
|
+
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 ?? '' });
|
|
418
|
+
shots.sort((a, b) => a.t - b.t);
|
|
419
|
+
if (in0) shots.unshift({ t: INTRO * 0.8, label: `intro · 0.0–${INTRO.toFixed(1)}s`, caption: '' });
|
|
288
420
|
if (ec) shots.push({ t: VIDEO + END / 2, label: `end card · ${VIDEO.toFixed(1)}–${TOTAL.toFixed(1)}s`, caption: '' });
|
|
289
421
|
const cells = shots.map((sh, k) => {
|
|
290
422
|
const f = path.join(cfg.work, `pv${k}.jpg`);
|
|
@@ -311,18 +443,37 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
311
443
|
whooshes: dedupe([
|
|
312
444
|
...tl.navs.map(toOut).filter((t) => t != null && t > 0.2),
|
|
313
445
|
...segs.filter((g) => g.afterHidden).map((g) => g.o0),
|
|
446
|
+
...(in0 ? [INTRO - 0.3] : []),
|
|
447
|
+
...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
|
|
314
448
|
...(ec ? [VIDEO - 0.25] : []),
|
|
315
449
|
]),
|
|
316
450
|
risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
|
|
317
451
|
chimes: segs.filter((g) => g.step.sfx === 'riser' || g.step.sfx === 'chime').map((g) => g.o1),
|
|
318
452
|
// narration: each line may run until the next one starts
|
|
319
453
|
voice: [
|
|
454
|
+
...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
|
|
455
|
+
...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
|
|
320
456
|
...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
457
|
...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
|
|
322
458
|
],
|
|
323
459
|
};
|
|
460
|
+
events.voice.sort((a, b) => a.start - b.start);
|
|
324
461
|
events.voice.forEach((v, k) => { v.until = events.voice[k + 1]?.start ?? TOTAL - 0.1; });
|
|
325
462
|
fs.writeFileSync(path.join(cfg.work, 'events.json'), JSON.stringify(events, null, 1));
|
|
326
463
|
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
|
-
|
|
464
|
+
// docs clips: the part of the frame each step is about (what it clicked, typed into, marked or
|
|
465
|
+
// noted, with some context), in output px, same aspect ratio as the video
|
|
466
|
+
for (const g of segs) {
|
|
467
|
+
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);
|
|
468
|
+
if (!bs.length) continue;
|
|
469
|
+
const x0 = Math.min(...bs.map((b) => b.x)), y0 = Math.min(...bs.map((b) => b.y));
|
|
470
|
+
const x1 = Math.max(...bs.map((b) => b.x + b.w)), y1 = Math.max(...bs.map((b) => b.y + b.h));
|
|
471
|
+
const pad = H * 0.12;
|
|
472
|
+
let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
|
|
473
|
+
if (w / h > W / H) h = w * H / W; else w = h * W / H;
|
|
474
|
+
w = Math.min(W, w); h = Math.min(H, h);
|
|
475
|
+
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
476
|
+
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
|
+
}
|
|
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 })) };
|
|
328
479
|
}
|
package/src/config.mjs
CHANGED
|
@@ -19,14 +19,23 @@ 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: true, 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: false, pace: 'slow', follow: true },
|
|
24
|
+
course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: false, 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
|
+
|
|
30
39
|
export const VIDEO_DIR = 'videos';
|
|
31
40
|
export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
|
|
32
41
|
// pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
|
|
@@ -76,26 +85,39 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
76
85
|
const P = PURPOSES[purpose];
|
|
77
86
|
if (!P) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
|
|
78
87
|
const pick = (k) => (raw[k] !== undefined ? raw[k] : P[k]);
|
|
88
|
+
if (!PACES[pick('pace')]) throw new Error(`pace must be one of ${Object.keys(PACES).join(', ')}`);
|
|
79
89
|
|
|
80
90
|
const steps = raw.steps.map((s, i) => {
|
|
81
91
|
const duration = s.duration ?? P.duration;
|
|
82
92
|
if (!s.hidden && duration !== 'auto' && !(duration > 0)) {
|
|
83
93
|
throw new Error(`step ${i + 1}: visible steps need a positive \`duration\` in seconds, or \`duration: auto\``);
|
|
84
94
|
}
|
|
85
|
-
|
|
95
|
+
const card = s.card ? { ...s.card, ...(s.card.image ? { imageFile: path.resolve(dir, s.card.image) } : {}) } : undefined;
|
|
96
|
+
return { actions: [], ...s, duration, card };
|
|
86
97
|
});
|
|
87
98
|
|
|
88
99
|
let endCard = raw.end_card === false ? null : raw.end_card ?? null;
|
|
89
100
|
if (endCard) endCard = { duration: endCard.say ? 'auto' : 1.0, ...endCard };
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
// An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
|
|
102
|
+
let intro = raw.intro === false ? null : raw.intro ?? null;
|
|
103
|
+
if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
|
|
104
|
+
for (const card of [endCard, intro]) {
|
|
105
|
+
if (card?.image) card.imageFile = path.resolve(dir, card.image);
|
|
106
|
+
if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
|
|
92
107
|
}
|
|
108
|
+
// Browser-window frame on a backdrop (promos by default): true, false, or { background: <css> }
|
|
109
|
+
const frame0 = pick('frame');
|
|
110
|
+
// follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
|
|
111
|
+
const follow0 = pick('follow');
|
|
112
|
+
const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
|
|
113
|
+
const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}) } : null;
|
|
93
114
|
|
|
94
115
|
let voice = null;
|
|
95
116
|
if (raw.voice) {
|
|
96
117
|
const provider = raw.voice.provider ?? 'kokoro';
|
|
97
118
|
if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
|
|
98
|
-
|
|
119
|
+
const pace0 = PACES[pick('pace')] ?? PACES.normal;
|
|
120
|
+
voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
|
|
99
121
|
if (draft && provider !== 'kokoro') {
|
|
100
122
|
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
|
|
101
123
|
}
|
|
@@ -103,8 +125,9 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
103
125
|
|
|
104
126
|
const m = pick('music');
|
|
105
127
|
let music = m === false || m === null ? null : { bpm: 120, volume: 1, ...(P.music || {}), ...(raw.music || {}) };
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
// per-step files for docs: any of gif, webm, mp4, png (annotated still of the step's result)
|
|
129
|
+
const clips = [].concat(pick('clips') ?? []).filter(Boolean);
|
|
130
|
+
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
131
|
if (music?.file) music.file = path.resolve(dir, music.file);
|
|
109
132
|
|
|
110
133
|
return {
|
|
@@ -115,7 +138,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
115
138
|
subtitles: pick('subtitles'),
|
|
116
139
|
chapters: pick('chapters'),
|
|
117
140
|
clips,
|
|
118
|
-
|
|
141
|
+
pace: { name: pick('pace'), ...PACES[pick('pace')] },
|
|
142
|
+
maxWait: raw.maxWait ?? PACES[pick('pace')].maxWait,
|
|
119
143
|
url: raw.url,
|
|
120
144
|
viewport,
|
|
121
145
|
output,
|
|
@@ -124,11 +148,15 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
124
148
|
ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
|
|
125
149
|
storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
|
|
126
150
|
scrollOffset: raw.scrollOffset ?? 40,
|
|
127
|
-
|
|
151
|
+
// burned-in subtitles sit at the bottom, so captions move to the top
|
|
152
|
+
captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
|
|
128
153
|
theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
|
|
129
154
|
setup: raw.setup ?? [],
|
|
130
155
|
steps,
|
|
131
156
|
endCard,
|
|
157
|
+
intro,
|
|
158
|
+
frame,
|
|
159
|
+
follow,
|
|
132
160
|
music,
|
|
133
161
|
voice,
|
|
134
162
|
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 *')) {
|
|
@@ -119,7 +123,8 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
119
123
|
if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
|
|
120
124
|
if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
|
|
121
125
|
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 };
|
|
126
|
+
if (!target && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
|
|
127
|
+
if (!target && el.label) target = await verify(page.getByLabel(el.label), { label: el.label }, el);
|
|
123
128
|
if (!target && el.role === 'link' && el.href) target = `a[href="${el.href}"]`;
|
|
124
129
|
} else {
|
|
125
130
|
if (el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
|
|
@@ -151,7 +156,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
151
156
|
'',
|
|
152
157
|
'## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
|
|
153
158
|
' 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)' : ''}`),
|
|
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)' : ''}`),
|
|
155
160
|
'',
|
|
156
161
|
'## Images with alt text (target: { alt: "..." })',
|
|
157
162
|
...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
|
package/src/mcp.mjs
CHANGED
|
@@ -51,7 +51,7 @@ server.registerTool('init_storyboard', {
|
|
|
51
51
|
inputSchema: {
|
|
52
52
|
url: z.string().describe('Start page of the web app'),
|
|
53
53
|
path: z.string().optional().describe('Absolute path for the YAML (must not exist yet). Default: <cwd>/videos/<site>-<kind>[-<topic>].yaml, kind = promo/tutorial/lesson/docs from purpose'),
|
|
54
|
-
purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch =
|
|
54
|
+
purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article'),
|
|
55
55
|
topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
|
|
56
56
|
storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
|
|
57
57
|
ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),
|