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.
@@ -19,7 +19,6 @@ import { fileURLToPath } from 'node:url';
19
19
 
20
20
  export const HF_VERSION = '0.8.217';
21
21
  const HERE = path.dirname(fileURLToPath(import.meta.url));
22
- const GSAP_CDN = 'https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js';
23
22
  // families HyperFrames resolves by name; any other needs an @font-face
24
23
  const BUNDLED = ['Inter', 'Montserrat', 'Outfit', 'Nunito', 'Oswald', 'League Gothic', 'Archivo Black', 'Space Mono', 'IBM Plex Mono', 'JetBrains Mono',
25
24
  'EB Garamond', 'Playfair Display', 'Source Code Pro', 'Noto Sans JP', 'Roboto', 'Open Sans', 'Lato', 'Poppins'];
@@ -112,13 +111,24 @@ export function cueTimes(items, say, start, len) {
112
111
 
113
112
  // --- shared bits -----------------------------------------------------------------------------------
114
113
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
115
- // "**word** rest" -> one span per word (per character in CJK), highlighted words inside a .hl span
116
- const words = (text, { cls = 'w', underline = '' } = {}) => String(text ?? '').split(/(\*\*.+?\*\*)/).filter(Boolean).map((seg) => {
117
- const hl = /^\*\*.+\*\*$/.test(seg);
118
- const inner = seg.replace(/^\*\*|\*\*$/g, '').split(/(\s+)/).filter(Boolean).map((w) => (/^\s+$/.test(w) ? ' '
119
- : CJK.test(w) ? [...w].map((ch) => `<span class="${cls}">${esc(ch)}</span>`).join('') : `<span class="${cls}">${esc(w)}</span>`)).join('');
120
- return hl ? `<span class="hl">${inner}${underline}</span>` : inner;
121
- }).join('');
114
+ // "**word** rest" -> one span per word (per character in CJK), highlighted words inside a .hl span.
115
+ // Punctuation stays with its neighbour, so a wrapped line never starts with "," or ends with "(".
116
+ const CLOSE = ',。、;:!?)」』》〉】”’,.;:!?)…', OPEN = '(「『《〈【“‘(';
117
+ const words = (text, { cls = 'w', underline = '' } = {}) => {
118
+ const span = (t) => `<span class="${cls}">${esc(t)}</span>`;
119
+ const chars = (w) => [...w].reduce((out, ch) => (out.length && (CLOSE.includes(ch) || OPEN.includes(out.at(-1).at(-1))) ? [...out.slice(0, -1), out.at(-1) + ch] : [...out, ch]), []);
120
+ const parts = [];
121
+ for (const seg of String(text ?? '').split(/(\*\*.+?\*\*)/).filter(Boolean)) {
122
+ const hl = /^\*\*.+\*\*$/.test(seg);
123
+ let body = seg.replace(/^\*\*|\*\*$/g, ''), lead = '';
124
+ while (!hl && parts.at(-1)?.hl && CLOSE.includes(body[lead.length] ?? '\0')) lead += body[lead.length];
125
+ body = body.slice(lead.length);
126
+ if (lead) parts.at(-1).html = `<span style="white-space:nowrap">${parts.at(-1).html}${span(lead)}</span>`; // "**实时预览**,"
127
+ const inner = body.split(/(\s+)/).filter(Boolean).map((w) => (/^\s+$/.test(w) ? ' ' : CJK.test(w) ? chars(w).map(span).join('') : span(w))).join('');
128
+ parts.push({ hl, html: hl ? `<span class="hl">${inner}${underline}</span>` : inner });
129
+ }
130
+ return parts.map((x) => x.html).join('');
131
+ };
122
132
  const md = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`(.+?)`/g, '<code>$1</code>');
123
133
  const isCmd = (cta) => /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(String(cta ?? ''));
