storyshot 0.6.0 → 0.7.2
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 +60 -26
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +93 -14
- package/src/audio.mjs +9 -4
- package/src/check.mjs +43 -3
- package/src/compose.mjs +33 -9
- package/src/config.mjs +15 -12
- package/src/doctor.mjs +2 -2
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +11 -2
- package/src/inspect.mjs +63 -19
- package/src/qwen-tts.mjs +40 -15
- package/src/qwen_tts.py +144 -13
- package/src/record.mjs +208 -64
- package/src/voice.mjs +107 -11
package/src/record.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
import { chromium } from 'playwright';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { describePage } from './inspect.mjs';
|
|
6
|
+
import { clickableKeys, describePage } from './inspect.mjs';
|
|
7
7
|
import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
|
|
8
8
|
import { certError } from './config.mjs';
|
|
9
9
|
|
|
10
10
|
// Injected into every page: fake cursor, click ripple, smooth in-page anchor scrolling.
|
|
11
|
-
function pageScript({ scrollOffset }) {
|
|
11
|
+
export function pageScript({ scrollOffset }) {
|
|
12
12
|
const install = () => {
|
|
13
13
|
if (document.getElementById('__demo_cursor')) return;
|
|
14
14
|
const c = document.createElement('div');
|
|
@@ -55,19 +55,60 @@ function pageScript({ scrollOffset }) {
|
|
|
55
55
|
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) if (scrollable(p)) return p;
|
|
56
56
|
return docEl();
|
|
57
57
|
};
|
|
58
|
+
// `behavior: 'instant'` on every frame: with `scroll-behavior: smooth` in the page's CSS (GitHub has it),
|
|
59
|
+
// plain scrollTop writes each start a smooth scroll of their own, so the page lags behind and is still
|
|
60
|
+
// moving after the step that scrolled it is over
|
|
58
61
|
window.__demoScrollBy = (dy, ms = 1200, sc = mainScroller()) => new Promise((done) => {
|
|
59
62
|
const start = sc.scrollTop, t0 = performance.now();
|
|
60
63
|
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
61
64
|
const step = (now) => {
|
|
62
|
-
const k = Math.min(1, (now - t0) / ms);
|
|
63
|
-
sc.
|
|
65
|
+
const k = ms > 0 ? Math.min(1, Math.max(0, (now - t0) / ms)) : 1;
|
|
66
|
+
sc.scrollTo({ top: start + dy * ease(k), behavior: 'instant' });
|
|
64
67
|
if (k < 1) requestAnimationFrame(step); else done();
|
|
65
68
|
};
|
|
66
69
|
requestAnimationFrame(step);
|
|
67
70
|
});
|
|
71
|
+
// How far down a header will cover the window once `el` is scrolled to the top: bars fixed at the top,
|
|
72
|
+
// and sticky ones that stick inside a box holding `el` (GitHub's README bar sticks within the README).
|
|
73
|
+
const headerCover = (el) => {
|
|
74
|
+
let cover = 0;
|
|
75
|
+
for (const h of document.querySelectorAll('body *')) {
|
|
76
|
+
if (h.getAttribute('id')?.startsWith('__demo') || h.contains(el)) continue; // not h.id: a form with an <input name=id> has that input as its .id
|
|
77
|
+
const cs = getComputedStyle(h);
|
|
78
|
+
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
|
|
79
|
+
const r = h.getBoundingClientRect();
|
|
80
|
+
if (!r.height || r.height > innerHeight * 0.3 || r.width < innerWidth * 0.3 || cs.visibility === 'hidden' || cs.opacity === '0') continue;
|
|
81
|
+
if (cs.position === 'fixed' && r.top <= 1) cover = Math.max(cover, r.bottom);
|
|
82
|
+
const stick = parseFloat(cs.top);
|
|
83
|
+
if (cs.position === 'sticky' && !Number.isNaN(stick) && stick < innerHeight * 0.3 && h.parentElement?.contains(el)) cover = Math.max(cover, stick + r.height);
|
|
84
|
+
}
|
|
85
|
+
return cover;
|
|
86
|
+
};
|
|
87
|
+
// the element's top lands `scrollOffset` px down, or just below a header that would cover it there
|
|
68
88
|
window.__demoScrollTo = (el, ms) => {
|
|
69
|
-
const sc = scrollerFor(el),
|
|
70
|
-
|
|
89
|
+
const sc = scrollerFor(el), docs = sc === docEl(), top = docs ? 0 : sc.getBoundingClientRect().top;
|
|
90
|
+
const offset = docs ? Math.max(scrollOffset, headerCover(el) + 12) : scrollOffset;
|
|
91
|
+
return window.__demoScrollBy(el.getBoundingClientRect().top - top - offset, ms, sc);
|
|
92
|
+
};
|
|
93
|
+
// resolves once nothing has scrolled for a few frames (the page's own smooth scrolling included)
|
|
94
|
+
window.__demoScrollIdle = (max = 2000) => new Promise((done) => {
|
|
95
|
+
const sc = mainScroller(), pos = () => `${scrollX},${scrollY},${sc.scrollTop}`;
|
|
96
|
+
const end = performance.now() + max;
|
|
97
|
+
let last = pos(), still = 0;
|
|
98
|
+
const check = () => {
|
|
99
|
+
const now = pos();
|
|
100
|
+
still = now === last ? still + 1 : 0; last = now;
|
|
101
|
+
if (still >= 3 || performance.now() > end) done(); else requestAnimationFrame(check);
|
|
102
|
+
};
|
|
103
|
+
requestAnimationFrame(check);
|
|
104
|
+
});
|
|
105
|
+
// a slider's value and range: an <input type=range>, or an ARIA slider (Desmos: [role=slider] on its knob)
|
|
106
|
+
window.__demoSliderValue = (el) => {
|
|
107
|
+
const r = el.type === 'range' ? el : el.querySelector?.('input[type=range]');
|
|
108
|
+
if (r) return { now: Number(r.value), lo: Number(r.min || 0), hi: Number(r.max || 100), native: r };
|
|
109
|
+
const aria = el.hasAttribute('aria-valuenow') ? el : el.querySelector?.('[aria-valuenow]');
|
|
110
|
+
const now = Number(aria?.getAttribute('aria-valuenow'));
|
|
111
|
+
return aria && Number.isFinite(now) ? { now, lo: Number(aria.getAttribute('aria-valuemin') ?? 0), hi: Number(aria.getAttribute('aria-valuemax') ?? 100) } : null;
|
|
71
112
|
};
|
|
72
113
|
// Tutorial callouts: spotlight a target, show a pressed key combo.
|
|
73
114
|
window.__demoSpot = (r) => {
|
|
@@ -86,24 +127,64 @@ function pageScript({ scrollOffset }) {
|
|
|
86
127
|
Object.assign(el.style, { left: `${r.x - pad}px`, top: `${r.y - pad}px`, width: `${r.width + 2 * pad}px`, height: `${r.height + 2 * pad}px` });
|
|
87
128
|
requestAnimationFrame(() => { el.style.opacity = '1'; });
|
|
88
129
|
};
|
|
89
|
-
window.__demoSpotOff = () => {
|
|
130
|
+
window.__demoSpotOff = (instant) => {
|
|
90
131
|
const el = document.getElementById('__demo_spot');
|
|
91
|
-
if (el
|
|
132
|
+
if (el && instant) el.remove();
|
|
133
|
+
else if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
|
|
92
134
|
};
|
|
93
|
-
|
|
135
|
+
// the key badge sits just below the cursor (above it near the bottom), where the action is: a zoom on
|
|
136
|
+
// that spot keeps it in the frame. Returns its box, so captions keep off it.
|
|
137
|
+
window.__demoKeys = (label, x = innerWidth / 2, y = innerHeight * 0.7) => {
|
|
94
138
|
const el = document.createElement('div');
|
|
95
139
|
el.textContent = label;
|
|
96
140
|
Object.assign(el.style, {
|
|
97
|
-
position: 'fixed', left: '
|
|
141
|
+
position: 'fixed', left: '0', top: '0', zIndex: '2147483646', pointerEvents: 'none', whiteSpace: 'nowrap',
|
|
98
142
|
font: '600 30px -apple-system, "Segoe UI", sans-serif', color: '#fff', background: 'rgba(15,23,42,.88)',
|
|
99
143
|
padding: '14px 26px', borderRadius: '14px', boxShadow: '0 10px 30px rgba(0,0,0,.3)', letterSpacing: '.04em',
|
|
100
144
|
});
|
|
101
145
|
document.documentElement.appendChild(el);
|
|
102
|
-
|
|
146
|
+
const { width: w, height: h } = el.getBoundingClientRect();
|
|
147
|
+
const left = Math.max(16, Math.min(innerWidth - w - 16, x - w / 2));
|
|
148
|
+
const top = y + 48 + h < innerHeight - 16 ? y + 48 : Math.max(16, y - 40 - h);
|
|
149
|
+
Object.assign(el.style, { left: `${left}px`, top: `${top}px` });
|
|
150
|
+
el.animate([{ opacity: 0, transform: 'scale(.9)' }, { opacity: 1, transform: 'scale(1)', offset: 0.15 },
|
|
103
151
|
{ opacity: 1, offset: 0.8 }, { opacity: 0 }], { duration: 1400, easing: 'ease-out' }).onfinish = () => el.remove();
|
|
152
|
+
return { x: left, y: top, width: w, height: h };
|
|
104
153
|
};
|
|
105
154
|
// `mark`: a highlighter sweeps over a phrase (a text Range, even across inline elements) or an
|
|
106
155
|
// element, line by line. Marks follow scrolling (positions are re-read every frame).
|
|
156
|
+
// What of a rect is really on screen: cut to the viewport and to every ancestor of `from` that clips
|
|
157
|
+
// its overflow (a panel scrolled past it, a collapsed section). scrollable: false cuts only to the
|
|
158
|
+
// clippers that can't scroll (what scrolling could never bring into view). null when nothing is left.
|
|
159
|
+
window.__demoClip = (q, from, { scrollable = true } = {}) => {
|
|
160
|
+
let { left, top, right, bottom } = q;
|
|
161
|
+
for (let a = from; a && a !== document.documentElement && a !== document.body; a = a.parentElement) {
|
|
162
|
+
const s = getComputedStyle(a);
|
|
163
|
+
const clips = s.overflowX !== 'visible' || s.overflowY !== 'visible', scrolls = /auto|scroll/.test(s.overflowX + s.overflowY);
|
|
164
|
+
if (clips && (scrollable || !scrolls)) {
|
|
165
|
+
const b = a.getBoundingClientRect();
|
|
166
|
+
left = Math.max(left, b.left); top = Math.max(top, b.top); right = Math.min(right, b.right); bottom = Math.min(bottom, b.bottom);
|
|
167
|
+
}
|
|
168
|
+
if (s.position === 'fixed') break; // a fixed element isn't clipped by what's around it
|
|
169
|
+
}
|
|
170
|
+
if (scrollable) { left = Math.max(left, 0); top = Math.max(top, 0); right = Math.min(right, innerWidth); bottom = Math.min(bottom, innerHeight); }
|
|
171
|
+
return right - left > 1 && bottom - top > 1 ? { left, top, right, bottom } : null;
|
|
172
|
+
};
|
|
173
|
+
// how a text range or element shows: 'shown' (on screen, nothing over it), 'away' (scrolling brings it
|
|
174
|
+
// into view), 'covered' (on screen, under something else) or 'hidden' (invisible, clipped, collapsed)
|
|
175
|
+
window.__demoShows = (src) => {
|
|
176
|
+
const el = src instanceof Range ? (src.commonAncestorContainer.nodeType === 1 ? src.commonAncestorContainer : src.commonAncestorContainer.parentElement) : src;
|
|
177
|
+
if (!el || getComputedStyle(el).visibility !== 'visible') return 'hidden';
|
|
178
|
+
for (let a = el; a; a = a.parentElement) if (+getComputedStyle(a).opacity < 0.05) return 'hidden';
|
|
179
|
+
const rects = [...(src instanceof Range ? src.getClientRects() : [src.getBoundingClientRect()])].filter((q) => q.width > 1 && q.height > 1);
|
|
180
|
+
const from = src instanceof Range ? el : el.parentElement;
|
|
181
|
+
if (!rects.some((q) => window.__demoClip(q, from, { scrollable: false }))) return 'hidden';
|
|
182
|
+
const seen = rects.map((q) => window.__demoClip(q, from)).filter(Boolean);
|
|
183
|
+
if (!seen.length) return 'away';
|
|
184
|
+
const q = seen.sort((a, b) => (b.right - b.left) * (b.bottom - b.top) - (a.right - a.left) * (a.bottom - a.top))[0];
|
|
185
|
+
const hit = document.elementFromPoint((q.left + q.right) / 2, (q.top + q.bottom) / 2);
|
|
186
|
+
return !hit || el.contains(hit) || hit.contains(el) && getComputedStyle(el).pointerEvents === 'none' || (!(src instanceof Range) && hit.contains(el)) ? 'shown' : 'covered';
|
|
187
|
+
};
|
|
107
188
|
window.__demoFind = (phrase) => {
|
|
108
189
|
const norm = (s) => s.replace(/\s+/g, ' ');
|
|
109
190
|
const want = norm(phrase).trim();
|
|
@@ -126,27 +207,29 @@ function pageScript({ scrollOffset }) {
|
|
|
126
207
|
const r = document.createRange();
|
|
127
208
|
const [sn, so] = map[at], [en, eo] = map[at + needle.length - 1];
|
|
128
209
|
r.setStart(sn, so); r.setEnd(en, eo + 1);
|
|
129
|
-
|
|
130
|
-
|
|
210
|
+
// text the viewer can't see (hidden, clipped, collapsed, or a page's old view kept in the DOM) doesn't count
|
|
211
|
+
const shows = window.__demoShows(r);
|
|
212
|
+
if (shows !== 'hidden') ranges.push({ r, shows });
|
|
131
213
|
}
|
|
132
214
|
if (ranges.length) break;
|
|
133
215
|
}
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
if (!
|
|
137
|
-
window.__demoFound = r;
|
|
138
|
-
return { inView:
|
|
216
|
+
// on screen first, then what a scroll brings into view, then what something covers right now
|
|
217
|
+
const found = ['shown', 'away', 'covered'].map((s) => ranges.find((x) => x.shows === s)).find(Boolean);
|
|
218
|
+
if (!found) return null;
|
|
219
|
+
window.__demoFound = found.r;
|
|
220
|
+
return { inView: found.shows === 'shown', count: ranges.length };
|
|
139
221
|
};
|
|
140
222
|
window.__demoMark = (what, { ms = 700, color, box = false }) => {
|
|
141
223
|
const layer = document.getElementById('__demo_marks') ?? document.documentElement.appendChild(Object.assign(document.createElement('div'), { id: '__demo_marks' }));
|
|
142
224
|
// multiply on the layer itself (its children are isolated inside it), so text under a mark stays readable
|
|
143
225
|
Object.assign(layer.style, { position: 'fixed', inset: '0', zIndex: '2147483644', pointerEvents: 'none', transition: 'opacity .3s ease', mixBlendMode: 'multiply' });
|
|
144
226
|
const src = what === 'found' ? window.__demoFound : window.__demoMarkEl;
|
|
227
|
+
const clipFrom = what === 'found' ? (src.commonAncestorContainer.nodeType === 1 ? src.commonAncestorContainer : src.commonAncestorContainer.parentElement) : src.parentElement;
|
|
145
228
|
const rectsOf = () => {
|
|
146
|
-
const all = what === 'found' ? [...src.getClientRects()] : [src.getBoundingClientRect()];
|
|
229
|
+
const all = (what === 'found' ? [...src.getClientRects()] : [src.getBoundingClientRect()]).filter((q) => q.width > 1 && q.height > 1);
|
|
147
230
|
// one rect per line: merge the pieces of inline elements that sit on the same line
|
|
148
231
|
const lines = [];
|
|
149
|
-
for (const q of all
|
|
232
|
+
for (const q of all) {
|
|
150
233
|
const l = lines.find((l) => Math.abs(l.top - q.top) < q.height * 0.5);
|
|
151
234
|
if (l) { l.left = Math.min(l.left, q.left); l.right = Math.max(l.right, q.right); l.top = Math.min(l.top, q.top); l.bottom = Math.max(l.bottom, q.bottom); }
|
|
152
235
|
else lines.push({ left: q.left, right: q.right, top: q.top, bottom: q.bottom });
|
|
@@ -167,17 +250,21 @@ function pageScript({ scrollOffset }) {
|
|
|
167
250
|
delay += d;
|
|
168
251
|
return el;
|
|
169
252
|
});
|
|
253
|
+
// each line drawn only where it's on screen: text scrolled out of its panel (or out from under a
|
|
254
|
+
// header) loses its mark there instead of leaving it floating over whatever is now in that place
|
|
170
255
|
const place = () => {
|
|
171
256
|
if (!els[0]?.isConnected) return;
|
|
172
|
-
rectsOf()
|
|
173
|
-
|
|
174
|
-
const pad = box ? 6 : 2;
|
|
175
|
-
|
|
257
|
+
const now = rectsOf();
|
|
258
|
+
els.forEach((el, i) => {
|
|
259
|
+
const q = now[i] && window.__demoClip(now[i], clipFrom), pad = box ? 6 : 2;
|
|
260
|
+
if (!q) { el.style.display = 'none'; return; }
|
|
261
|
+
Object.assign(el.style, { display: '', left: `${q.left - pad}px`, top: `${q.top - (box ? pad : 0)}px`, width: `${q.right - q.left + 2 * pad}px`, height: `${q.bottom - q.top + (box ? 2 * pad : 0)}px` });
|
|
176
262
|
});
|
|
177
263
|
requestAnimationFrame(place);
|
|
178
264
|
};
|
|
179
265
|
place();
|
|
180
|
-
const
|
|
266
|
+
const shown = first.map((q) => window.__demoClip(q, clipFrom)).filter(Boolean);
|
|
267
|
+
const u = (shown.length ? shown : first).reduce((a, q) => ({ x: Math.min(a.x, q.left), y: Math.min(a.y, q.top), r: Math.max(a.r, q.right), b: Math.max(a.b, q.bottom) }), { x: 1e9, y: 1e9, r: -1e9, b: -1e9 });
|
|
181
268
|
return { x: u.x, y: u.y, width: u.r - u.x, height: u.b - u.y };
|
|
182
269
|
};
|
|
183
270
|
// `note`: a short label with an arrow pointing at an element, placed on the side with the most room.
|
|
@@ -348,6 +435,8 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
348
435
|
});
|
|
349
436
|
const sleep = (ms) => page.waitForTimeout(ms);
|
|
350
437
|
const now = () => Date.now() / 1000;
|
|
438
|
+
const warned = []; // also printed by inspect --after, whose log is silent
|
|
439
|
+
const warn = (m) => { warned.push(m); log(` ⚠ ${m}`); };
|
|
351
440
|
|
|
352
441
|
// --- cursor ---------------------------------------------------------------------
|
|
353
442
|
let cur = { x: VW * 0.62, y: VH * 0.55 };
|
|
@@ -403,7 +492,13 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
403
492
|
const l = locate(page, spec);
|
|
404
493
|
await waiting(() => l.waitFor({ state: 'visible', timeout }));
|
|
405
494
|
await l.scrollIntoViewIfNeeded();
|
|
406
|
-
|
|
495
|
+
let b = await l.boundingBox();
|
|
496
|
+
if (b && (b.x + b.width <= 0 || b.y + b.height <= 0)) {
|
|
497
|
+
// parked off screen, the way styled radios and checkboxes hide the real input: go to its label
|
|
498
|
+
const label = (await l.evaluateHandle((el) => [...(el.labels ?? [])].find((x) => { const r = x.getBoundingClientRect(); return r.width > 2 && r.height > 2 && r.right > 0; }) ?? null).catch(() => null))?.asElement();
|
|
499
|
+
if (label) { await label.scrollIntoViewIfNeeded().catch(() => {}); b = (await label.boundingBox()) ?? b; }
|
|
500
|
+
else warn(`${acting}: the target is off screen (at x=${Math.round(b.x)}, y=${Math.round(b.y)}) — the page hides it there; target what it shows instead (inspect lists it)`);
|
|
501
|
+
}
|
|
407
502
|
boxes.set(keyOf(spec), b);
|
|
408
503
|
marks.push({ t: now(), box: b });
|
|
409
504
|
const o = opt.offset ?? { x: 0.5, y: 0.5 };
|
|
@@ -424,7 +519,11 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
424
519
|
await sleep(280);
|
|
425
520
|
return true;
|
|
426
521
|
}
|
|
427
|
-
const spotOff = () => page.evaluate(() => window.__demoSpotOff()).catch(() => {});
|
|
522
|
+
const spotOff = (instant) => page.evaluate((i) => window.__demoSpotOff(i), !!instant).catch(() => {});
|
|
523
|
+
const keys = async (label) => {
|
|
524
|
+
const box = await page.evaluate(([l, x, y]) => window.__demoKeys(l, x, y), [label, cur.x, cur.y]).catch(() => null);
|
|
525
|
+
if (box) marks.push({ t: now(), box });
|
|
526
|
+
};
|
|
428
527
|
const keyLabel = (k) => k.split('+').map((p) => ({ Meta: '⌘', ControlOrMeta: '⌘', Control: 'Ctrl', Shift: '⇧', Alt: '⌥', Enter: '↵ Enter', Escape: 'Esc', Backspace: '⌫', Tab: '⇥ Tab' })[p] ?? (p.length === 1 ? p.toUpperCase() : p)).join(' ');
|
|
429
528
|
|
|
430
529
|
async function runAction(action) {
|
|
@@ -443,6 +542,16 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
443
542
|
const p = await pointOf(spec, opt);
|
|
444
543
|
const lit = await spot(p.box, hl);
|
|
445
544
|
await moveTo(p.x, p.y, opt.ms ?? cfg.pace.cursor);
|
|
545
|
+
if (!isPoint(spec)) {
|
|
546
|
+
const over = await locate(page, spec).evaluate((el, [x, y]) => {
|
|
547
|
+
const hit = document.elementFromPoint(x, y);
|
|
548
|
+
const label = el.closest('label') ?? [...(el.labels ?? [])].find((l) => l.contains(hit));
|
|
549
|
+
if (!hit || el.contains(hit) || hit.contains(el) || (label && label.contains(hit)) || el.getRootNode() !== document || hit.closest('[id^=__demo]')) return null;
|
|
550
|
+
const text = (hit.innerText ?? '').replace(/\s+/g, ' ').trim().slice(0, 50);
|
|
551
|
+
return hit.tagName.toLowerCase() + [...hit.classList].slice(0, 2).map((c) => `.${c}`).join('') + (text ? ` "${text}"` : '');
|
|
552
|
+
}, [p.x, p.y]).catch(() => null);
|
|
553
|
+
if (over) warn(`${acting}: ${over} is on top of the click target, so the click lands on it — close or hide it first (in setup: click its close button, or hide: <its css>)`);
|
|
554
|
+
}
|
|
446
555
|
await sleep(lit ? 350 + cfg.pace.beat / 3 : 120);
|
|
447
556
|
if (kind === 'dblclick') { clicks.push(now()); await page.mouse.dblclick(p.x, p.y); } else await press();
|
|
448
557
|
if (lit) { await sleep(150); await spotOff(); }
|
|
@@ -463,7 +572,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
463
572
|
}
|
|
464
573
|
case 'dismiss': {
|
|
465
574
|
const l = locate(page, spec);
|
|
466
|
-
if (await l.isVisible().catch(() => false)) { await l.click(); await sleep(400); }
|
|
575
|
+
if (await l.isVisible().catch(() => false)) { await l.click({ timeout: 10000 }); await sleep(400); }
|
|
467
576
|
break;
|
|
468
577
|
}
|
|
469
578
|
case 'waitFor': await waiting(() => locate(page, spec).waitFor({ state: 'visible', timeout: opt.timeout ?? 180000 })); await settle(); await nudge(); break;
|
|
@@ -473,6 +582,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
473
582
|
case 'scrollTo': {
|
|
474
583
|
const h = await locate(page, spec).elementHandle({ timeout: opt.timeout ?? 15000 });
|
|
475
584
|
await page.evaluate(([el, ms]) => window.__demoScrollTo(el, ms), [h, opt.ms ?? 1200]);
|
|
585
|
+
// where it landed: a zoom on the same target frames it there, not where it was before the scroll
|
|
586
|
+
const b = await h.boundingBox().catch(() => null);
|
|
587
|
+
if (b) boxes.set(keyOf(spec), b);
|
|
476
588
|
break;
|
|
477
589
|
}
|
|
478
590
|
case 'scroll': await page.evaluate(([dy, ms]) => window.__demoScrollBy(dy, ms), [value.y ?? value, value.ms ?? 1000]); break;
|
|
@@ -558,7 +670,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
558
670
|
document.addEventListener('paste', (e) => { window.__demoPasted = !!e.clipboardData?.getData('text'); }, { capture: true, once: true });
|
|
559
671
|
return true;
|
|
560
672
|
}, () => false), text).catch(() => false);
|
|
561
|
-
if (!fast) await
|
|
673
|
+
if (!fast) await keys(process.platform === 'darwin' ? '⌘ V' : 'Ctrl V');
|
|
562
674
|
if (copied) await page.keyboard.press('ControlOrMeta+V');
|
|
563
675
|
await sleep(200);
|
|
564
676
|
const pasted = copied && await page.evaluate(() => window.__demoPasted === true).catch(() => false);
|
|
@@ -570,25 +682,28 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
570
682
|
}
|
|
571
683
|
case 'slide': {
|
|
572
684
|
// slide: { target: <slider>, to: 0.2 } — drag a slider to a position along its track (0 = start,
|
|
573
|
-
// 1 = end),
|
|
574
|
-
|
|
685
|
+
// 1 = end), or { target, value: 3 } — to a value, for sliders that report their range; found from
|
|
686
|
+
// the element instead of pixel positions that break when the layout moves
|
|
687
|
+
if (!value?.target || (typeof value?.to !== 'number' && typeof value?.value !== 'number')) throw new Error('slide needs { target: <slider>, to: 0..1 } or { target: <slider>, value: <number> }');
|
|
575
688
|
const p = await pointOf(value.target, value);
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
689
|
+
const s = await locate(page, value.target).evaluate((el) => {
|
|
690
|
+
// a knob (Desmos's [role=slider] is its 38 px thumb): slide along its track, the nearest wide parent
|
|
691
|
+
let track = el;
|
|
692
|
+
const w = el.getBoundingClientRect().width;
|
|
693
|
+
for (let a = el.parentElement, i = 0; w < 60 && a && i < 4; a = a.parentElement, i++) if (a.getBoundingClientRect().width >= 3 * w) { track = a; break; }
|
|
694
|
+
const t = track.getBoundingClientRect(), v = window.__demoSliderValue(el);
|
|
695
|
+
let from = v ? (v.now - v.lo) / ((v.hi - v.lo) || 1) : null;
|
|
696
|
+
if (from == null) {
|
|
697
|
+
// custom sliders: start where the knob is drawn, so the value doesn't jump first
|
|
698
|
+
const knob = [...track.querySelectorAll('[role=slider], [class*=thumb], [class*=handle], [class*=knob]')]
|
|
699
|
+
.map((k) => k.getBoundingClientRect()).find((k) => k.width > 0 && k.width < t.width * 0.5);
|
|
700
|
+
from = knob ? (knob.x + knob.width / 2 - t.x) / (t.width || 1) : 0.5;
|
|
584
701
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}).catch(() => 0.5);
|
|
591
|
-
const x0 = b.x + b.width * from, x1 = b.x + b.width * Math.max(0, Math.min(1, value.to)), y = b.y + b.height / 2;
|
|
702
|
+
return { box: { x: t.x, y: t.y, width: t.width, height: t.height }, from, range: v && { lo: v.lo, hi: v.hi } };
|
|
703
|
+
}).catch(() => ({ box: p.box, from: 0.5, range: null }));
|
|
704
|
+
if (typeof value.value === 'number' && !s.range) throw new Error('slide value: this slider doesn\'t report its range (no aria-valuemin/max, not an <input type=range>) — use to: 0..1');
|
|
705
|
+
const to = typeof value.value === 'number' ? (value.value - s.range.lo) / ((s.range.hi - s.range.lo) || 1) : value.to;
|
|
706
|
+
const b = s.box, x0 = b.x + b.width * s.from, x1 = b.x + b.width * Math.max(0, Math.min(1, to)), y = b.y + b.height / 2;
|
|
592
707
|
await moveTo(x0, y, value.ms ?? cfg.pace.cursor);
|
|
593
708
|
await sleep(120);
|
|
594
709
|
clicks.push(now());
|
|
@@ -596,6 +711,19 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
596
711
|
await moveTo(x1, y, value.dragMs ?? 900);
|
|
597
712
|
await sleep(120);
|
|
598
713
|
await page.mouse.up();
|
|
714
|
+
if (typeof value.value === 'number') {
|
|
715
|
+
// a native range's thumb doesn't reach the ends of its box, so the drag can land a step off: set it
|
|
716
|
+
const got = await locate(page, value.target).evaluate((el, want) => {
|
|
717
|
+
const v = window.__demoSliderValue(el), r = v?.native;
|
|
718
|
+
if (r && v.now !== want) {
|
|
719
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(r, String(want));
|
|
720
|
+
r.dispatchEvent(new Event('input', { bubbles: true })); r.dispatchEvent(new Event('change', { bubbles: true }));
|
|
721
|
+
return Number(r.value);
|
|
722
|
+
}
|
|
723
|
+
return v?.now;
|
|
724
|
+
}, value.value).catch(() => null);
|
|
725
|
+
if (got != null && Math.abs(got - value.value) > 1e-9) warn(`${acting}: slide stopped at ${got}, not ${value.value} — the slider snaps to its step`);
|
|
726
|
+
}
|
|
599
727
|
await settle();
|
|
600
728
|
await beat();
|
|
601
729
|
break;
|
|
@@ -608,11 +736,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
608
736
|
let box = null;
|
|
609
737
|
const phraseOnly = v.text && Object.keys(v).every((k) => ['text', 'exact'].includes(k));
|
|
610
738
|
if (phraseOnly) {
|
|
739
|
+
await page.evaluate(() => window.__demoScrollIdle()).catch(() => {}); // judge "in view" where the page ends up
|
|
611
740
|
const found = await page.evaluate((t) => window.__demoFind(t), v.text);
|
|
612
|
-
if (found && !found.inView)
|
|
613
|
-
await page.evaluate((ms) => window.__demoScrollTo(window.__demoFound.startContainer.parentElement, ms), fast ? 0 : 900);
|
|
614
|
-
await sleep(fast ? 50 : 950);
|
|
615
|
-
}
|
|
741
|
+
if (found && !found.inView) await page.evaluate((ms) => window.__demoScrollTo(window.__demoFound.startContainer.parentElement, ms), fast ? 0 : 900);
|
|
616
742
|
if (found) box = await page.evaluate(([ms, color, b]) => window.__demoMark('found', { ms, color, box: b }), [ms, color, opt.box ?? false]);
|
|
617
743
|
}
|
|
618
744
|
if (!box) {
|
|
@@ -637,8 +763,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
637
763
|
await l.evaluate((el) => { window.__demoMarkEl = el; });
|
|
638
764
|
const box = await page.evaluate(([t, o]) => window.__demoNote(t, o), [String(value.text), { side: value.side, ink: cfg.theme.ink, accent: cfg.theme.accent }]);
|
|
639
765
|
marks.push({ t: now(), box });
|
|
640
|
-
// reading time: about 3 words
|
|
641
|
-
const
|
|
766
|
+
// reading time: about 3 words a second, or 5 Chinese/Japanese/Korean characters; at least a second
|
|
767
|
+
const t = String(value.text), cjk = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g;
|
|
768
|
+
const words = t.replace(cjk, ' ').split(/\s+/).filter(Boolean).length, chars = (t.match(cjk) ?? []).length;
|
|
769
|
+
const read = fast ? 0 : value.ms ?? Math.max(1000, words * 330 + chars * 200);
|
|
642
770
|
holds += read / 1000;
|
|
643
771
|
await sleep(read);
|
|
644
772
|
break;
|
|
@@ -647,7 +775,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
647
775
|
const key = typeof value === 'object' ? value.key : String(value);
|
|
648
776
|
// on-screen key badge: shortcuts and special keys only (not plain characters), when highlighting
|
|
649
777
|
const show = value?.show ?? (hl && (key.includes('+') || key.length > 1));
|
|
650
|
-
if (show && !fast) await
|
|
778
|
+
if (show && !fast) await keys(keyLabel(key));
|
|
651
779
|
await page.keyboard.press(key);
|
|
652
780
|
await sleep(200);
|
|
653
781
|
if (key === 'Enter') { await settle(); await beat(); }
|
|
@@ -659,7 +787,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
659
787
|
|
|
660
788
|
// --- setup (not recorded) -----------------------------------------------------------
|
|
661
789
|
// Run a list of actions; on failure, say exactly which one and save a screenshot.
|
|
790
|
+
let acting = '';
|
|
662
791
|
async function runAll(actions, where) {
|
|
792
|
+
acting = where;
|
|
663
793
|
for (const [j, a] of actions.entries()) {
|
|
664
794
|
try {
|
|
665
795
|
// watchdog: an action that hangs (a frozen page, a stalled browser) fails with a clear message
|
|
@@ -690,10 +820,14 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
690
820
|
const lw = await detectLogin(page).catch(() => ({})), loginWall = lw.wall ? lw.reason : null;
|
|
691
821
|
if (desc) fs.writeFileSync(pageFile, desc.text);
|
|
692
822
|
await browser.close().catch(() => {});
|
|
823
|
+
// Playwright's call log says when another element takes the click (a modal, a banner, an overlay)
|
|
824
|
+
const blocker = e.message.replace(/\u001b\[\d+m/g, '').match(/\n\s*-\s*(?:<[^\n]*?> from )?(<[^\n]*?>)(?: subtree)? intercepts pointer events/)?.[1];
|
|
693
825
|
const err = new Error(`${where}, action ${j + 1} (${kind}: ${JSON.stringify(value)}) failed: ` +
|
|
694
|
-
`${e.message.split('\n')[0]}${matches}
|
|
826
|
+
`${e.message.split('\n')[0]}${matches}` +
|
|
827
|
+
(blocker ? `\n another element is on top of it and takes the click: ${blocker.slice(0, 200)} — close it first (click its button, or hide: it in setup)` : '') +
|
|
828
|
+
`\n page: ${page.url()}\n screenshot of the moment it failed: ${shot}` +
|
|
695
829
|
(loginWall ? `\n ⚠ the page is asking to log in (${loginWall}) — ${cfg.storageState ? 'the saved session has probably expired; run login again' : loginAdvice(cfg.url)}` : '') +
|
|
696
|
-
(desc ? `\n targets on the page at that moment: ${pageFile}` : ''));
|
|
830
|
+
(desc ? (fast ? `\n\nThe page at that moment:\n${desc.text}` : `\n targets on the page at that moment: ${pageFile}`) : ''));
|
|
697
831
|
err.screenshot = shot;
|
|
698
832
|
err.pageDescription = desc?.text;
|
|
699
833
|
throw err;
|
|
@@ -716,8 +850,11 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
716
850
|
await runAll(cfg.setup, 'setup');
|
|
717
851
|
for (const [card, file] of [[cfg.endCard, 'logo.png'], [cfg.intro, 'intro-logo.png']]) {
|
|
718
852
|
if (!card?.logo || card.logoFile) continue;
|
|
719
|
-
await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
|
|
720
|
-
.catch((
|
|
853
|
+
await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 }).catch(async (e) => {
|
|
854
|
+
const all = locateAll(page, card.logo), n = await all.count().catch(() => 0), v = await all.filter({ visible: true }).count().catch(() => 0);
|
|
855
|
+
const why = !n ? 'nothing on the first page matches it' : !v ? `it matches ${n} element(s), none of them visible` : e.message.split('\n')[0];
|
|
856
|
+
log(`warning: could not capture the ${card === cfg.intro ? 'intro' : 'end card'} logo ${JSON.stringify(card.logo)}: ${why} — an inline <svg> logo is { css: "header a svg" } or similar, not an img; or give a logo file`);
|
|
857
|
+
});
|
|
721
858
|
}
|
|
722
859
|
// where the intro's logo sits on the first page: a HyperFrames launch intro lands its logo there
|
|
723
860
|
const introLogo = cfg.intro?.logo && !cfg.intro.logoFile
|
|
@@ -757,18 +894,21 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
757
894
|
|
|
758
895
|
if (fast) {
|
|
759
896
|
const n = Math.min(inspectAfter, cfg.steps.length);
|
|
897
|
+
let before = null;
|
|
760
898
|
for (const [i, s] of cfg.steps.slice(0, n).entries()) {
|
|
761
899
|
stepHighlight = false;
|
|
762
|
-
|
|
763
|
-
|
|
900
|
+
if (i === n - 1) before = await clickableKeys(page);
|
|
901
|
+
// like the recording: marks, notes and spotlights end with their step (the last step's stay in the screenshot)
|
|
902
|
+
if (i > 0) await page.evaluate(() => { window.__demoUnmark(true); window.__demoUnnote(true); window.__demoSpotOff(true); }).catch(() => {});
|
|
764
903
|
await runAll(s.actions, `step ${i + 1}`);
|
|
904
|
+
await page.evaluate(() => window.__demoScrollIdle()).catch(() => {});
|
|
765
905
|
}
|
|
766
906
|
await sleep(800);
|
|
767
907
|
const shot = path.join(cfg.work, `inspect-after-${n}.jpg`);
|
|
768
908
|
await page.screenshot({ path: shot, quality: 80, scale: 'css' });
|
|
769
|
-
const desc = await describePage(page, cfg.viewport);
|
|
909
|
+
const desc = await describePage(page, cfg.viewport, { before });
|
|
770
910
|
await browser.close();
|
|
771
|
-
return { text: `State after ${n ? `step ${n}` : 'setup'} of ${cfg.name}:\n${desc.text}\n\nScreenshot: ${shot}`, screenshot: shot };
|
|
911
|
+
return { text: `State after ${n ? `step ${n}` : 'setup'} of ${cfg.name}:\n${warned.map((w) => `⚠ ${w}\n`).join('')}${desc.text}\n\nScreenshot: ${shot}`, screenshot: shot };
|
|
772
912
|
}
|
|
773
913
|
|
|
774
914
|
// --- recording ----------------------------------------------------------------------
|
|
@@ -804,10 +944,14 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
804
944
|
const zoomTarget = s.zoom?.target && !isPoint(s.zoom.target);
|
|
805
945
|
const startBox = zoomTarget ? await boxNow() : null;
|
|
806
946
|
await runAll(s.actions, `step ${i + 1}`);
|
|
947
|
+
// a scroll still running (the page's own smooth scrolling) ends in this step, not in the next one's first shot
|
|
948
|
+
await page.evaluate(() => window.__demoScrollIdle()).catch(() => {});
|
|
807
949
|
const end = now(); // before clearing: a step that is held for its narration keeps its marks
|
|
808
|
-
|
|
809
|
-
//
|
|
810
|
-
|
|
950
|
+
// docs clips start where the step starts, and a hidden step is cut: clear at once, so no fading
|
|
951
|
+
// callout leaks into the next shot
|
|
952
|
+
const instant = cfg.clips.length > 0 || !!s.hidden;
|
|
953
|
+
await spotOff(instant);
|
|
954
|
+
await page.evaluate((i) => { window.__demoUnmark(i); window.__demoUnnote(i); }, instant).catch(() => {});
|
|
811
955
|
let zoomBox = null;
|
|
812
956
|
if (s.zoom?.target) {
|
|
813
957
|
if (isPoint(s.zoom.target)) {
|