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/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' (m x and mx as '#m', '#x')."""
76
- t = text.lower().replace('−', '-')
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'[㐀-鿿]|[a-z]+|\d+', t):
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 is half a mistake
108
- cost = 0 if x == y else 0.5 if letter(x) or letter(y) else 1
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
- if asr is None or not (chinese or (line['lang'] == 'auto' and not re.search(r'[㐀-鿿]', line['text']))):
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 'English').text.strip()
164
+ heard = asr.generate(tmp, language='Chinese' if chinese else latin).text.strip()
153
165
  finally:
154
166
  os.remove(tmp)
155
- return heard, mismatch(line['text'], heard, chinese, pinyin)
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
- window.__demoKeys = (label) => {
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: '50%', bottom: '22%', transform: 'translateX(-50%)', zIndex: '2147483646', pointerEvents: 'none',
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
- el.animate([{ opacity: 0, transform: 'translateX(-50%) scale(.9)' }, { opacity: 1, transform: 'translateX(-50%) scale(1)', offset: 0.15 },
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
- const rects = [...r.getClientRects()].filter((q) => q.width > 1 && q.height > 1);
172
- if (rects.length) ranges.push(r);
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
- const inView = (r) => { const q = r.getBoundingClientRect(); return q.top >= 0 && q.bottom <= innerHeight; };
177
- const r = ranges.find(inView) ?? ranges[0];
178
- if (!r) return null;
179
- window.__demoFound = r;
180
- return { inView: inView(r), count: ranges.length };
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.filter((q) => q.width > 1 && q.height > 1)) {
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().forEach((q, i) => {
215
- const el = els[i]; if (!el) return;
216
- const pad = box ? 6 : 2;
217
- Object.assign(el.style, { 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` });
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 u = 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 });
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
- const b = await l.boundingBox();
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 page.evaluate((l) => window.__demoKeys(l), process.platform === 'darwin' ? '⌘ V' : 'Ctrl V').catch(() => {});
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) log(` ⚠ ${acting}: slide stopped at ${got}, not ${value.value} — the slider snaps to its step`);
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 per second, at least a second
700
- const read = fast ? 0 : value.ms ?? Math.max(1000, String(value.text).split(/\s+/).length * 330);
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 page.evaluate((l) => window.__demoKeys(l), keyLabel(key)).catch(() => {});
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}\n page: ${page.url()}\n screenshot of the moment it failed: ${shot}` +
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((e) => log(`warning: could not capture logo (${e.message.split('\n')[0]})`));
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);