124
134
  const lum = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return 0.5; const n = parseInt(m[1], 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
@@ -148,8 +158,82 @@ const fontFaces = (font, { hand = false } = {}) => [
148
158
  hand ? `@font-face{font-family:'Virgil';src:url('assets/Virgil.woff2') format('woff2')}` : '',
149
159
  ].join('\n');
150
160
 
151
- const doc = ({ W, H, D, css, body, js, bg = 'transparent', gsap }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
152
- <script src="${gsap}"></script>
161
+ // The cards animate with the browser's own Web Animations API, which HyperFrames seeks frame by frame.
162
+ // The templates describe their motion with GSAP-style calls (tl.fromTo / tl.to with duration, ease,
163
+ // stagger); this small timeline collects them, then gives each element one animation per property that
164
+ // spans the whole card, with keyframes at each tween's start and end, so tweens on the same element play
165
+ // in time order however they were written. It runs in the card's page.
166
+ function pageTimeline(D) {
167
+ const EASES = { none: 'linear', 'power1.out': 'cubic-bezier(0.5,1,0.89,1)', 'power1.inOut': 'cubic-bezier(0.45,0,0.55,1)',
168
+ 'power2.in': 'cubic-bezier(0.32,0,0.67,0)', 'power2.out': 'cubic-bezier(0.33,1,0.68,1)', 'power2.inOut': 'cubic-bezier(0.65,0,0.35,1)',
169
+ 'power3.inOut': 'cubic-bezier(0.76,0,0.24,1)', 'power4.out': 'cubic-bezier(0.22,1,0.36,1)', 'expo.out': 'cubic-bezier(0.16,1,0.3,1)' };
170
+ const easing = (e = 'power1.out') => {
171
+ if (EASES[e]) return EASES[e];
172
+ if (/^back\.out/.test(e)) return 'cubic-bezier(0.34,1.56,0.64,1)';
173
+ const steps = /^steps\((\d+)\)$/.exec(e);
174
+ return steps ? `steps(${steps[1]}, end)` : 'linear';
175
+ };
176
+ const TF = ['x', 'y', 'scale', 'scaleX', 'scaleY'];
177
+ const setTf = (s, p) => {
178
+ for (const [k, v] of Object.entries(p ?? {})) {
179
+ if (k === 'x' || k === 'y') s[k] = v;
180
+ else if (k === 'scale') s.sx = s.sy = v;
181
+ else if (k === 'scaleX') s.sx = v;
182
+ else if (k === 'scaleY') s.sy = v;
183
+ }
184
+ return s;
185
+ };
186
+ const tfText = (s) => `translate(${s.x}px, ${s.y}px) scale(${s.sx}, ${s.sy})`;
187
+ const tweens = [];
188
+ const add = (sel, from, to, at) => {
189
+ const { duration = 0.5, ease, stagger = 0, immediateRender, ...props } = to;
190
+ [...document.querySelectorAll(sel)].forEach((el, i) => tweens.push({ el, from, to: props, t0: at + i * stagger, d: duration, ease: easing(ease), n: tweens.length }));
191
+ };
192
+ const build = () => {
193
+ const byEl = new Map();
194
+ for (const t of tweens) byEl.set(t.el, [...(byEl.get(t.el) ?? []), t]);
195
+ for (const [el, list] of byEl) {
196
+ list.sort((a, b) => a.t0 - b.t0 || a.n - b.n);
197
+ const cs = getComputedStyle(el);
198
+ const state = { tf: { x: 0, y: 0, sx: 1, sy: 1 }, opacity: Number(cs.opacity), filter: cs.filter === 'none' ? 'blur(0px)' : cs.filter,
199
+ strokeDashoffset: parseFloat(cs.strokeDashoffset) || 0, backgroundSize: cs.backgroundSize, clipPath: cs.clipPath };
200
+ const tracks = {};
201
+ const push = (ch, t, v0, v1) => (tracks[ch] ??= []).push({ t0: t.t0, t1: t.t0 + t.d, v0, v1, e: t.ease });
202
+ for (const t of list) {
203
+ const keys = [...new Set([...Object.keys(t.from ?? {}), ...Object.keys(t.to)])];
204
+ if (keys.some((k) => TF.includes(k))) {
205
+ const a = setTf({ ...state.tf }, Object.fromEntries(Object.entries(t.from ?? {}).filter(([k]) => TF.includes(k))));
206
+ const b = setTf({ ...a }, Object.fromEntries(Object.entries(t.to).filter(([k]) => TF.includes(k))));
207
+ push('transform', t, tfText(a), tfText(b));
208
+ state.tf = b;
209
+ }
210
+ for (const k of keys.filter((k) => !TF.includes(k))) {
211
+ const v0 = t.from && k in t.from ? t.from[k] : state[k], v1 = k in t.to ? t.to[k] : v0;
212
+ push(k, t, v0, v1);
213
+ state[k] = v1;
214
+ }
215
+ }
216
+ const at = (t) => Math.min(1, Math.max(0, t / D));
217
+ for (const [ch, segs] of Object.entries(tracks)) {
218
+ const val = (v) => (ch === 'strokeDashoffset' ? `${v}px` : String(v));
219
+ const kf = [{ offset: 0, [ch]: val(segs[0].v0), easing: 'linear' }];
220
+ let last = segs[0].v0;
221
+ segs.forEach((s, i) => {
222
+ const end = i + 1 < segs.length ? Math.min(s.t1, Math.max(s.t0, segs[i + 1].t0)) : s.t1; // a later tween cuts it short
223
+ kf.push({ offset: at(s.t0), [ch]: val(last), easing: 'linear' }, { offset: at(s.t0), [ch]: val(s.v0), easing: s.e }, { offset: at(end), [ch]: val(s.v1), easing: 'linear' });
224
+ last = s.v1;
225
+ });
226
+ kf.push({ offset: 1, [ch]: val(last) });
227
+ const a = el.animate(kf, { duration: D * 1000, fill: 'both' });
228
+ a.pause();
229
+ a.currentTime = 0;
230
+ }
231
+ }
232
+ };
233
+ return { fromTo: (sel, from, to, at = 0) => add(sel, from, to, at), to: (sel, to, at = 0) => add(sel, null, to, at), build };
234
+ }
235
+
236
+ const doc = ({ W, H, D, css, body, js, bg = 'transparent' }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
153
237
  <style>
154
238
  *{margin:0;padding:0;box-sizing:border-box}
155
239
  html,body{width:${W}px;height:${H}px;overflow:hidden;background:${bg}}
@@ -163,11 +247,34 @@ ${css}
163
247
  ${body}
164
248
  </div></div>
165
249
  <script>
166
- const tl = gsap.timeline({ paused: true });
250
+ const tl = (${pageTimeline})(${D.toFixed(3)});
167
251
  const blurIn = (sel, at, o = {}) => { if (document.querySelector(sel)) tl.fromTo(sel, { opacity: 0, filter: 'blur(8px)', ...o.from }, { opacity: 1, filter: 'blur(0px)', duration: o.d ?? 0.18, ease: 'power4.out', stagger: o.stagger ?? 0, ...o.to }, at); };
168
252
  const draw = (sel, at, d = 0.35, stagger = 0.12) => { if (document.querySelector(sel)) tl.fromTo(sel, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: d, ease: 'power1.inOut', stagger }, at); };
253
+ // text that would run out of the frame shrinks until it fits: w px wide and at most that many lines (0: a
254
+ // row that doesn't wrap, only its width counts); then what's in [data-fit-box] shrinks together until it
255
+ // fits top to bottom. Layout sizes only (offset*, scroll*): the animations' transforms don't count.
256
+ const fits = [];
257
+ const fit = (sel, w, lines = 2) => document.querySelectorAll(sel).forEach((el) => fits.push({ el, w, lines, size: parseFloat(getComputedStyle(el).fontSize) }));
258
+ const over = ({ el, w, lines }) => {
259
+ const cs = getComputedStyle(el), lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
260
+ // (a row that doesn't wrap overflows its own box: its scrollWidth, not its parent's, says so)
261
+ return Math.max(el.scrollWidth, el.offsetWidth, ...[...el.children].map((c) => c.scrollWidth)) > w + 1 || (lines > 0 && el.offsetHeight > lh * lines + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + 4);
262
+ };
263
+ const shrink = (f) => { f.el.style.fontSize = parseFloat(f.el.style.fontSize) * 0.95 + 'px'; };
264
+ const refit = () => {
265
+ for (const f of fits) { f.el.style.fontSize = f.size + 'px'; f.el.style.maxWidth = f.w + 'px'; }
266
+ for (const f of fits) for (let k = 0; k < 40 && over(f); k++) shrink(f);
267
+ const box = document.querySelector('[data-fit-box]');
268
+ for (let k = 0; box && fits.length && k < 30; k++) {
269
+ const kids = [...box.querySelectorAll(':scope > *, :scope > .right > *')].filter((c) => c.offsetHeight && !c.classList.contains('right'));
270
+ if (!kids.length || (Math.min(...kids.map((c) => c.offsetTop)) >= ${H * 0.03} && Math.max(...kids.map((c) => c.offsetTop + c.offsetHeight)) <= ${H * 0.97})) break;
271
+ fits.forEach(shrink);
272
+ }
273
+ };
169
274
  ${js}
170
- window.__timelines["main"] = tl;
275
+ refit();
276
+ document.fonts?.ready.then(refit);
277
+ tl.build();
171
278
  </script></body></html>`;
172
279
 
173
280
  // --- lesson: tutorials, courses, docs ---------------------------------------------------------------
@@ -228,7 +335,7 @@ function lesson(job, ctx) {
228
335
  .kick{font:600 ${30 * u}px 'IBM Plex Mono','StoryshotCJK',monospace;letter-spacing:.24em;text-transform:uppercase;color:${P.muted};margin-bottom:${22 * u}px}
229
336
  h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}px;text-wrap:balance;font-size:${titleSize}px}
230
337
  .hl{position:relative;${dark ? `color:${accent}` : `background-image:linear-gradient(${accent},${accent});background-repeat:no-repeat;background-position:0 88%;background-size:0% 36%;padding:0 .06em`}}
231
- .sub{font-size:${44 * u}px;color:${P.muted};margin-top:${22 * u}px;letter-spacing:-.01em;max-width:${W * 0.8}px}
338
+ .sub{font-size:${44 * u}px;line-height:1.3;text-wrap:balance;color:${P.muted};margin-top:${22 * u}px;letter-spacing:-.01em;max-width:${W * 0.8}px}
232
339
  .logo{height:${H * 0.09}px;margin-bottom:${36 * u}px;${dark ? `background:#fff;padding:${10 * u}px ${16 * u}px;border-radius:${14 * u}px` : ''}}
233
340
  .cimg{max-width:${W * 0.6}px;max-height:${H * 0.4}px;margin-top:${34 * u}px;border-radius:${12 * u}px;box-shadow:0 ${12 * u}px ${36 * u}px ${P.shadow}}
234
341
  .split{flex-direction:row;gap:${72 * u}px;text-align:left}
@@ -253,7 +360,7 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
253
360
  .tx{font-size:${31 * u}px;line-height:1.24;font-weight:500}
254
361
  .tx b{color:${dark ? accent : P.text}}
255
362
  .tx code,.li code{font:500 .9em 'IBM Plex Mono',monospace}
256
- .cta{margin-top:${50 * u}px;font-size:${38 * u}px;font-weight:600;padding:${20 * u}px ${44 * u}px;border-radius:${16 * u}px;background:${dark ? accent : ink};color:${dark ? ink : '#fff'}}
363
+ .cta{margin-top:${50 * u}px;font-size:${38 * u}px;line-height:1.3;font-weight:600;padding:${20 * u}px ${44 * u}px;border-radius:${16 * u}px;background:${dark ? accent : ink};color:${dark ? ink : '#fff'}}
257
364
  .cta.cmd{font:500 ${36 * u}px 'IBM Plex Mono',monospace;background:${dark ? '#1a2238' : ink};color:#e2e8f0;text-align:left}
258
365
  .cta.cmd b{color:${accent}}
259
366
  #type{display:inline-block;white-space:nowrap}
@@ -265,7 +372,7 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
265
372
  const body = `<div class="full bg"></div>
266
373
  ${job.backdrop ? `<img id="backdrop" class="full backdrop" src="assets/backdrop.jpg">` : ''}
267
374
  ${bandText ? band('bandA', 96 * u, bandText) + band('bandB', H - 176 * u, [...bandText].reverse().join('')) : ''}
268
- <div class="col${split ? ' split' : ''}">
375
+ <div class="col${split ? ' split' : ''}" data-fit-box>
269
376
  ${split ? `<div class="left">${image}</div><div class="right">` : ''}
270
377
  ${logo}${emblem}
271
378
  ${kicker ? `<div id="kick" class="kick">${esc(kicker)}</div>` : ''}
@@ -284,6 +391,11 @@ ${think}`;
284
391
 
285
392
  const n = (x) => x.toFixed(2);
286
393
  const js = `
394
+ fit('#title', ${split ? W * 0.44 : W * 0.82}, 3);
395
+ fit('#sub', ${split ? W * 0.44 : W * 0.8}, 3);
396
+ fit('#aln', ${split ? W * 0.44 : W * 0.88}, 0);
397
+ fit('.steps', ${W * 0.88}, 0);
398
+ fit('#cta', ${split ? W * 0.44 : W * 0.8}, ${isCmd(c.cta) ? 0 : 2});
287
399
  ${bandText ? `tl.fromTo('#bandA', { x: 0 }, { x: ${-92 * D * u}, duration: ${D}, ease: 'none' }, 0);
288
400
  tl.fromTo('#bandB', { x: ${-900 * u} }, { x: ${-900 * u + 92 * D * u}, duration: ${D}, ease: 'none' }, 0);` : ''}
289
401
  ${job.backdrop ? `tl.fromTo('#backdrop', { x: 0 }, { x: ${-14 * D * u}, duration: ${D}, ease: 'none' }, 0);` : ''}
@@ -315,7 +427,7 @@ ${isCmd(c.cta) ? `tl.fromTo('#type', { clipPath: 'inset(0 100% 0 0)' }, { clipPa
315
427
  ${think ? `blurIn('#think', ${n(job.thinkAt)});
316
428
  tl.fromTo('#drain', { scaleX: 1 }, { scaleX: 0, duration: ${c.think}, ease: 'none' }, ${n(job.thinkAt)});` : ''}
317
429
  `;
318
- return doc({ W, H, D, css, body, js, bg: P.ground, gsap: ctx.gsap });
430
+ return doc({ W, H, D, css, body, js, bg: P.ground });
319
431
  }
320
432
 
321
433
  // --- launch: promos ----------------------------------------------------------------------------------
@@ -334,7 +446,7 @@ async function launch(job, ctx) {
334
446
  #stage{font-family:'${font}','StoryshotCJK',sans-serif;color:${INK}}
335
447
  .paper{background:#fff}
336
448
  .grid{background-image:radial-gradient(#d4d4dc ${1.6 * u}px,transparent ${1.8 * u}px);background-size:${36 * u}px ${36 * u}px;background-position:${18 * u}px ${18 * u}px}
337
- .line{position:absolute;left:0;right:0;text-align:center;white-space:nowrap}
449
+ .line{position:absolute;left:0;right:0;margin:0 auto;text-align:center;line-height:1.3;text-wrap:balance}
338
450
  .hl{position:relative;display:inline-block;color:${STROKE}}
339
451
  .ul{position:absolute;left:-4%;width:108%;bottom:-0.42em;height:0.5em;overflow:visible}
340
452
  .ulp{stroke-width:${6 * u}px}
@@ -346,8 +458,10 @@ async function launch(job, ctx) {
346
458
  const T = job.logoTarget; // where the app shows the same logo in its first frame, if it does
347
459
  const L = job.logoSize; // the logo picture's size in output px
348
460
  const hasLogo = !!(job.logo && L);
349
- const big = hasLogo ? Math.min((W * 0.42) / L.w, (H * 0.17) / L.h, 3.2) : 1;
350
- const cy = H * (c.subtitle ? 0.41 : 0.47);
461
+ // a logo and a title: the boxed logo, the title under it (leave out title when the logo spells the name)
462
+ const named = hasLogo && !!c.title, nameSize = (String(c.title ?? '').length > 24 ? 64 : 84) * u;
463
+ const big = hasLogo ? Math.min((W * 0.42) / L.w, (H * (named ? 0.15 : 0.17)) / L.h, 3.2) : 1;
464
+ const cy = H * (named ? (c.subtitle ? 0.355 : 0.4) : c.subtitle ? 0.41 : 0.47);
351
465
  const bw = hasLogo ? L.w * big : 0, bh = hasLogo ? L.h * big : 0, bx = (W - bw) / 2, by = cy - bh / 2;
352
466
  const exitAt = Math.max(1.4, D - 1.0), shrink = 0.68;
353
467
  const box = hasLogo ? strokes(g.rectangle(bx - 70 * u, by - 56 * u, bw + 140 * u, bh + 112 * u, R({ seed: 7, strokeWidth: 3.5 * u })), 'bx') : '';
@@ -357,8 +471,9 @@ async function launch(job, ctx) {
357
471
  #logo{position:absolute;left:${logoBox.x}px;top:${logoBox.y}px;width:${T ? T.w : bw}px;height:${T ? T.h : bh}px;transform-origin:0 0}
358
472
  #pageimg{object-fit:fill;opacity:0}
359
473
  .tbox{position:absolute;left:0;right:0;top:${cy - 80 * u}px;display:flex;justify-content:center}
360
- .tbox span.t{position:relative;display:inline-block;padding:${24 * u}px ${60 * u}px;font-size:${(String(c.title ?? '').length > 18 ? 96 : 128) * u}px}
361
- .tbox svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}`;
474
+ .tbox span.t{position:relative;display:inline-block;line-height:1.2;white-space:nowrap;padding:${24 * u}px ${60 * u}px;font-size:${(String(c.title ?? '').length > 18 ? 96 : 128) * u}px}
475
+ .tbox svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
476
+ #name{font-size:${nameSize}px}`;
362
477
  const tbox = !hasLogo && c.title ? `<div class="tbox" id="tbox"><span class="t title">${words(c.title)}<svg viewBox="0 0 1000 300" preserveAspectRatio="none">${strokes(g.rectangle(6, 8, 988, 284, R({ seed: 7 })), 'bx', 'vector-effect="non-scaling-stroke"')}</svg></span></div>` : '';
363
478
  const body = `
364
479
  ${F.frameBg ? `<img class="full" src="assets/frame-bg.png">` : `<div class="full" style="background:#0b1020"></div>`}
@@ -366,25 +481,30 @@ ${F.chrome ? `<img id="chrome" class="full" src="assets/frame-chrome.png" style=
366
481
  ${F.url ? `<img id="url" class="full" src="assets/frame-url.png" style="opacity:0">` : ''}
367
482
  <div id="paper" class="full paper"><div id="grid" class="full grid"></div>${job.page ? `<img id="pageimg" class="full" src="assets/page.jpg">` : ''}</div>
368
483
  ${hasLogo ? `<svg id="box" class="full" viewBox="0 0 ${W} ${H}">${box}</svg><img id="logo" src="assets/logo${path.extname(job.logo)}">` : tbox}
369
- ${c.subtitle ? `<div id="sub" class="line" style="top:${(hasLogo ? by + bh + 120 * u : cy + 190 * u)}px;font-family:${handFont};font-size:${(opts.hand ? 78 : 64) * u}px">${words(c.subtitle, { underline: underline(11) })}</div>` : ''}`;
484
+ ${named ? `<div id="name" class="line title" style="top:${by + bh + 92 * u}px">${words(c.title)}</div>` : ''}
485
+ ${c.subtitle ? `<div id="sub" class="line" style="top:${(named ? by + bh + 92 * u + nameSize * 1.45 : hasLogo ? by + bh + 120 * u : cy + 190 * u)}px;font-family:${handFont};font-size:${(opts.hand ? 78 : 64) * u}px">${words(c.subtitle, { underline: underline(11) })}</div>` : ''}`;
370
486
  const sx = F.FW / W, sy = F.FH / H, framed = F.FX > 0 || F.FY > 0;
371
487
  const js = `
488
+ fit('#tbox .t', ${W * 0.86}, 0);
489
+ fit('#name', ${W * 0.86}, 1);
490
+ fit('#sub', ${W * 0.84}, 2);
372
491
  ${hasLogo ? `draw('.bx', 0.0, 0.5, 0.18);
373
492
  tl.fromTo('#logo', { x: ${n(bx - logoBox.x)}, y: ${n(by - logoBox.y)}, scale: ${n(T ? (bw / T.w) * 0.92 : 0.92)}, opacity: 0, filter: 'blur(8px)' },
374
- { x: ${n(bx - logoBox.x)}, y: ${n(by - logoBox.y)}, scale: ${n(T ? bw / T.w : 1)}, opacity: 1, filter: 'blur(0px)', duration: 0.45, ease: 'back.out(1.5)' }, 0.12);`
493
+ { x: ${n(bx - logoBox.x)}, y: ${n(by - logoBox.y)}, scale: ${n(T ? bw / T.w : 1)}, opacity: 1, filter: 'blur(0px)', duration: 0.45, ease: 'back.out(1.5)' }, 0.12);
494
+ ${named ? "blurIn('#name .w', 0.35, { d: 0.16, stagger: 0.07 });" : ''}`
375
495
  : `draw('.bx', 0.0, 0.5, 0.18);
376
496
  blurIn('#tbox .w', 0.12, { d: 0.16, stagger: 0.07 });`}
377
497
  blurIn('#sub .w', 0.5, { d: 0.16, stagger: 0.07 });
378
498
  draw('#sub .ulp', 1.0, 0.4, 0);
379
499
  // the paper turns into the app window; the logo lands where the app shows its own
380
- tl.to('#box, #sub, #grid, #tbox', { opacity: 0, filter: 'blur(6px)', duration: 0.22, ease: 'power2.in' }, ${n(exitAt)});
500
+ tl.to('#box, #sub, #grid, #tbox${named ? ', #name' : ''}', { opacity: 0, filter: 'blur(6px)', duration: 0.22, ease: 'power2.in' }, ${n(exitAt)});
381
501
  ${framed ? `tl.to('#paper', { x: ${F.FX}, y: ${F.FY}, scaleX: ${sx.toFixed(5)}, scaleY: ${sy.toFixed(5)}, duration: ${shrink}, ease: 'power3.inOut' }, ${n(exitAt + 0.08)});` : ''}
382
502
  ${job.page ? `tl.fromTo('#pageimg', { opacity: 0 }, { opacity: 1, duration: 0.4, ease: 'power2.inOut' }, ${n(exitAt + 0.25)});` : ''}
383
503
  ${hasLogo ? (T ? `tl.to('#logo', { x: 0, y: 0, scale: 1, duration: ${shrink}, ease: 'power3.inOut' }, ${n(exitAt + 0.08)});`
384
504
  : `tl.to('#logo', { opacity: 0, filter: 'blur(6px)', duration: 0.22, ease: 'power2.in' }, ${n(exitAt)});`) : ''}
385
505
  ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.out' }, ${n(exitAt + 0.35)});` : ''}
386
506
  `;
387
- return doc({ W, H, D, css, body, js, bg: '#fff', gsap: ctx.gsap });
507
+ return doc({ W, H, D, css, body, js, bg: '#fff' });
388
508
  }
389
509
 
390
510
  // the end card is drawn over the last frame, which storyshot zooms into the page underneath: the
@@ -401,12 +521,12 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
401
521
  const css = `${css0}
402
522
  .stack{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:${30 * u}px}
403
523
  #logo{height:${lh}px;width:${lw}px}
404
- .etitle{font-size:${(String(c.title ?? '').length > 24 ? 80 : 104) * u}px}
405
- .esub{font-family:${handFont};font-size:${(opts.hand ? 66 : 54) * u}px}
524
+ .etitle{font-size:${(String(c.title ?? '').length > 24 ? 80 : 104) * u}px;line-height:1.2;text-wrap:balance}
525
+ .esub{font-family:${handFont};font-size:${(opts.hand ? 66 : 54) * u}px;line-height:1.3;text-wrap:balance}
406
526
  .blist{display:flex;flex-direction:column;gap:${12 * u}px;font-size:${36 * u}px;text-align:left}
407
527
  .blist .bi{display:flex;align-items:center;gap:${16 * u}px}
408
528
  .blist svg{width:${40 * u}px;height:${40 * u}px;overflow:visible}
409
- .cta{position:relative;margin-top:${26 * u}px;padding:${22 * u}px ${54 * u}px;font-family:${cmd ? "'IBM Plex Mono',monospace" : handFont};font-size:${(cmd ? 46 : opts.hand ? 84 : 64) * u}px;color:${STROKE};font-weight:${cmd ? 500 : 600}}
529
+ .cta{position:relative;line-height:1.2;margin-top:${26 * u}px;padding:${22 * u}px ${54 * u}px;font-family:${cmd ? "'IBM Plex Mono',monospace" : handFont};font-size:${(cmd ? 46 : opts.hand ? 84 : 64) * u}px;color:${STROKE};font-weight:${cmd ? 500 : 600}}
410
530
  .cta .box{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
411
531
  .cta .arrow{position:absolute;left:${-arrowW - 16 * u}px;top:calc(50% - ${24 * u}px);width:${arrowW}px;height:${arrowH}px;overflow:visible}
412
532
  .cta .try{position:absolute;right:calc(100% + ${arrowW - 10 * u}px);top:calc(50% + ${arrowH - 30 * u}px);font-family:'Virgil','StoryshotCJK',sans-serif;font-size:${46 * u}px;color:${INK};white-space:nowrap;transform:rotate(-7deg)}
@@ -414,7 +534,7 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
414
534
  const check = (i) => strokes(g.linearPath([[4, 22], [15, 34], [36, 6]], R({ seed: 30 + i, stroke: STROKE, strokeWidth: 4 * u })), `ck${i}`);
415
535
  const body = `
416
536
  <div id="paper" class="full paper" style="opacity:0"></div>
417
- <div class="stack">
537
+ <div class="stack" data-fit-box>
418
538
  ${hasLogo ? `<img id="logo" src="assets/logo${path.extname(job.logo)}">` : ''}
419
539
  ${c.title ? `<div id="etitle" class="etitle title">${words(c.title)}</div>` : ''}
420
540
  ${c.subtitle ? `<div id="esub" class="esub">${words(c.subtitle, { underline: underline(13) })}</div>` : ''}
@@ -424,7 +544,11 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
424
544
  <svg class="arrow" viewBox="0 0 280 160" preserveAspectRatio="none">${strokes(g.curve([[18, 150], [60, 72], [150, 30], [270, 24]], R({ seed: 5, strokeWidth: 3.5 })), 'ar', 'vector-effect="non-scaling-stroke"')}${strokes(g.linearPath([[238, 0], [272, 24], [240, 50]], R({ seed: 6, strokeWidth: 3.5 })), 'ah', 'vector-effect="non-scaling-stroke"')}</svg>
425
545
  <div id="try" class="try">${esc(tryLabel)}</div></div>` : ''}
426
546
  </div>`;
547
+ // the call to action keeps room on its left for the hand-drawn arrow and its "try it"
427
548
  const js = `
549
+ fit('#etitle', ${W * 0.84}, 2);
550
+ fit('#esub', ${W * 0.8}, 2);
551
+ fit('#cta', ${W - 2 * (arrowW + 240 * u)}, ${cmd ? 0 : 2});
428
552
  tl.fromTo('#paper', { opacity: 0 }, { opacity: 0.88, duration: 0.45, ease: 'power2.out' }, 0.3);
429
553
  ${hasLogo ? `tl.fromTo('#logo', { opacity: 0, y: ${14 * u}, filter: 'blur(8px)' }, { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.4, ease: 'expo.out' }, 0.5);` : ''}
430
554
  blurIn('#etitle .w', 0.5, { d: 0.16, stagger: 0.07 });
@@ -439,7 +563,7 @@ draw('.ar', ${n(ctaAt + 0.55)}, 0.4, 0.1);
439
563
  draw('.ah', ${n(ctaAt + 0.9)}, 0.2, 0.05);
440
564
  blurIn('#try', ${n(ctaAt + 0.85)}, { d: 0.25 });` : ''}
441
565
  `;
442
- return doc({ W, H, D, css, body, js, bg: 'transparent', gsap: ctx.gsap });
566
+ return doc({ W, H, D, css, body, js, bg: 'transparent' });
443
567
  }
444
568
 
445
569
  // --- rendering ---------------------------------------------------------------------------------------
@@ -457,8 +581,6 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
457
581
  if (!jobs.length) return {};
458
582
  let cli = hyperframesCli();
459
583
  if (cli.error) { warn(`hyperframes: ${cli.error} — using the built-in cards`); return {}; }
460
- let gsapFile = null;
461
- try { gsapFile = createRequire(import.meta.url).resolve('gsap/dist/gsap.min.js'); } catch { /* the CDN copy */ }
462
584
  const template = ctx.opts.template;
463
585
  const root = path.join(ctx.work, 'hf');
464
586
  fs.mkdirSync(root, { recursive: true });
@@ -470,10 +592,8 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
470
592
  try { await import('roughjs'); } catch { warn('hyperframes: the launch template needs the roughjs package (npm i roughjs) — using the built-in cards'); return {}; }
471
593
  }
472
594
  const alpha = launchCard && job.kind === 'end';
473
- const html = launchCard ? await launch(job, { ...ctx, gsap: gsapFile ? 'assets/gsap.min.js' : GSAP_CDN })
474
- : lesson(job, { ...ctx, gsap: gsapFile ? 'assets/gsap.min.js' : GSAP_CDN });
595
+ const html = launchCard ? await launch(job, ctx) : lesson(job, ctx);
475
596
  const assets = {
476
- ...(gsapFile ? { 'gsap.min.js': gsapFile } : {}),
477
597
  ...(launchCard ? { 'Virgil.woff2': path.join(HERE, 'fonts', 'Virgil.woff2') } : {}),
478
598
  ...(job.backdrop ? { 'backdrop.jpg': job.backdrop } : {}),
479
599
  ...(job.page ? { 'page.jpg': job.page } : {}),
package/src/init.mjs CHANGED
@@ -7,11 +7,12 @@ import { PURPOSES, defaultStoryboard } from './config.mjs';
7
7
  import { loginAdvice } from './auth.mjs';
8
8
 
9
9
  const q = (s) => JSON.stringify(String(s));
10
- // The product's name from the page title: its first part ("Squoosh | Image compressor" → "Squoosh"),
10
+ // The product's name from the page title: its first part ("Squoosh | Image compressor" → "Squoosh",
11
+ // "Hoppscotch • Open source API development ecosystem • Hoppscotch" → "Hoppscotch"),
11
12
  // or the repo on a code host ("GitHub - boheling/storyshot: Ask…" → "storyshot"). Only spaced
12
13
  // separators split, so a hyphenated name (Tic-Tac-Toe) stays whole.
13
14
  export function productName(title, host) {
14
- const segs = String(title ?? '').split(/\s+[|—–·-]\s+|:\s+/).map((s) => s.trim()).filter(Boolean);
15
+ const segs = String(title ?? '').split(/\s+[||—–·•∙‧-]\s+|:\s+/).map((s) => s.trim()).filter(Boolean);
15
16
  const repo = segs.find((s) => /^[\w.-]+ ?\/ ?[\w.-]+$/.test(s));
16
17
  return repo ? repo.split('/')[1].trim() : segs[0] ?? host;
17
18
  }
package/src/inspect.mjs CHANGED
@@ -39,7 +39,19 @@ function collect() {
39
39
  const pos = (el) => {
40
40
  const r = el.getBoundingClientRect();
41
41
  return { x: Math.round(r.x + scrollX), y: Math.round(r.y + scrollTop), w: Math.round(r.width), h: Math.round(r.height),
42
- cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2), css: cssOf(el) };
42
+ vx: Math.round(r.x), vy: Math.round(r.y), cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2), css: cssOf(el) };
43
+ };
44
+ // what to list first when there are more than inspect shows: an open dialog, menu or popover (0), what
45
+ // is on screen and not covered by something else (1), below the fold (2), covered (3)
46
+ const LAYER = 'dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true], [role=menu], [role=listbox], [popover], [role=tooltip]';
47
+ const rank = (el) => {
48
+ if (el.closest(LAYER)) return 0;
49
+ const r = el.getBoundingClientRect(), x = r.x + r.width / 2, y = r.y + r.height / 2;
50
+ if (y < 0 || y > innerHeight || x < 0 || x > innerWidth) return 2;
51
+ const hit = document.elementFromPoint(x, y), label = el.closest('label');
52
+ // (inside a shadow root the hit is its host; storyshot's own cursor and highlights don't count)
53
+ const clear = !hit || el.contains(hit) || hit.contains(el) || (label && hit.closest('label') === label) || el.getRootNode() !== document || hit.closest('[id^=__demo]');
54
+ return clear ? 1 : 3;
43
55
  };
44
56
  const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
45
57
  // a short CSS selector (tag + classes, or #id) — the last-resort target when names don't resolve
@@ -80,15 +92,15 @@ function collect() {
80
92
  .filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
81
93
  .map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
82
94
  range: roleOf(el) === 'slider' ? sliderRange(el) : null,
83
- options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
95
+ options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, rank: rank(shownBy(el) ?? el), ...pos(shownBy(el) ?? el) }));
84
96
  // custom clickables: outermost cursor:pointer elements that aren't native controls
