storyshot 0.6.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -26
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +93 -14
- package/src/audio.mjs +9 -4
- package/src/check.mjs +43 -3
- package/src/compose.mjs +33 -9
- package/src/config.mjs +15 -12
- package/src/doctor.mjs +2 -2
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +11 -2
- package/src/inspect.mjs +63 -19
- package/src/qwen-tts.mjs +40 -15
- package/src/qwen_tts.py +144 -13
- package/src/record.mjs +208 -64
- package/src/voice.mjs +107 -11
package/src/config.mjs
CHANGED
|
@@ -31,10 +31,10 @@ export function loadEnv(dir) {
|
|
|
31
31
|
|
|
32
32
|
// What the video is for sets sensible defaults; anything in the YAML overrides them.
|
|
33
33
|
export const PURPOSES = {
|
|
34
|
-
pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false },
|
|
35
|
-
tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: 'clicks', highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
36
|
-
course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
37
|
-
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
|
|
34
|
+
pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false, hyperframes: false },
|
|
35
|
+
tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: 'clicks', highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true, hyperframes: true },
|
|
36
|
+
course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true, hyperframes: true },
|
|
37
|
+
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false, hyperframes: false },
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
// Where storyboards and their videos go, and how they're named: <site>-<kind>[-<topic>].yaml →
|
|
@@ -43,9 +43,10 @@ export const PURPOSES = {
|
|
|
43
43
|
// follow each action and to read its result before the next one starts.
|
|
44
44
|
export const PACES = {
|
|
45
45
|
// cursor ms typing ms/char beat after a click (ms) hold after narration (s) waits shown up to (s) voice speed
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// shortest pause between the sentences of one line (s)
|
|
47
|
+
fast: { cursor: 700, type: 55, beat: 0, tail: 0.4, maxWait: 1.0, voiceSpeed: 1, sentencePause: 0.3 },
|
|
48
|
+
normal: { cursor: 900, type: 70, beat: 350, tail: 0.8, maxWait: 1.5, voiceSpeed: 1, sentencePause: 0.45 },
|
|
49
|
+
slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92, sentencePause: 0.6 },
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
|
|
@@ -138,8 +139,9 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
138
139
|
if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
|
|
139
140
|
|
|
140
141
|
// hyperframes: true | { template: lesson | launch, band, bandColors, emblem, hand, backdrop } — cards
|
|
141
|
-
// rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one
|
|
142
|
-
|
|
142
|
+
// rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one.
|
|
143
|
+
// On by default for tutorials and lessons, where Node can run it (22+); else the built-in cards
|
|
144
|
+
const hf0 = raw.hyperframes !== undefined ? raw.hyperframes : P.hyperframes && Number(process.versions.node.split('.')[0]) >= 22;
|
|
143
145
|
const hyperframes = hf0 ? { template: purpose === 'pitch' ? 'launch' : 'lesson', backdrop: true, ...(typeof hf0 === 'object' ? hf0 : {}) } : null;
|
|
144
146
|
if (hyperframes && !['lesson', 'launch'].includes(hyperframes.template)) throw new Error('hyperframes.template must be lesson or launch');
|
|
145
147
|
if (hyperframes?.bandColors && !['aminoacid', 'nucleotide', 'accent'].includes(hyperframes.bandColors)) throw new Error('hyperframes.bandColors must be aminoacid, nucleotide or accent');
|
|
@@ -152,7 +154,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
152
154
|
// kokoro's and qwen's default voices follow the narration's language (zf_001 / serena for Chinese)
|
|
153
155
|
const kokoroVoice = kokoroVoiceFor(raw);
|
|
154
156
|
const byLang = { kokoro: kokoroVoice, qwen: qwenVoiceFor(raw) }[provider];
|
|
155
|
-
voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...raw.voice, provider };
|
|
157
|
+
voice = { speed: pace0.voiceSpeed, volume: 1, pause: pace0.sentencePause, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...(raw.lang ? { lang: raw.lang } : {}), ...raw.voice, provider };
|
|
158
|
+
if (!(typeof voice.pause === 'number' && voice.pause >= 0 && voice.pause <= 3)) throw new Error('voice.pause is the shortest pause between the sentences of a line, in seconds, 0–3 (0 leaves the voice as it is)');
|
|
156
159
|
if (provider === 'qwen') {
|
|
157
160
|
const v = qwenVoice(voice.voice);
|
|
158
161
|
if (!v) throw new Error(`voice: qwen has no voice "${voice.voice}" (one of ${QWEN_VOICES.join(', ')})`);
|
|
@@ -214,8 +217,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
214
217
|
follow,
|
|
215
218
|
music,
|
|
216
219
|
voice,
|
|
217
|
-
// sfx: true (clicks
|
|
218
|
-
// sound at every card soon grates), or false
|
|
220
|
+
// sfx: true (clicks, transition whooshes and risers), 'clicks' (no whooshes or risers: tutorials and
|
|
221
|
+
// lessons, where a sound at every card soon grates), or false
|
|
219
222
|
sfx: pick('sfx') === false ? false : { clicks: true, transitions: pick('sfx') !== 'clicks' },
|
|
220
223
|
draft,
|
|
221
224
|
out: path.resolve(dir, raw.out ?? `${name}.mp4`).replace(/(\.[^./]+)?$/, (ext) => (draft ? `.draft${ext || '.mp4'}` : ext)),
|
package/src/doctor.mjs
CHANGED
|
@@ -81,8 +81,8 @@ export async function doctor({ log = console.log } = {}) {
|
|
|
81
81
|
: '· Chinese voice (kokoro zf_001) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
|
|
82
82
|
const q = qwenStatus();
|
|
83
83
|
lines.push(!q.supported ? '· Qwen3-TTS voice (provider: qwen) needs an Apple Silicon Mac; storyboards that use it fall back to kokoro here'
|
|
84
|
-
: q.ready ? `✔ Qwen3-TTS voice (provider: qwen): venv in ${q.venv}${q.model ? ',
|
|
85
|
-
: q.python ? `· Qwen3-TTS voice (provider: qwen): Python ${q.python.version} found; storyshot sets up its venv in ${q.venv} and downloads the
|
|
84
|
+
: q.ready ? `✔ Qwen3-TTS voice (provider: qwen): venv in ${q.venv}${q.model ? ', models downloaded' : '; the models (~3.3 GB: the voice, and a speech recognizer that checks each take) download on first use'}`
|
|
85
|
+
: q.python ? `· Qwen3-TTS voice (provider: qwen): Python ${q.python.version} found; storyshot sets up its venv in ${q.venv} and downloads the models (~3.3 GB) on first use`
|
|
86
86
|
: '· Qwen3-TTS voice (provider: qwen) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
|
|
87
87
|
const hf = hyperframesCli();
|
|
88
88
|
lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
|
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' }) => `<!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 =
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
350
|
-
const
|
|
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
|
-
${
|
|
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'
|
|
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'
|
|
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,
|
|
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,6 +7,15 @@ 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",
|
|
11
|
+
// "Hoppscotch • Open source API development ecosystem • Hoppscotch" → "Hoppscotch"),
|
|
12
|
+
// or the repo on a code host ("GitHub - boheling/storyshot: Ask…" → "storyshot"). Only spaced
|
|
13
|
+
// separators split, so a hyphenated name (Tic-Tac-Toe) stays whole.
|
|
14
|
+
export function productName(title, host) {
|
|
15
|
+
const segs = String(title ?? '').split(/\s+[||—–·•∙‧-]\s+|:\s+/).map((s) => s.trim()).filter(Boolean);
|
|
16
|
+
const repo = segs.find((s) => /^[\w.-]+ ?\/ ?[\w.-]+$/.test(s));
|
|
17
|
+
return repo ? repo.split('/')[1].trim() : segs[0] ?? host;
|
|
18
|
+
}
|
|
10
19
|
const tgt = (t) => (typeof t === 'string' ? q(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ')} }`);
|
|
11
20
|
|
|
12
21
|
export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, ignoreHTTPSErrors = false, viewport = { width: 1600, height: 900 } } = {}) {
|
|
@@ -41,7 +50,7 @@ out: ${name}.mp4
|
|
|
41
50
|
${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
|
|
42
51
|
|
|
43
52
|
${purpose === 'pitch' ? `intro: # title card before the recording (promos; optional for tutorials/lessons)
|
|
44
|
-
title: ${q(page.title
|
|
53
|
+
title: ${q(productName(page.title, host))}
|
|
45
54
|
subtitle: TODO the one-line promise, with the **key words** in bold
|
|
46
55
|
${PURPOSES[purpose]?.frame ? '# frame: false # the recording sits in a browser window on a backdrop; false = full frame\n' : ''}
|
|
47
56
|
` : purpose === 'tutorial' || purpose === 'course' ? `# intro: { title: TODO, subtitle: TODO } # optional title card (e.g. "How to export a report")
|
|
@@ -65,7 +74,7 @@ steps:
|
|
|
65
74
|
# run \`storyshot inspect ${file} --after <step>\` to list verified targets at that point.
|
|
66
75
|
|
|
67
76
|
end_card:
|
|
68
|
-
title: ${q(page.title
|
|
77
|
+
title: ${q(productName(page.title, host))}
|
|
69
78
|
cta: ${q(`Try it at ${host}`)}${narrated ? `\n say: ${q(`Try it at ${host}.`)}` : ''}
|
|
70
79
|
|
|
71
80
|
# ── Verified targets on ${page.url} (each matches exactly one visible element) ──
|