storyshot 0.7.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 +37 -10
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +18 -3
- package/src/check.mjs +3 -1
- package/src/compose.mjs +28 -9
- package/src/config.mjs +1 -1
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +3 -2
- package/src/inspect.mjs +51 -15
- package/src/qwen-tts.mjs +10 -6
- package/src/qwen_tts.py +24 -9
- package/src/record.mjs +110 -32
- package/src/voice.mjs +5 -5
package/src/qwen_tts.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# storyshot's own venv (qwen-tts.mjs), one process per render: the model loads once for all lines.
|
|
3
3
|
#
|
|
4
4
|
# stdin: {"repo", "revision", "temperature", "asr": {"repo", "revision"} | null,
|
|
5
|
-
# "lines": [{"text", "file", "voice", "lang", "seed"}]}
|
|
5
|
+
# "lines": [{"text", "written", "file", "voice", "lang", "seed"}]}
|
|
6
|
+
# (text: what the model reads; written: the line as the storyboard has it, before `pronounce`)
|
|
6
7
|
# stderr: "line i/n <seconds>s [<tries> tries]" per line, plus whatever the libraries log
|
|
7
8
|
# stdout: {"results": [{"seconds", "tries", "suspect", "heard", "mismatch"}], "asr": null | "why it didn't load"}
|
|
8
9
|
#
|
|
@@ -15,6 +16,7 @@ import json
|
|
|
15
16
|
import os
|
|
16
17
|
import re
|
|
17
18
|
import sys
|
|
19
|
+
import unicodedata
|
|
18
20
|
|
|
19
21
|
os.environ.setdefault('HF_HUB_DISABLE_TELEMETRY', '1')
|
|
20
22
|
os.environ.setdefault('TRANSFORMERS_VERBOSITY', 'error')
|
|
@@ -72,12 +74,13 @@ def cn_number(m):
|
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
def units(text, chinese, pinyin):
|
|
75
|
-
"""The line as a list of sounds: pinyin syllables, digits, and Latin words as '#word'
|
|
76
|
-
|
|
77
|
+
"""The line as a list of sounds: pinyin syllables, digits, and Latin words as '#word' without accents
|
|
78
|
+
(m x and mx as '#m', '#x')."""
|
|
79
|
+
t = ''.join(c for c in unicodedata.normalize('NFD', text.lower().replace('−', '-')) if unicodedata.category(c) != 'Mn')
|
|
77
80
|
if chinese:
|
|
78
81
|
t = re.sub(r'\d+(?:\.\d+)?', cn_number, t)
|
|
79
82
|
out = []
|
|
80
|
-
for tok in re.findall(r'[㐀-鿿]|[
|
|
83
|
+
for tok in re.findall(r'[㐀-鿿]|[^\W\d_㐀-鿿]+|\d+', t): # Han one by one, words (with accents), numbers
|
|
81
84
|
if chinese and re.match(r'[㐀-鿿]', tok):
|
|
82
85
|
out += [{'liang': 'er'}.get(p, p) for p in pinyin(tok)]
|
|
83
86
|
elif tok.isdigit():
|
|
@@ -92,6 +95,7 @@ def mismatch(text, heard, chinese, pinyin):
|
|
|
92
95
|
text. A garbled take has a run of several; a homophone or a misheard letter is one on its own."""
|
|
93
96
|
a, b = units(text, chinese, pinyin), units(heard, chinese, pinyin)
|
|
94
97
|
letter = lambda u: len(u) == 2 and u[0] == '#'
|
|
98
|
+
glue = lambda us: ''.join(u.lstrip('#') for u in us)
|
|
95
99
|
d = {(0, 0): (0, None)}
|
|
96
100
|
for i in range(len(a) + 1):
|
|
97
101
|
for j in range(len(b) + 1):
|
|
@@ -104,12 +108,18 @@ def mismatch(text, heard, chinese, pinyin):
|
|
|
104
108
|
ways.append((d[i, j - 1][0] + 1, (i, j - 1, 1)))
|
|
105
109
|
if i and j:
|
|
106
110
|
x, y = a[i - 1], b[j - 1]
|
|
107
|
-
# a variable (m, b, x) the recognizer wrote as a character
|
|
108
|
-
|
|
111
|
+
# a variable (m, b, x) the recognizer wrote as a character, or a number word it wrote in digits
|
|
112
|
+
# (tres → 3), is half a mistake
|
|
113
|
+
cost = 0 if x == y else 0.5 if letter(x) or letter(y) or x.isdigit() != y.isdigit() else 1
|
|
109
114
|
ways.append((d[i - 1, j - 1][0] + cost, (i - 1, j - 1, cost)))
|
|
110
115
|
# a name in Latin letters in a Chinese line (Desmos) can come back as a few syllables: one mistake
|
|
111
116
|
if chinese and len(x) > 3 and x[0] == '#':
|
|
112
117
|
ways += [(d[i - 1, j - k][0] + 1, (i - 1, j - k, 1)) for k in range(2, min(5, j) + 1)]
|
|
118
|
+
# one word written as two or three (escribámosla / escribamos la): no mistake
|
|
119
|
+
if len(x) > 3 and x[0] == '#':
|
|
120
|
+
ways += [(d[i - 1, j - k][0], (i - 1, j - k, 0)) for k in range(2, min(4, j) + 1) if glue(b[j - k:j]) == x[1:]]
|
|
121
|
+
if len(y) > 3 and y[0] == '#':
|
|
122
|
+
ways += [(d[i - k, j - 1][0], (i - k, j - 1, 0)) for k in range(2, min(4, i) + 1) if glue(a[i - k:i]) == y[1:]]
|
|
113
123
|
d[i, j] = min(ways, key=lambda w: w[0])
|
|
114
124
|
i, j, run, worst = len(a), len(b), 0, 0
|
|
115
125
|
while (i, j) != (0, 0):
|
|
@@ -144,15 +154,20 @@ def main():
|
|
|
144
154
|
def hear(audio, line):
|
|
145
155
|
"""(what the recognizer heard, mismatch), or (None, None) for a line it can't check."""
|
|
146
156
|
chinese = line['lang'] == 'chinese'
|
|
147
|
-
|
|
157
|
+
latin = {'english': 'English', 'spanish': 'Spanish', 'french': 'French', 'german': 'German', 'italian': 'Italian',
|
|
158
|
+
'portuguese': 'Portuguese'}.get(line['lang'], 'English' if line['lang'] == 'auto' else None)
|
|
159
|
+
if asr is None or not (chinese or (latin and not re.search(r'[㐀-鿿]', line['text']))):
|
|
148
160
|
return None, None
|
|
149
161
|
tmp = line['file'] + '.check.wav'
|
|
150
162
|
write(tmp, audio, model.sample_rate)
|
|
151
163
|
try:
|
|
152
|
-
heard = asr.generate(tmp, language='Chinese' if chinese else
|
|
164
|
+
heard = asr.generate(tmp, language='Chinese' if chinese else latin).text.strip()
|
|
153
165
|
finally:
|
|
154
166
|
os.remove(tmp)
|
|
155
|
-
|
|
167
|
+
# against the line as written too: `pronounce` respells words for the voice (y → ye), and the
|
|
168
|
+
# recognizer writes what it hears the usual way
|
|
169
|
+
ms = [mismatch(t, heard, chinese, pinyin) for t in dict.fromkeys([line['text'], line.get('written') or line['text']])]
|
|
170
|
+
return heard, min(ms, key=lambda m: max(m[0] / RETRY_AT, m[1] / RETRY_RUN))
|
|
156
171
|
|
|
157
172
|
results, n = [], len(job['lines'])
|
|
158
173
|
for i, line in enumerate(job['lines']):
|
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');
|
|
@@ -132,20 +132,59 @@ function pageScript({ scrollOffset }) {
|
|
|
132
132
|
if (el && instant) el.remove();
|
|
133
133
|
else if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
|
|
134
134
|
};
|
|
135
|
-
|
|
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) => {
|
|
136
138
|
const el = document.createElement('div');
|
|
137
139
|
el.textContent = label;
|
|
138
140
|
Object.assign(el.style, {
|
|
139
|
-
position: 'fixed', left: '
|
|
141
|
+
position: 'fixed', left: '0', top: '0', zIndex: '2147483646', pointerEvents: 'none', whiteSpace: 'nowrap',
|
|
140
142
|
font: '600 30px -apple-system, "Segoe UI", sans-serif', color: '#fff', background: 'rgba(15,23,42,.88)',
|
|
141
143
|
padding: '14px 26px', borderRadius: '14px', boxShadow: '0 10px 30px rgba(0,0,0,.3)', letterSpacing: '.04em',
|
|
142
144
|
});
|
|
143
145
|
document.documentElement.appendChild(el);
|
|
144
|
-
|
|
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 },
|
|
145
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 };
|
|
146
153
|
};
|
|
147
154
|
// `mark`: a highlighter sweeps over a phrase (a text Range, even across inline elements) or an
|
|
148
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
|
+
};
|
|
149
188
|
window.__demoFind = (phrase) => {
|
|
150
189
|
const norm = (s) => s.replace(/\s+/g, ' ');
|
|
151
190
|
const want = norm(phrase).trim();
|
|
@@ -168,27 +207,29 @@ function pageScript({ scrollOffset }) {
|
|
|
168
207
|
const r = document.createRange();
|
|
169
208
|
const [sn, so] = map[at], [en, eo] = map[at + needle.length - 1];
|
|
170
209
|
r.setStart(sn, so); r.setEnd(en, eo + 1);
|
|
171
|
-
|
|
172
|
-
|
|
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 });
|
|
173
213
|
}
|
|
174
214
|
if (ranges.length) break;
|
|
175
215
|
}
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
179
|
-
window.__demoFound = r;
|
|
180
|
-
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 };
|
|
181
221
|
};
|
|
182
222
|
window.__demoMark = (what, { ms = 700, color, box = false }) => {
|
|
183
223
|
const layer = document.getElementById('__demo_marks') ?? document.documentElement.appendChild(Object.assign(document.createElement('div'), { id: '__demo_marks' }));
|
|
184
224
|
// multiply on the layer itself (its children are isolated inside it), so text under a mark stays readable
|
|
185
225
|
Object.assign(layer.style, { position: 'fixed', inset: '0', zIndex: '2147483644', pointerEvents: 'none', transition: 'opacity .3s ease', mixBlendMode: 'multiply' });
|
|
186
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;
|
|
187
228
|
const rectsOf = () => {
|
|
188
|
-
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);
|
|
189
230
|
// one rect per line: merge the pieces of inline elements that sit on the same line
|
|
190
231
|
const lines = [];
|
|
191
|
-
for (const q of all
|
|
232
|
+
for (const q of all) {
|
|
192
233
|
const l = lines.find((l) => Math.abs(l.top - q.top) < q.height * 0.5);
|
|
193
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); }
|
|
194
235
|
else lines.push({ left: q.left, right: q.right, top: q.top, bottom: q.bottom });
|
|
@@ -209,17 +250,21 @@ function pageScript({ scrollOffset }) {
|
|
|
209
250
|
delay += d;
|
|
210
251
|
return el;
|
|
211
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
|
|
212
255
|
const place = () => {
|
|
213
256
|
if (!els[0]?.isConnected) return;
|
|
214
|
-
rectsOf()
|
|
215
|
-
|
|
216
|
-
const pad = box ? 6 : 2;
|
|
217
|
-
|
|
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` });
|
|
218
262
|
});
|
|
219
263
|
requestAnimationFrame(place);
|
|
220
264
|
};
|
|
221
265
|
place();
|
|
222
|
-
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 });
|
|
223
268
|
return { x: u.x, y: u.y, width: u.r - u.x, height: u.b - u.y };
|
|
224
269
|
};
|
|
225
270
|
// `note`: a short label with an arrow pointing at an element, placed on the side with the most room.
|
|
@@ -390,6 +435,8 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
390
435
|
});
|
|
391
436
|
const sleep = (ms) => page.waitForTimeout(ms);
|
|
392
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}`); };
|
|
393
440
|
|
|
394
441
|
// --- cursor ---------------------------------------------------------------------
|
|
395
442
|
let cur = { x: VW * 0.62, y: VH * 0.55 };
|
|
@@ -445,7 +492,13 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
445
492
|
const l = locate(page, spec);
|
|
446
493
|
await waiting(() => l.waitFor({ state: 'visible', timeout }));
|
|
447
494
|
await l.scrollIntoViewIfNeeded();
|
|
448
|
-
|
|
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
|
+
}
|
|
449
502
|
boxes.set(keyOf(spec), b);
|
|
450
503
|
marks.push({ t: now(), box: b });
|
|
451
504
|
const o = opt.offset ?? { x: 0.5, y: 0.5 };
|
|
@@ -467,6 +520,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
467
520
|
return true;
|
|
468
521
|
}
|
|
469
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
|
+
};
|
|
470
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(' ');
|
|
471
528
|
|
|
472
529
|
async function runAction(action) {
|
|
@@ -485,6 +542,16 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
485
542
|
const p = await pointOf(spec, opt);
|
|
486
543
|
const lit = await spot(p.box, hl);
|
|
487
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
|
+
}
|
|
488
555
|
await sleep(lit ? 350 + cfg.pace.beat / 3 : 120);
|
|
489
556
|
if (kind === 'dblclick') { clicks.push(now()); await page.mouse.dblclick(p.x, p.y); } else await press();
|
|
490
557
|
if (lit) { await sleep(150); await spotOff(); }
|
|
@@ -505,7 +572,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
505
572
|
}
|
|
506
573
|
case 'dismiss': {
|
|
507
574
|
const l = locate(page, spec);
|
|
508
|
-
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); }
|
|
509
576
|
break;
|
|
510
577
|
}
|
|
511
578
|
case 'waitFor': await waiting(() => locate(page, spec).waitFor({ state: 'visible', timeout: opt.timeout ?? 180000 })); await settle(); await nudge(); break;
|
|
@@ -603,7 +670,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
603
670
|
document.addEventListener('paste', (e) => { window.__demoPasted = !!e.clipboardData?.getData('text'); }, { capture: true, once: true });
|
|
604
671
|
return true;
|
|
605
672
|
}, () => false), text).catch(() => false);
|
|
606
|
-
if (!fast) await
|
|
673
|
+
if (!fast) await keys(process.platform === 'darwin' ? '⌘ V' : 'Ctrl V');
|
|
607
674
|
if (copied) await page.keyboard.press('ControlOrMeta+V');
|
|
608
675
|
await sleep(200);
|
|
609
676
|
const pasted = copied && await page.evaluate(() => window.__demoPasted === true).catch(() => false);
|
|
@@ -655,7 +722,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
655
722
|
}
|
|
656
723
|
return v?.now;
|
|
657
724
|
}, value.value).catch(() => null);
|
|
658
|
-
if (got != null && Math.abs(got - value.value) > 1e-9)
|
|
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`);
|
|
659
726
|
}
|
|
660
727
|
await settle();
|
|
661
728
|
await beat();
|
|
@@ -696,8 +763,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
696
763
|
await l.evaluate((el) => { window.__demoMarkEl = el; });
|
|
697
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 }]);
|
|
698
765
|
marks.push({ t: now(), box });
|
|
699
|
-
// reading time: about 3 words
|
|
700
|
-
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);
|
|
701
770
|
holds += read / 1000;
|
|
702
771
|
await sleep(read);
|
|
703
772
|
break;
|
|
@@ -706,7 +775,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
706
775
|
const key = typeof value === 'object' ? value.key : String(value);
|
|
707
776
|
// on-screen key badge: shortcuts and special keys only (not plain characters), when highlighting
|
|
708
777
|
const show = value?.show ?? (hl && (key.includes('+') || key.length > 1));
|
|
709
|
-
if (show && !fast) await
|
|
778
|
+
if (show && !fast) await keys(keyLabel(key));
|
|
710
779
|
await page.keyboard.press(key);
|
|
711
780
|
await sleep(200);
|
|
712
781
|
if (key === 'Enter') { await settle(); await beat(); }
|
|
@@ -751,10 +820,14 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
751
820
|
const lw = await detectLogin(page).catch(() => ({})), loginWall = lw.wall ? lw.reason : null;
|
|
752
821
|
if (desc) fs.writeFileSync(pageFile, desc.text);
|
|
753
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];
|
|
754
825
|
const err = new Error(`${where}, action ${j + 1} (${kind}: ${JSON.stringify(value)}) failed: ` +
|
|
755
|
-
`${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}` +
|
|
756
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)}` : '') +
|
|
757
|
-
(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}`) : ''));
|
|
758
831
|
err.screenshot = shot;
|
|
759
832
|
err.pageDescription = desc?.text;
|
|
760
833
|
throw err;
|
|
@@ -777,8 +850,11 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
777
850
|
await runAll(cfg.setup, 'setup');
|
|
778
851
|
for (const [card, file] of [[cfg.endCard, 'logo.png'], [cfg.intro, 'intro-logo.png']]) {
|
|
779
852
|
if (!card?.logo || card.logoFile) continue;
|
|
780
|
-
await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
|
|
781
|
-
.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
|
+
});
|
|
782
858
|
}
|
|
783
859
|
// where the intro's logo sits on the first page: a HyperFrames launch intro lands its logo there
|
|
784
860
|
const introLogo = cfg.intro?.logo && !cfg.intro.logoFile
|
|
@@ -818,8 +894,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
818
894
|
|
|
819
895
|
if (fast) {
|
|
820
896
|
const n = Math.min(inspectAfter, cfg.steps.length);
|
|
897
|
+
let before = null;
|
|
821
898
|
for (const [i, s] of cfg.steps.slice(0, n).entries()) {
|
|
822
899
|
stepHighlight = false;
|
|
900
|
+
if (i === n - 1) before = await clickableKeys(page);
|
|
823
901
|
// like the recording: marks, notes and spotlights end with their step (the last step's stay in the screenshot)
|
|
824
902
|
if (i > 0) await page.evaluate(() => { window.__demoUnmark(true); window.__demoUnnote(true); window.__demoSpotOff(true); }).catch(() => {});
|
|
825
903
|
await runAll(s.actions, `step ${i + 1}`);
|
|
@@ -828,9 +906,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
828
906
|
await sleep(800);
|
|
829
907
|
const shot = path.join(cfg.work, `inspect-after-${n}.jpg`);
|
|
830
908
|
await page.screenshot({ path: shot, quality: 80, scale: 'css' });
|
|
831
|
-
const desc = await describePage(page, cfg.viewport);
|
|
909
|
+
const desc = await describePage(page, cfg.viewport, { before });
|
|
832
910
|
await browser.close();
|
|
833
|
-
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 };
|
|
834
912
|
}
|
|
835
913
|
|
|
836
914
|
// --- recording ----------------------------------------------------------------------
|
package/src/voice.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { isZhVoice, synthZh, zhSpeakText, ZH_DEFAULT_VOICE, ZH_TTS_ID } from './kokoro-zh.mjs';
|
|
10
|
-
import { qwenSpeakText, QWEN_TTS_ID, synthQwen } from './qwen-tts.mjs';
|
|
10
|
+
import { qwenLang, qwenSpeakText, QWEN_TTS_ID, synthQwen } from './qwen-tts.mjs';
|
|
11
11
|
|
|
12
12
|
const SR = 48000;
|
|
13
13
|
export const VOICE_DEFAULTS = {
|
|
@@ -112,10 +112,10 @@ function cached(cfg, line) {
|
|
|
112
112
|
const zh = v.provider === 'kokoro' && isZhVoice(v.voice), qwen = v.provider === 'qwen';
|
|
113
113
|
const text = zh ? zhSpeakText(pronounce(v, line)) : qwen ? qwenSpeakText(pronounce(v, line)) : pronounce(v, line);
|
|
114
114
|
const ext = { kokoro: 'wav', qwen: 'wav', say: 'aiff' }[v.provider] ?? 'mp3';
|
|
115
|
-
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : qwen ? [v.provider, v.voice, QWEN_TTS_ID, v.speed, text]
|
|
115
|
+
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : qwen ? [v.provider, v.voice, QWEN_TTS_ID, v.speed, text, ...(qwenLang(text, v.lang) !== qwenLang(text) ? [qwenLang(text, v.lang)] : [])]
|
|
116
116
|
: [v.provider, v.voice, v.model, v.speed, v.instructions, text];
|
|
117
117
|
const hash = crypto.createHash('sha1').update(JSON.stringify(key)).digest('hex').slice(0, 16);
|
|
118
|
-
return { text, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
118
|
+
return { text, written: line, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// A line's sentences, so each is spoken on its own with a pause after it (TTS engines run the
|
|
@@ -142,10 +142,10 @@ export function sentences(text) {
|
|
|
142
142
|
// One line as stereo Float32 PCM at 48 kHz, leading and trailing silence trimmed.
|
|
143
143
|
async function speakLine(cfg, line, log) {
|
|
144
144
|
const v = cfg.voice;
|
|
145
|
-
const { text, file, zh, qwen } = cached(cfg, line);
|
|
145
|
+
const { text, written, file, zh, qwen } = cached(cfg, line);
|
|
146
146
|
if (!fs.existsSync(file)) {
|
|
147
147
|
if (zh) await synthZh(v, [{ text, file }], { log });
|
|
148
|
-
else if (qwen) await synthQwen(v, [{ text, file }], { log });
|
|
148
|
+
else if (qwen) await synthQwen(v, [{ text, written, file }], { log });
|
|
149
149
|
else {
|
|
150
150
|
log(` tts (${v.provider}): "${text}"`);
|
|
151
151
|
await synth(v, text, file);
|