85
97
  const pointer = [];
86
98
  for (const el of document.querySelectorAll('body *')) {
87
- if (pointer.length >= 40) break;
99
+ if (pointer.length >= 60) break;
88
100
  if (el.closest(NATIVE) || el.querySelector(NATIVE) || !vis(el) || isAd(el)) continue;
89
101
  if (getComputedStyle(el).cursor !== 'pointer' || (el.parentElement && getComputedStyle(el.parentElement).cursor === 'pointer')) continue;
90
102
  const img = el.tagName === 'IMG' ? el : el.querySelector('img[alt]');
91
- pointer.push({ kind: 'pointer', alt: img?.alt ? clean(img.alt).slice(0, 80) : null, name: clean(el.innerText).slice(0, 80), ...pos(el) });
103
+ pointer.push({ kind: 'pointer', alt: img?.alt ? clean(img.alt).slice(0, 80) : null, name: clean(el.innerText).slice(0, 80), rank: rank(el), ...pos(el) });
92
104
  }
93
105
  return {
94
106
  title: document.title,
@@ -105,24 +117,38 @@ function collect() {
105
117
 
106
118
  const yaml = (t) => (typeof t === 'string' ? JSON.stringify(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`);
107
119
 
120
+ // What can be clicked right now, without verifying targets: `inspect --after N` compares the page before and
121
+ // after step N, to list what the step opened first.
122
+ const keyOf = (el) => `${el.kind}|${el.role ?? ''}|${el.name}|${el.css ?? ''}`;
123
+ export async function clickableKeys(page) {
124
+ const info = await page.evaluate(collect).catch(() => null);
125
+ return info && new Set(info.interactive.map(keyOf));
126
+ }
127
+
108
128
  // Describe the page as it is right now. Shared by `inspect` and failure diagnostics.
109
- export async function describePage(page, viewport, { maxElements = 80 } = {}) {
129
+ // before: clickableKeys() of an earlier state; what is new since then is listed first and marked.
130
+ export async function describePage(page, viewport, { maxElements = 150, before = null } = {}) {
110
131
  const info = await page.evaluate(collect);
111
- const seen = new Set(), elements = [];
132
+ const seen = new Set(), targets = new Set(), elements = [];
112
133
  const verify = async (locator, spec, el) => {
113
134
  const visible = locator.filter({ visible: true });
114
135
  const n = await visible.count().catch(() => 0);
115
136
  if (n === 1) return spec;
116
137
  if (n > 1) {
117
- // pick the match whose box is where we saw the element
118
- const xs = await visible.evaluateAll((els) => els.map((e) => { const r = e.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width)]; })).catch(() => []);
119
- const k = xs.findIndex(([x, , w]) => Math.abs(x - el.x) <= 2 && Math.abs(w - el.w) <= 2);
120
- if (k >= 0) return { ...spec, nth: k };
138
+ // pick the match whose box is where we saw the element (all four sides: a column of same-named
139
+ // buttons shares x and width)
140
+ const xs = await visible.evaluateAll((els) => els.map((e) => { const r = e.getBoundingClientRect(); return [r.x, r.y, r.width, r.height].map(Math.round); })).catch(() => []);
141
+ const at = xs.map(([x, y, w, h], k) => [k, Math.abs(x - el.vx) + Math.abs(y - el.vy) + Math.abs(w - el.w) + Math.abs(h - el.h)]).filter(([, d]) => d <= 4);
142
+ if (at.length === 1) return { ...spec, nth: at[0][0] };
121
143
  }
122
144
  return null;
123
145
  };
124
- for (const el of info.interactive) {
125
- if (elements.length >= maxElements) break;
146
+ const fresh = (el) => !!before && !before.has(keyOf(el));
147
+ // the most useful first (new since `before`, open dialogs and menus, on screen), then the page's order
148
+ const order = info.interactive.map((el, k) => ({ el, k })).sort((a, b) => (fresh(b.el) - fresh(a.el)) || (a.el.rank - b.el.rank) || (a.k - b.k));
149
+ let left = 0;
150
+ for (const { el, k } of order) {
151
+ if (elements.length >= maxElements) { left++; continue; }
126
152
  const key = `${el.role ?? el.alt}|${el.name}|${el.x}|${el.y}`;
127
153
  if (seen.has(key)) continue;
128
154
  seen.add(key);
@@ -143,8 +169,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
143
169
  if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
144
170
  }
145
171
  if (!target && el.css) target = await verify(page.locator(el.css), { css: el.css }, el);
146
- if (target) elements.push({ ...el, target });
172
+ // two elements given the same target would both click the first: list neither as verified
173
+ if (target && targets.has(JSON.stringify(target))) {
174
+ const dup = elements.findIndex((e) => JSON.stringify(e.target) === JSON.stringify(target));
175
+ if (dup >= 0) elements.splice(dup, 1);
176
+ continue;
177
+ }
178
+ if (target) { targets.add(JSON.stringify(target)); elements.push({ ...el, k, fresh: fresh(el), target }); }
147
179
  }
180
+ elements.sort((a, b) => a.k - b.k);
148
181
 
149
182
  const url = page.url(), origin = new URL(url).origin;
150
183
  const internal = [...new Set(info.interactive.filter((e) => e.href && !e.href.startsWith('#'))
@@ -168,7 +201,10 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
168
201
  '',
169
202
  '## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
170
203
  ' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
171
- ...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? (e.range ? ` (slider ${e.range.lo}…${e.range.hi}, now ${e.range.now} — \`slide: { target: …, value: ${e.range.now} }\`)` : ' (slider — `slide: { target: …, to: 0..1 }`)') : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
204
+ ...(before && elements.some((e) => e.fresh) ? [' ★ = new since the step before (what this step opened)'] : []),
205
+ ...elements.map((e) => `${e.fresh ? '★' : fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? (e.range ? ` (slider ${e.range.lo}…${e.range.hi}, now ${e.range.now} — \`slide: { target: …, value: ${e.range.now} }\`)` : ' (slider — `slide: { target: …, to: 0..1 }`)') : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}${e.rank === 3 ? ' (covered by something on top right now)' : ''}`),
206
+ ...(left ? [`… and ${left} more not listed (the first ${maxElements} are: ${before ? 'new ones, ' : ''}open dialogs and menus, then what's on screen). ` +
207
+ 'For a panel or menu, open it with a step and run `inspect <storyboard> --after <that step>`; its elements are listed first.'] : []),
172
208
  '',
173
209
  '## Images with alt text (target: { alt: "..." })',
174
210
  ...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
@@ -179,7 +215,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
179
215
  return { text: lines.join('\n'), height: info.height, title: info.title, url, headings: info.headings, anchors: info.anchors, elements };
180
216
  }
181
217
 
182
- export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
218
+ export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 150 } = {}) {
183
219
  const browser = await chromium.launch();
184
220
  const context = await sessionContext(browser, { viewport, storageState, ignoreHTTPSErrors });
185
221
  const page = await context.newPage();
package/src/qwen-tts.mjs CHANGED
@@ -49,13 +49,16 @@ export function qwenSpeakText(text, lang = qwenLang(text)) {
49
49
  return t;
50
50
  }
51
51
 
52
- // The language the model is told: the script decides; Latin text is left to the model.
53
- export function qwenLang(text) {
52
+ // The language the model is told: the script decides; a line in Latin letters gets the storyboard's
53
+ // `lang` (es → spanish) when the model knows it, else it's left to the model.
54
+ const LATIN_LANGS = { en: 'english', es: 'spanish', fr: 'french', de: 'german', it: 'italian', pt: 'portuguese' };
55
+ export function qwenLang(text, lang) {
54
56
  const s = String(text).replace(/\d+(?:\.\d+)?(?:[eE][-−+]?\d+)?/g, ' '); // the e in 2e-105 is no English word
55
57
  if (/[぀-ヿ]/.test(s)) return 'japanese';
56
58
  if (/[가-힯]/.test(s)) return 'korean';
57
- const han = (s.match(/[㐀-鿿]/g) ?? []).length, latin = (s.match(/[A-Za-z]+/g) ?? []).length;
58
- return han > latin ? 'chinese' : 'auto';
59
+ if (/[А-яЁё]/.test(s)) return 'russian';
60
+ const han = (s.match(/[㐀-鿿]/g) ?? []).length, latin = (s.match(/[A-Za-zÀ-ÿ]+/g) ?? []).length;
61
+ return han > latin ? 'chinese' : LATIN_LANGS[String(lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'auto';
59
62
  }
60
63
 
61
64
  // --- the venv ---------------------------------------------------------------------------------------
@@ -104,7 +107,8 @@ function ensureVenv(log) {
104
107
  }
105
108
 
106
109
  // --- synthesis --------------------------------------------------------------------------------------
107
- // entries: [{ text, file }] (text already through qwenSpeakText) → a WAV at each `file`.
110
+ // entries: [{ text, written?, file }] (text already through qwenSpeakText; written: the line before
111
+ // `pronounce`, which the take check also accepts) → a WAV at each `file`.
108
112
  export async function synthQwen(v, entries, { log = console.log } = {}) {
109
113
  if (!entries.length) return;
110
114
  if (!qwenSupported()) throw new Error('the Qwen3-TTS voice runs on Apple Silicon Macs only (it uses MLX)');
@@ -112,7 +116,7 @@ export async function synthQwen(v, entries, { log = console.log } = {}) {
112
116
  if (!fs.existsSync(MODEL_FILE)) log(`downloading the Qwen3-TTS model (one time, ~2.3 GB) into ${HUB}…`);
113
117
  if (!fs.existsSync(ASR_FILE)) log(`downloading the Qwen3-ASR model that listens to each take (one time, ~1 GB) into ${HUB}…`);
114
118
  const lines = entries.map((e) => ({
115
- text: e.text, file: `${e.file}.raw.wav`, voice: v.voice, lang: qwenLang(e.text),
119
+ text: e.text, written: e.written ?? e.text, file: `${e.file}.raw.wav`, voice: v.voice, lang: qwenLang(e.text, v.lang),
116
120
  // the same line gets the same take: a seed from the text
117
121
  seed: parseInt(crypto.createHash('sha1').update(e.text).digest('hex').slice(0, 7), 16),
118
122
  }));