storyshot 0.7.0 → 0.7.3
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 +44 -11
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +18 -3
- package/src/check.mjs +3 -1
- package/src/compose.mjs +29 -10
- package/src/config.mjs +1 -1
- package/src/hyperframes.mjs +167 -42
- package/src/init.mjs +3 -2
- package/src/inspect.mjs +51 -15
- package/src/outputs.mjs +11 -7
- 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/hyperframes.mjs
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
115
|
-
// "**word** rest" -> one span per word (per character in CJK), highlighted words inside a .hl span
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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', floor = H * 0.97 }) => `<!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,16 +247,42 @@ ${css}
|
|
|
163
247
|
${body}
|
|
164
248
|
</div></div>
|
|
165
249
|
<script>
|
|
166
|
-
const tl =
|
|
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)) <= ${floor})) break;
|
|
271
|
+
fits.forEach(shrink);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
169
274
|
${js}
|
|
170
|
-
|
|
275
|
+
refit();
|
|
276
|
+
document.fonts?.ready.then(refit);
|
|
277
|
+
tl.build();
|
|
171
278
|
</script></body></html>`;
|
|
172
279
|
|
|
173
280
|
// --- lesson: tutorials, courses, docs ---------------------------------------------------------------
|
|
174
281
|
function lesson(job, ctx) {
|
|
175
|
-
|
|
282
|
+
// sizes are designed for 1920x1080; a portrait frame (1080x1920) sizes them by its width instead
|
|
283
|
+
const { W, H, dark, font, opts } = ctx, u = Math.min(H, W * 1.15) / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
|
|
284
|
+
// portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
|
|
285
|
+
const lift = ctx.subs && H > W ? H * 0.22 : 0;
|
|
176
286
|
const P = dark
|
|
177
287
|
? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
|
|
178
288
|
: { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
|
|
@@ -186,9 +296,11 @@ function lesson(job, ctx) {
|
|
|
186
296
|
const em = job.kind === 'end' ? null : c.emblem === false ? null : [].concat(c.emblem ?? opts.emblem ?? []).filter(Boolean).slice(0, 3).map(String);
|
|
187
297
|
const emblem = !em?.length ? '' : (() => {
|
|
188
298
|
const a = em[0], z = em.at(-1);
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
299
|
+
// (only lines of the same length line up like an alignment; lines of code differ everywhere)
|
|
300
|
+
const aligned = em.length > 1 && em.every((x) => x.length === a.length);
|
|
301
|
+
const diff = (i) => aligned && a[i] !== z[i];
|
|
302
|
+
const row = (s, k) => `<div class="row r${k === 0 || !aligned ? 'q' : k === em.length - 1 ? 's' : 'm'}">${[...s].map((ch, i) =>
|
|
303
|
+
`<i class="${aligned && (k === 1 && em.length === 3 ? ch !== '|' && ch !== ' ' : diff(i)) ? 'df' : ''}">${ch === ' ' ? ' ' : esc(ch)}</i>`).join('')}</div>`;
|
|
192
304
|
return `<div id="aln" class="aln" data-layout-ignore>${em.map(row).join('')}</div>`;
|
|
193
305
|
})();
|
|
194
306
|
const kicker = c.kicker ?? (job.kind === 'end' && c.subtitle && c.bullets?.length && String(c.subtitle).length <= 14 ? c.subtitle : null);
|
|
@@ -221,14 +333,14 @@ function lesson(job, ctx) {
|
|
|
221
333
|
.band-wrap{position:absolute;left:0;right:0;height:${80 * u}px;overflow:hidden;opacity:${P.band};-webkit-mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent)}
|
|
222
334
|
.band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
|
|
223
335
|
.band i,.aln i{font-style:normal}
|
|
224
|
-
.col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px}
|
|
336
|
+
.col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px ${lift}px}
|
|
225
337
|
.aln{font:500 ${30 * u}px 'IBM Plex Mono',monospace;letter-spacing:.34em;line-height:1.12;margin-bottom:${44 * u}px;color:${P.muted}}
|
|
226
338
|
.aln .row{white-space:nowrap}.aln i{display:inline-block}
|
|
227
339
|
.aln .rm{color:${strong};opacity:.8}.aln i.df{color:${dark ? accent : P.text};background:${dark ? 'transparent' : accent};font-weight:700}
|
|
228
340
|
.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
341
|
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
342
|
.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}
|
|
343
|
+
.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
344
|
.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
345
|
.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
346
|
.split{flex-direction:row;gap:${72 * u}px;text-align:left}
|
|
@@ -253,11 +365,11 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
|
|
|
253
365
|
.tx{font-size:${31 * u}px;line-height:1.24;font-weight:500}
|
|
254
366
|
.tx b{color:${dark ? accent : P.text}}
|
|
255
367
|
.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'}}
|
|
368
|
+
.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
369
|
.cta.cmd{font:500 ${36 * u}px 'IBM Plex Mono',monospace;background:${dark ? '#1a2238' : ink};color:#e2e8f0;text-align:left}
|
|
258
370
|
.cta.cmd b{color:${accent}}
|
|
259
371
|
#type{display:inline-block;white-space:nowrap}
|
|
260
|
-
.think{position:absolute;left:50%;bottom:${H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
|
|
372
|
+
.think{position:absolute;left:50%;bottom:${lift ? H * 0.2 : H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
|
|
261
373
|
.tlabel{margin-bottom:${12 * u}px}
|
|
262
374
|
.track{height:${8 * u}px;border-radius:99px;background:${P.line};overflow:hidden}
|
|
263
375
|
.drain{height:100%;background:${accent};transform-origin:0 50%}`;
|
|
@@ -265,7 +377,7 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
|
|
|
265
377
|
const body = `<div class="full bg"></div>
|
|
266
378
|
${job.backdrop ? `<img id="backdrop" class="full backdrop" src="assets/backdrop.jpg">` : ''}
|
|
267
379
|
${bandText ? band('bandA', 96 * u, bandText) + band('bandB', H - 176 * u, [...bandText].reverse().join('')) : ''}
|
|
268
|
-
<div class="col${split ? ' split' : ''}">
|
|
380
|
+
<div class="col${split ? ' split' : ''}" data-fit-box>
|
|
269
381
|
${split ? `<div class="left">${image}</div><div class="right">` : ''}
|
|
270
382
|
${logo}${emblem}
|
|
271
383
|
${kicker ? `<div id="kick" class="kick">${esc(kicker)}</div>` : ''}
|
|
@@ -284,6 +396,11 @@ ${think}`;
|
|
|
284
396
|
|
|
285
397
|
const n = (x) => x.toFixed(2);
|
|
286
398
|
const js = `
|
|
399
|
+
fit('#title', ${split ? W * 0.44 : W * 0.82}, 3);
|
|
400
|
+
fit('#sub', ${split ? W * 0.44 : W * 0.8}, 3);
|
|
401
|
+
fit('#aln', ${split ? W * 0.44 : W * 0.88}, 0);
|
|
402
|
+
fit('.steps', ${W * 0.88}, 0);
|
|
403
|
+
fit('#cta', ${split ? W * 0.44 : W * 0.8}, ${isCmd(c.cta) ? 0 : 2});
|
|
287
404
|
${bandText ? `tl.fromTo('#bandA', { x: 0 }, { x: ${-92 * D * u}, duration: ${D}, ease: 'none' }, 0);
|
|
288
405
|
tl.fromTo('#bandB', { x: ${-900 * u} }, { x: ${-900 * u + 92 * D * u}, duration: ${D}, ease: 'none' }, 0);` : ''}
|
|
289
406
|
${job.backdrop ? `tl.fromTo('#backdrop', { x: 0 }, { x: ${-14 * D * u}, duration: ${D}, ease: 'none' }, 0);` : ''}
|
|
@@ -315,7 +432,7 @@ ${isCmd(c.cta) ? `tl.fromTo('#type', { clipPath: 'inset(0 100% 0 0)' }, { clipPa
|
|
|
315
432
|
${think ? `blurIn('#think', ${n(job.thinkAt)});
|
|
316
433
|
tl.fromTo('#drain', { scaleX: 1 }, { scaleX: 0, duration: ${c.think}, ease: 'none' }, ${n(job.thinkAt)});` : ''}
|
|
317
434
|
`;
|
|
318
|
-
return doc({ W, H, D, css, body, js, bg: P.ground,
|
|
435
|
+
return doc({ W, H, D, css, body, js, bg: P.ground, floor: H * 0.97 - lift });
|
|
319
436
|
}
|
|
320
437
|
|
|
321
438
|
// --- launch: promos ----------------------------------------------------------------------------------
|
|
@@ -334,7 +451,7 @@ async function launch(job, ctx) {
|
|
|
334
451
|
#stage{font-family:'${font}','StoryshotCJK',sans-serif;color:${INK}}
|
|
335
452
|
.paper{background:#fff}
|
|
336
453
|
.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;
|
|
454
|
+
.line{position:absolute;left:0;right:0;margin:0 auto;text-align:center;line-height:1.3;text-wrap:balance}
|
|
338
455
|
.hl{position:relative;display:inline-block;color:${STROKE}}
|
|
339
456
|
.ul{position:absolute;left:-4%;width:108%;bottom:-0.42em;height:0.5em;overflow:visible}
|
|
340
457
|
.ulp{stroke-width:${6 * u}px}
|
|
@@ -346,8 +463,10 @@ async function launch(job, ctx) {
|
|
|
346
463
|
const T = job.logoTarget; // where the app shows the same logo in its first frame, if it does
|
|
347
464
|
const L = job.logoSize; // the logo picture's size in output px
|
|
348
465
|
const hasLogo = !!(job.logo && L);
|
|
349
|
-
|
|
350
|
-
const
|
|
466
|
+
// a logo and a title: the boxed logo, the title under it (leave out title when the logo spells the name)
|
|
467
|
+
const named = hasLogo && !!c.title, nameSize = (String(c.title ?? '').length > 24 ? 64 : 84) * u;
|
|
468
|
+
const big = hasLogo ? Math.min((W * 0.42) / L.w, (H * (named ? 0.15 : 0.17)) / L.h, 3.2) : 1;
|
|
469
|
+
const cy = H * (named ? (c.subtitle ? 0.355 : 0.4) : c.subtitle ? 0.41 : 0.47);
|
|
351
470
|
const bw = hasLogo ? L.w * big : 0, bh = hasLogo ? L.h * big : 0, bx = (W - bw) / 2, by = cy - bh / 2;
|
|
352
471
|
const exitAt = Math.max(1.4, D - 1.0), shrink = 0.68;
|
|
353
472
|
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 +476,9 @@ async function launch(job, ctx) {
|
|
|
357
476
|
#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
477
|
#pageimg{object-fit:fill;opacity:0}
|
|
359
478
|
.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}
|
|
479
|
+
.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}
|
|
480
|
+
.tbox svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
|
|
481
|
+
#name{font-size:${nameSize}px}`;
|
|
362
482
|
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
483
|
const body = `
|
|
364
484
|
${F.frameBg ? `<img class="full" src="assets/frame-bg.png">` : `<div class="full" style="background:#0b1020"></div>`}
|
|
@@ -366,25 +486,30 @@ ${F.chrome ? `<img id="chrome" class="full" src="assets/frame-chrome.png" style=
|
|
|
366
486
|
${F.url ? `<img id="url" class="full" src="assets/frame-url.png" style="opacity:0">` : ''}
|
|
367
487
|
<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
488
|
${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
|
-
${
|
|
489
|
+
${named ? `<div id="name" class="line title" style="top:${by + bh + 92 * u}px">${words(c.title)}</div>` : ''}
|
|
490
|
+
${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
491
|
const sx = F.FW / W, sy = F.FH / H, framed = F.FX > 0 || F.FY > 0;
|
|
371
492
|
const js = `
|
|
493
|
+
fit('#tbox .t', ${W * 0.86}, 0);
|
|
494
|
+
fit('#name', ${W * 0.86}, 1);
|
|
495
|
+
fit('#sub', ${W * 0.84}, 2);
|
|
372
496
|
${hasLogo ? `draw('.bx', 0.0, 0.5, 0.18);
|
|
373
497
|
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)
|
|
498
|
+
{ 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);
|
|
499
|
+
${named ? "blurIn('#name .w', 0.35, { d: 0.16, stagger: 0.07 });" : ''}`
|
|
375
500
|
: `draw('.bx', 0.0, 0.5, 0.18);
|
|
376
501
|
blurIn('#tbox .w', 0.12, { d: 0.16, stagger: 0.07 });`}
|
|
377
502
|
blurIn('#sub .w', 0.5, { d: 0.16, stagger: 0.07 });
|
|
378
503
|
draw('#sub .ulp', 1.0, 0.4, 0);
|
|
379
504
|
// 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)});
|
|
505
|
+
tl.to('#box, #sub, #grid, #tbox${named ? ', #name' : ''}', { opacity: 0, filter: 'blur(6px)', duration: 0.22, ease: 'power2.in' }, ${n(exitAt)});
|
|
381
506
|
${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
507
|
${job.page ? `tl.fromTo('#pageimg', { opacity: 0 }, { opacity: 1, duration: 0.4, ease: 'power2.inOut' }, ${n(exitAt + 0.25)});` : ''}
|
|
383
508
|
${hasLogo ? (T ? `tl.to('#logo', { x: 0, y: 0, scale: 1, duration: ${shrink}, ease: 'power3.inOut' }, ${n(exitAt + 0.08)});`
|
|
384
509
|
: `tl.to('#logo', { opacity: 0, filter: 'blur(6px)', duration: 0.22, ease: 'power2.in' }, ${n(exitAt)});`) : ''}
|
|
385
510
|
${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.out' }, ${n(exitAt + 0.35)});` : ''}
|
|
386
511
|
`;
|
|
387
|
-
return doc({ W, H, D, css, body, js, bg: '#fff'
|
|
512
|
+
return doc({ W, H, D, css, body, js, bg: '#fff' });
|
|
388
513
|
}
|
|
389
514
|
|
|
390
515
|
// the end card is drawn over the last frame, which storyshot zooms into the page underneath: the
|
|
@@ -401,12 +526,12 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
|
|
|
401
526
|
const css = `${css0}
|
|
402
527
|
.stack{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:${30 * u}px}
|
|
403
528
|
#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}
|
|
529
|
+
.etitle{font-size:${(String(c.title ?? '').length > 24 ? 80 : 104) * u}px;line-height:1.2;text-wrap:balance}
|
|
530
|
+
.esub{font-family:${handFont};font-size:${(opts.hand ? 66 : 54) * u}px;line-height:1.3;text-wrap:balance}
|
|
406
531
|
.blist{display:flex;flex-direction:column;gap:${12 * u}px;font-size:${36 * u}px;text-align:left}
|
|
407
532
|
.blist .bi{display:flex;align-items:center;gap:${16 * u}px}
|
|
408
533
|
.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}}
|
|
534
|
+
.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
535
|
.cta .box{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
|
|
411
536
|
.cta .arrow{position:absolute;left:${-arrowW - 16 * u}px;top:calc(50% - ${24 * u}px);width:${arrowW}px;height:${arrowH}px;overflow:visible}
|
|
412
537
|
.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 +539,7 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
|
|
|
414
539
|
const check = (i) => strokes(g.linearPath([[4, 22], [15, 34], [36, 6]], R({ seed: 30 + i, stroke: STROKE, strokeWidth: 4 * u })), `ck${i}`);
|
|
415
540
|
const body = `
|
|
416
541
|
<div id="paper" class="full paper" style="opacity:0"></div>
|
|
417
|
-
<div class="stack">
|
|
542
|
+
<div class="stack" data-fit-box>
|
|
418
543
|
${hasLogo ? `<img id="logo" src="assets/logo${path.extname(job.logo)}">` : ''}
|
|
419
544
|
${c.title ? `<div id="etitle" class="etitle title">${words(c.title)}</div>` : ''}
|
|
420
545
|
${c.subtitle ? `<div id="esub" class="esub">${words(c.subtitle, { underline: underline(13) })}</div>` : ''}
|
|
@@ -424,7 +549,11 @@ ${F.chrome ? `tl.to('#chrome, #url', { opacity: 1, duration: 0.3, ease: 'power2.
|
|
|
424
549
|
<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
550
|
<div id="try" class="try">${esc(tryLabel)}</div></div>` : ''}
|
|
426
551
|
</div>`;
|
|
552
|
+
// the call to action keeps room on its left for the hand-drawn arrow and its "try it"
|
|
427
553
|
const js = `
|
|
554
|
+
fit('#etitle', ${W * 0.84}, 2);
|
|
555
|
+
fit('#esub', ${W * 0.8}, 2);
|
|
556
|
+
fit('#cta', ${W - 2 * (arrowW + 240 * u)}, ${cmd ? 0 : 2});
|
|
428
557
|
tl.fromTo('#paper', { opacity: 0 }, { opacity: 0.88, duration: 0.45, ease: 'power2.out' }, 0.3);
|
|
429
558
|
${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
559
|
blurIn('#etitle .w', 0.5, { d: 0.16, stagger: 0.07 });
|
|
@@ -439,7 +568,7 @@ draw('.ar', ${n(ctaAt + 0.55)}, 0.4, 0.1);
|
|
|
439
568
|
draw('.ah', ${n(ctaAt + 0.9)}, 0.2, 0.05);
|
|
440
569
|
blurIn('#try', ${n(ctaAt + 0.85)}, { d: 0.25 });` : ''}
|
|
441
570
|
`;
|
|
442
|
-
return doc({ W, H, D, css, body, js, bg: 'transparent'
|
|
571
|
+
return doc({ W, H, D, css, body, js, bg: 'transparent' });
|
|
443
572
|
}
|
|
444
573
|
|
|
445
574
|
// --- rendering ---------------------------------------------------------------------------------------
|
|
@@ -451,14 +580,12 @@ const hashOf = (...parts) => {
|
|
|
451
580
|
|
|
452
581
|
// jobs: [{ name, kind: intro|section|end, seconds, card, voice: {text, start, len}?, index, of, thinkAt,
|
|
453
582
|
// backdrop?, page?, logo?, logoSize?, logoTarget?, image?, alpha? }]
|
|
454
|
-
// ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work }
|
|
583
|
+
// ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work, subs (burned-in subtitles) }
|
|
455
584
|
// Returns { [name]: { dir, ext, alpha } } for the cards that rendered; the rest fall back.
|
|
456
585
|
export async function renderHyperframesCards(jobs, ctx, { log = console.log, warn = log } = {}) {
|
|
457
586
|
if (!jobs.length) return {};
|
|
458
587
|
let cli = hyperframesCli();
|
|
459
588
|
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
589
|
const template = ctx.opts.template;
|
|
463
590
|
const root = path.join(ctx.work, 'hf');
|
|
464
591
|
fs.mkdirSync(root, { recursive: true });
|
|
@@ -470,10 +597,8 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
|
|
|
470
597
|
try { await import('roughjs'); } catch { warn('hyperframes: the launch template needs the roughjs package (npm i roughjs) — using the built-in cards'); return {}; }
|
|
471
598
|
}
|
|
472
599
|
const alpha = launchCard && job.kind === 'end';
|
|
473
|
-
const html = launchCard ? await launch(job,
|
|
474
|
-
: lesson(job, { ...ctx, gsap: gsapFile ? 'assets/gsap.min.js' : GSAP_CDN });
|
|
600
|
+
const html = launchCard ? await launch(job, ctx) : lesson(job, ctx);
|
|
475
601
|
const assets = {
|
|
476
|
-
...(gsapFile ? { 'gsap.min.js': gsapFile } : {}),
|
|
477
602
|
...(launchCard ? { 'Virgil.woff2': path.join(HERE, 'fonts', 'Virgil.woff2') } : {}),
|
|
478
603
|
...(job.backdrop ? { 'backdrop.jpg': job.backdrop } : {}),
|
|
479
604
|
...(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+[
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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 =
|
|
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/outputs.mjs
CHANGED
|
@@ -90,7 +90,11 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
90
90
|
// Chinese, Japanese, Korean: no spaces between words, so lines are cut by characters (one line of
|
|
91
91
|
// at most ~18), in a lighter weight and a font that has the glyphs
|
|
92
92
|
const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
|
|
93
|
-
|
|
93
|
+
// portrait (9:16): size by the width, and keep the lines above the bottom fifth, which short-video
|
|
94
|
+
// apps cover with the title, the account name and their buttons
|
|
95
|
+
const portrait = H > W;
|
|
96
|
+
const size = Math.round(portrait ? W * (cjk ? 0.068 : 0.062) : H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
|
|
97
|
+
const lift = Math.round(H * (portrait ? 0.2 : 0.06)), most = portrait ? Math.floor((W * 0.84) / size) : 18;
|
|
94
98
|
const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
|
|
95
99
|
const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
|
|
96
100
|
for (const line of narration) {
|
|
@@ -102,7 +106,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
102
106
|
const total = words.reduce((a, w) => a + weight(w), 0), span = line.end - line.start;
|
|
103
107
|
let at = line.start;
|
|
104
108
|
const timed = words.map((w) => { const d = (span * weight(w)) / total; const r = { w, start: at, d }; at += d; return r; });
|
|
105
|
-
// chunks of up to 7 words, broken after sentence punctuation
|
|
109
|
+
// chunks of up to 7 words (5 in portrait), broken after sentence punctuation
|
|
106
110
|
let chunk = [];
|
|
107
111
|
const flush = () => {
|
|
108
112
|
if (!chunk.length) return;
|
|
@@ -111,10 +115,10 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
111
115
|
};
|
|
112
116
|
const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
|
|
113
117
|
for (const x of timed) {
|
|
114
|
-
// a CJK line ends at a sentence break, or at a comma once it is half full, or
|
|
115
|
-
if (cjk && chunk.length && len() + x.w.length >
|
|
118
|
+
// a CJK line ends at a sentence break, or at a comma once it is half full, or when full (18 characters; about 12 in portrait)
|
|
119
|
+
if (cjk && chunk.length && len() + x.w.length > most) flush();
|
|
116
120
|
chunk.push(x);
|
|
117
|
-
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >=
|
|
121
|
+
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >= most / 2) : chunk.length >= (portrait ? 5 : 7) || /[.!?;:]$/.test(x.w)) flush();
|
|
118
122
|
}
|
|
119
123
|
flush();
|
|
120
124
|
}
|
|
@@ -157,7 +161,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
157
161
|
for (const [j, x] of cue.words.entries()) {
|
|
158
162
|
const file = `s${String(n++).padStart(5, '0')}.png`;
|
|
159
163
|
const text = cue.words.map((y, k) => `<span style="color:${k <= j ? sweep : '#fff'}">${esc(y.w)}</span>`).join(cjk ? '' : ' ');
|
|
160
|
-
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${
|
|
164
|
+
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${lift}px;text-align:center;
|
|
161
165
|
font-family:${font};font-size:${px}px;font-weight:${cjk ? 400 : 700};line-height:1.2;
|
|
162
166
|
-webkit-text-stroke:${edge * 2}px ${edgeCol};paint-order:stroke fill;text-shadow:${Math.round(edge * 0.8)}px ${Math.round(edge * 0.8)}px 0 rgba(0,0,0,.5)">${text}</div>`, path.join(dir, file));
|
|
163
167
|
push(file, j + 1 < cue.words.length ? cue.words[j + 1].start : end);
|
|
@@ -184,7 +188,7 @@ WrapStyle: 0
|
|
|
184
188
|
|
|
185
189
|
[V4+ Styles]
|
|
186
190
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
187
|
-
Style: Sub,${fontName},${size},${bgr(sweep)},&H00FFFFFF,${bgr(edgeCol)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${
|
|
191
|
+
Style: Sub,${fontName},${size},${bgr(sweep)},&H00FFFFFF,${bgr(edgeCol)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${lift},1
|
|
188
192
|
|
|
189
193
|
[Events]
|
|
190
194
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|