storyshot 0.4.0 → 0.4.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 +7 -5
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +11 -4
- package/src/audio.mjs +11 -3
- package/src/build.mjs +1 -1
- package/src/check.mjs +18 -2
- package/src/compose.mjs +90 -13
- package/src/config.mjs +24 -4
- package/src/doctor.mjs +10 -1
- package/src/ffmpeg.mjs +27 -0
- package/src/inspect.mjs +4 -2
- package/src/outputs.mjs +83 -11
- package/src/record.mjs +60 -13
- package/src/voice.mjs +1 -0
package/README.md
CHANGED
|
@@ -217,7 +217,7 @@ Everything the agent does is also a command:
|
|
|
217
217
|
| `storyshot record videos/acme-promo.yaml` | Only record. |
|
|
218
218
|
| `storyshot guide` | Print the playbook and the reference. |
|
|
219
219
|
| `storyshot setup <agent>` (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf`, `cline`) | Set up the MCP server and the skill in this project, and install or check what Storyshot needs. |
|
|
220
|
-
| `storyshot doctor` | Check Node, ffmpeg, Chromium and voices; install Chromium if it's missing. |
|
|
220
|
+
| `storyshot doctor` | Check Node, ffmpeg (and the filters and encoders Storyshot uses), Chromium and voices; install Chromium if it's missing. |
|
|
221
221
|
| `storyshot mcp` | Run the MCP server. |
|
|
222
222
|
|
|
223
223
|
Recordings, previews and voice lines are cached in `.storyshot/` next to the
|
|
@@ -252,15 +252,18 @@ voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceo
|
|
|
252
252
|
storageState: auth.json # optional Playwright login state
|
|
253
253
|
ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
|
|
254
254
|
captionPosition: bottom # or top; can also be set per step. A caption that would cover a step's click target or zoom subject moves to the other side automatically
|
|
255
|
+
captions: true # false keeps caption pills off the video (chapters and SRT still use them), e.g. with subtitles: burn
|
|
255
256
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
256
257
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
257
258
|
stepNumbers: false # numbered caption badges
|
|
258
|
-
subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top)
|
|
259
|
+
subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top); uses ffmpeg's libass when it has it, else draws the words itself
|
|
259
260
|
pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
|
|
260
261
|
follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
|
|
261
262
|
article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
|
|
263
|
+
lang: es # language of built-in labels ("Pause and think", chapter "Introduction", "Downloaded"): en, zh, es, pt, fr, de, ja; default: zh if the steps contain Chinese, else en
|
|
262
264
|
cards: auto # intro/section/end cards: auto follows the frame (dark frames get dark cards), or light | dark
|
|
263
|
-
frame: aurora # the recording sits in a browser window on a backdrop: aurora (dark, grain; pitch default), studio (light, grain; tutorial and course default), dark, pastel, blur (the page itself, blurred); false = full frame; or { style, background: <css
|
|
265
|
+
frame: aurora # the recording sits in a browser window on a backdrop: aurora (dark, grain; pitch default), studio (light, grain; tutorial and course default), dark, pastel, blur (the page itself, blurred); false = full frame; or { style, background: <css>, window, title, icon }
|
|
266
|
+
# window: app draws an installed app's window (its icon and name in the title bar, no address bar) for a PWA or desktop web app; title and icon come from the page's web app manifest unless given
|
|
264
267
|
chapters: false # write <out>.chapters.txt for YouTube
|
|
265
268
|
clips: null # gif | webm | mp4 | png, or a list (docs default: [gif, png]): one file per step in <out>-steps/, cropped to what the step touches, plus README.md, a ready-to-paste article (step captions, `doc:` text, images)
|
|
266
269
|
maxWait: 1.0 # with duration: auto, the longest a wait is shown
|
|
@@ -299,8 +302,7 @@ end_card:
|
|
|
299
302
|
logo: { alt: Company logo } # captured from the page, or a path to an image
|
|
300
303
|
title: Acme
|
|
301
304
|
subtitle: Ship **faster**
|
|
302
|
-
cta: Start free at acme.com
|
|
303
|
-
duration: 1.0
|
|
305
|
+
cta: Start free at acme.com # duration: auto (default) fits the say line, bullets and cta, at least 2.5 s
|
|
304
306
|
```
|
|
305
307
|
|
|
306
308
|
Steps that share a caption keep it on screen across both steps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
|
|
5
5
|
"author": "boheling <boheling@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -193,10 +193,12 @@ Rules of thumb from real renders (promos and tutorials):
|
|
|
193
193
|
|
|
194
194
|
- **One idea per step.**
|
|
195
195
|
- `pitch`: 1.5–3 s per step (`duration:`), so the viewer gets one beat per
|
|
196
|
-
shot; the number of shots comes from the story, plus
|
|
196
|
+
shot; the number of shots comes from the story, plus an `end_card` held long enough to read its call to action (at least 2.5 s; leave its `duration` out). Typical shape: `intro` → hero → 2–4
|
|
197
197
|
steps of the core flow ending on a visible result → pricing or CTA → end
|
|
198
198
|
card. Promos sit in a browser window on a backdrop (`frame: false` turns
|
|
199
|
-
that off).
|
|
199
|
+
that off). For an app people install (a PWA, or a desktop app built on
|
|
200
|
+
web tech), use `frame: { window: app }`: the title bar shows the app's
|
|
201
|
+
icon and name, not an address bar.
|
|
200
202
|
- Other purposes: leave out `duration`. `auto` times each step to its
|
|
201
203
|
narration and plays motion in real time.
|
|
202
204
|
- **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
|
|
@@ -257,8 +259,13 @@ the page. Test new targets with `inspect_page` + `after_step`, which is much
|
|
|
257
259
|
faster than a failed render.
|
|
258
260
|
|
|
259
261
|
Notes:
|
|
260
|
-
- The end card's duration fits its `say:` line
|
|
261
|
-
`duration` there.
|
|
262
|
+
- The end card's duration fits its `say:` line, bullets and `cta`
|
|
263
|
+
automatically (never under 2.5 s); leave out `duration` there.
|
|
264
|
+
- Compose warns about **dead air**: 3 s or more where nothing moves and
|
|
265
|
+
nothing is said. Shorten the wait or pause, hide the load in a `hidden`
|
|
266
|
+
step, or narrate it. Keep `intro` cards to 6–8 s.
|
|
267
|
+
- With `subtitles: burn`, the top caption pills can repeat the subtitles;
|
|
268
|
+
`captions: false` keeps them off the video (chapters and SRT still use them).
|
|
262
269
|
- `.storyshot/` next to the YAML is a cache of recordings, screenshots and
|
|
263
270
|
voice lines. Add it to `.gitignore`.
|
|
264
271
|
|
package/src/audio.mjs
CHANGED
|
@@ -137,8 +137,14 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
137
137
|
let dur = pcm.length / 2 / SR;
|
|
138
138
|
const start = Math.max(line.start, prevEnd + 0.12), room = line.until - start - 0.1;
|
|
139
139
|
let sped = 1;
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
// a line pushed past its window by the one before has no room left (room <= 0): it plays late, at normal speed
|
|
141
|
+
if (dur > room && room > 0 && dur / room <= 1.2) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
|
|
142
|
+
else if (dur > room) warn(room > 0
|
|
143
|
+
? `${line.where}: narration is ${dur.toFixed(1)}s but only ${room.toFixed(1)}s until ${line === events.voice.at(-1) ? 'the video ends' : 'the next line'} — shorten "${line.text}" or give the step more duration`
|
|
144
|
+
: `${line.where}: the narration before it runs ${(start - line.start).toFixed(1)}s past where this line should start, so "${line.text}" plays late — shorten the earlier lines or give their steps more duration`);
|
|
145
|
+
if (start + dur > events.total) warn(start >= events.total
|
|
146
|
+
? `${line.where}: "${line.text}" is cut off: the narration before it pushes it past the end of the video (${events.total.toFixed(1)}s) — shorten the lines or give the steps more duration`
|
|
147
|
+
: `${line.where}: the last ${(start + dur - events.total).toFixed(1)}s of "${line.text}" is cut off at the end of the video — shorten it or give the steps more duration`);
|
|
142
148
|
log(` voice ${start.toFixed(2)}–${(start + dur).toFixed(2)}s ${line.where}${sped > 1 ? ` (sped up x${sped.toFixed(2)})` : ''} "${line.text}"`);
|
|
143
149
|
let pk = 0;
|
|
144
150
|
for (const v of pcm) pk = Math.max(pk, Math.abs(v));
|
|
@@ -183,8 +189,10 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
183
189
|
const wavFile = path.join(cfg.work, 'soundtrack.wav');
|
|
184
190
|
fs.writeFileSync(wavFile, wav);
|
|
185
191
|
|
|
192
|
+
// loudnorm alone lets peaks through after AAC encoding; the limiter keeps true peaks near −2 dB,
|
|
193
|
+
// so re-encoding by video sites doesn't clip
|
|
186
194
|
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
|
|
187
|
-
'-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af',
|
|
195
|
+
'-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `loudnorm=I=-14:TP=-1.5:LRA=11,aresample=${SR},alimiter=limit=0.7:level=false:attack=1:release=50`, '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
|
|
188
196
|
log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${events.whooshes.length} whooshes` +
|
|
189
197
|
(cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
|
|
190
198
|
return { out: cfg.out, warnings, narration };
|
package/src/build.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export async function build(cfg, mode = 'render', { log = console.log } = {}) {
|
|
|
12
12
|
const voiceLens = await measureVoice(cfg, { log });
|
|
13
13
|
const { silent, clean, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
|
|
14
14
|
const audio = await soundtrack(cfg, events, silent, { log });
|
|
15
|
-
if (cfg.subtitles === 'burn') burnSubtitles(cfg, audio.narration ?? [], { log });
|
|
15
|
+
if (cfg.subtitles === 'burn') await burnSubtitles(cfg, audio.narration ?? [], { log });
|
|
16
16
|
const extras = await writeOutputs(cfg, { captions, cards, segs, clean, narration: audio.narration, total: events.total }, { log });
|
|
17
17
|
return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
|
|
18
18
|
}
|
package/src/check.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { loadConfig } from './config.mjs';
|
|
|
5
5
|
import { gitIgnored } from './auth.mjs';
|
|
6
6
|
|
|
7
7
|
const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
|
|
8
|
-
'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
|
|
8
|
+
'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
|
|
9
9
|
const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
|
|
10
10
|
const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
|
|
11
11
|
const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
|
|
@@ -85,7 +85,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
85
85
|
if (raw.end_card) unknown(raw.end_card, END, 'end_card');
|
|
86
86
|
if (raw.intro) unknown(raw.intro, END, 'intro');
|
|
87
87
|
if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
|
|
88
|
-
if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style'], 'frame');
|
|
88
|
+
if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
|
|
89
89
|
const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
|
|
90
90
|
for (const [, n] of todo) errors.push(`line ${n}: replace the TODO placeholder`);
|
|
91
91
|
|
|
@@ -138,6 +138,22 @@ export function check(file, { draft = false } = {}) {
|
|
|
138
138
|
const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
|
|
139
139
|
if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
|
|
140
140
|
if (!cfg.voice && says) warnings.push('steps have `say:` lines but there is no `voice:` block, so they will not be spoken');
|
|
141
|
+
// fixed-length steps: a line that clearly can't be spoken before the next one starts (a rough
|
|
142
|
+
// estimate from its length; the render measures the real voice)
|
|
143
|
+
if (cfg.voice) {
|
|
144
|
+
const words = (t) => { const x = String(t).replace(/\*\*/g, ''); const c = (x.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length; return { c, w: x.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter(Boolean).length }; };
|
|
145
|
+
const secs = (t) => { const { c, w } = words(t); return (c / 4.5 + w / 2.8) / (cfg.voice.speed ?? 1); };
|
|
146
|
+
const vis = visible.map((s) => ({ s, i: cfg.steps.indexOf(s) }));
|
|
147
|
+
for (const [k, { s, i }] of vis.entries()) {
|
|
148
|
+
if (!s.say) continue;
|
|
149
|
+
const nextK = vis.findIndex((v, j) => j > k && v.s.say);
|
|
150
|
+
const span = vis.slice(k, nextK < 0 ? undefined : nextK).map((v) => v.s.duration);
|
|
151
|
+
if (!span.every((d) => typeof d === 'number')) continue;
|
|
152
|
+
const room = span.reduce((a, d) => a + d, 0) - (s.sayAt ?? 0.15) + (nextK < 0 && cfg.endCard ? 2.5 : 0);
|
|
153
|
+
const need = secs(s.say);
|
|
154
|
+
if (need > room * 1.4 && need - room > 1) warnings.push(`step ${i + 1}: the say: line takes about ${need.toFixed(1)}s to speak but has ${room.toFixed(1)}s before ${nextK < 0 ? 'the video ends' : 'the next line'} — shorten it or raise duration (the lines after it would play late)`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
141
157
|
if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
|
|
142
158
|
const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
|
|
143
159
|
if (!process.env[env]) errors.push(`voice provider ${cfg.voice.provider} needs ${env} (environment or a .env file next to the storyboard) — or render with draft mode to use the free local voice`);
|
package/src/compose.mjs
CHANGED
|
@@ -22,9 +22,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
22
22
|
const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
|
|
23
23
|
// `think: N` (question cards): after the question is read out, N seconds with a countdown bar
|
|
24
24
|
const cardLen = (c, voice, min) => (c.duration === 'auto' || c.duration == null ? Math.max(min, (voice ?? 0) + 0.9, readTime(c)) + (c.think ?? 0) : c.duration);
|
|
25
|
-
|
|
25
|
+
// the end card carries the call to action: long enough to read it (and for a command to type itself out)
|
|
26
|
+
const ctaTime = (c) => (c?.cta ? 0.8 + String(c.cta).length * 0.045 + 1.6 : 0);
|
|
27
|
+
const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
|
|
28
|
+
if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
|
|
26
29
|
const in0 = cfg.intro;
|
|
27
30
|
const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
|
|
31
|
+
if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
|
|
28
32
|
const cards = []; // section cards: { i, t0, t1, card }
|
|
29
33
|
|
|
30
34
|
// --- 1. segments (one per visible step), split into motion / waiting pieces ------------
|
|
@@ -150,6 +154,39 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
150
154
|
}
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
// --- 2c. dead air: stretches where nothing moves and nobody speaks --------------------------------
|
|
158
|
+
{
|
|
159
|
+
const tw = 160, th = 90, rate = 10;
|
|
160
|
+
const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
|
|
161
|
+
'-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
162
|
+
const n = Math.floor(buf.length / (tw * th)), still = [false];
|
|
163
|
+
for (let f = 1; f < n; f++) {
|
|
164
|
+
let changed = 0; // pixels that changed visibly: even the cursor moving changes a few
|
|
165
|
+
for (let k = 0, a = f * tw * th, b = a - tw * th; k < tw * th; k++) if (Math.abs(buf[a + k] - buf[b + k]) > 10) changed++;
|
|
166
|
+
still.push(changed < 4);
|
|
167
|
+
}
|
|
168
|
+
const spoken = [
|
|
169
|
+
...(in0 ? [[0, INTRO]] : []), // cards are their own pictures (the frames under them don't show)
|
|
170
|
+
...cards.map((c) => [c.t0, c.t1]),
|
|
171
|
+
...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + (g.step.sayAt ?? 0.15), g.o0 + (g.step.sayAt ?? 0.15) + voiceLens.steps[g.i]]),
|
|
172
|
+
];
|
|
173
|
+
let from = null;
|
|
174
|
+
const report = (to) => {
|
|
175
|
+
if (from != null && to - from >= 3) {
|
|
176
|
+
const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
|
|
177
|
+
warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? segs.at(-1).i + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
|
|
178
|
+
}
|
|
179
|
+
from = null;
|
|
180
|
+
};
|
|
181
|
+
for (let f = 0; f < n; f++) {
|
|
182
|
+
const t = f / rate;
|
|
183
|
+
const dead = still[f] && !spoken.some(([a, b]) => t >= a && t <= b);
|
|
184
|
+
if (dead && from == null) from = t;
|
|
185
|
+
else if (!dead) report(t);
|
|
186
|
+
}
|
|
187
|
+
report(n / rate);
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
// --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
|
|
154
191
|
const C0 = [W / 2, H / 2];
|
|
155
192
|
const kf = [[0, 1, C0]];
|
|
@@ -197,7 +234,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
197
234
|
const b = g.zoomBox, off = z.offset ?? {};
|
|
198
235
|
const c = [(b.x + b.width / 2 + (off.x ?? 0)) * K, (b.y + b.height / 2 + (off.y ?? 0)) * K];
|
|
199
236
|
const [rin, rout] = Array.isArray(z.ramp) ? z.ramp : [z.ramp ?? 0.5, z.ramp ?? 0.5];
|
|
200
|
-
|
|
237
|
+
// a zoom belongs to its step: an `end` past the step (e.g. after the narration grew it) stops there
|
|
238
|
+
if (z.end != null && g.o0 + z.end > g.o1 + 0.05) warn(`step ${g.i + 1}: zoom end ${z.end}s is past the step's end (${(g.o1 - g.o0).toFixed(1)}s), so it stops there — leave out end to zoom to the step's end`);
|
|
239
|
+
const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o1, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
|
|
201
240
|
const last = kf.at(-1)[0];
|
|
202
241
|
if (s < last) { warn(`zoom in step ${g.i + 1} overlaps the previous zoom, skipped`); continue; }
|
|
203
242
|
kf.push([s, 1, C0], [Math.min(s + rin, e), scale, c], [Math.max(s + rin, e - rout), scale, c], [e, 1, C0]);
|
|
@@ -235,6 +274,20 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
235
274
|
return { x: FX + (b.x * K - v.x0) * v.z * F, y: FY + (b.y * K - v.y0) * v.z * F, w: b.width * K * v.z * F, h: b.height * K * v.z * F };
|
|
236
275
|
};
|
|
237
276
|
|
|
277
|
+
// what the address bar shows over time: the page's URL when each visible moment was recorded
|
|
278
|
+
const addressOf = (u) => { try { const x = new URL(u); return x.host.replace(/^www\./, '') + x.pathname.replace(/\/$/, ''); } catch { return String(u); } };
|
|
279
|
+
const urlAt = (r) => (tl.urls ?? []).filter((x) => x.t <= r).at(-1)?.url ?? cfg.url;
|
|
280
|
+
const addresses = [];
|
|
281
|
+
for (const g of cfg.frame?.window === 'browser' ? segs : []) {
|
|
282
|
+
const r = tl.steps[g.i], changes = (tl.urls ?? []).filter((x) => x.t > r.start && x.t <= r.end);
|
|
283
|
+
for (const [t, u] of [[g.o0, urlAt(r.start)], ...changes.map((x) => [toOut(x.t) ?? g.o0, x.url])]) {
|
|
284
|
+
const text = addressOf(u), last = addresses.at(-1);
|
|
285
|
+
if (last?.text === text) continue;
|
|
286
|
+
if (last) last.t1 = t;
|
|
287
|
+
addresses.push({ text, t0: last ? t : 0, t1: TOTAL });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
238
291
|
// --- 4. captions + end card rendered as PNGs ----------------------------------------------
|
|
239
292
|
const { accent, ink, font } = cfg.theme;
|
|
240
293
|
const fontCss = `font-family:'${font}',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;`;
|
|
@@ -247,8 +300,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
247
300
|
await pg.screenshot({ path: file, omitBackground: transparent });
|
|
248
301
|
};
|
|
249
302
|
const fs1 = Math.round(H * 0.043), MARGIN = Math.round(H * 0.06);
|
|
303
|
+
// in a browser-window frame a top caption sits just inside the page, below the address bar
|
|
304
|
+
const TOP = cfg.frame ? FY + Math.round(H * 0.022) : MARGIN;
|
|
250
305
|
const bubble = (text, n, pos) => `<body style="margin:0;background:transparent">
|
|
251
|
-
<div style="position:absolute;left:0;right:0;${pos === 'top' ?
|
|
306
|
+
<div style="position:absolute;left:0;right:0;${pos === 'top' ? `top:${TOP}` : `bottom:${MARGIN}`}px;display:flex;justify-content:center;padding:0 5%">
|
|
252
307
|
<div id="cap" style="${fontCss}font-size:${fs1}px;font-weight:500;letter-spacing:-.01em;color:#fff;background:${ink}e0;text-align:center;
|
|
253
308
|
padding:${Math.round(fs1 * 0.43)}px ${Math.round(fs1 * 0.87)}px;border-radius:${fs1}px;box-shadow:0 12px 40px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.12)">
|
|
254
309
|
${cfg.stepNumbers ? `<span style="display:inline-flex;align-items:center;justify-content:center;width:1.45em;height:1.45em;margin:-.2em .55em -.2em -.35em;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:.85em;vertical-align:middle">${n}</span>` : ''}${md(text, accent)}</div></div></body>`;
|
|
@@ -273,7 +328,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
273
328
|
const n = prev && prev.text === g.step.caption && Math.abs(prev.t[1] - g.o0) < 1e-6 ? prev.n : captions.length + 1;
|
|
274
329
|
const explicit = g.step.captionPosition, want = explicit ?? cfg.captionPosition;
|
|
275
330
|
const { w, h } = await sizeOf(g.step.caption, n);
|
|
276
|
-
const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ?
|
|
331
|
+
const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? TOP : H - MARGIN - h, w, h });
|
|
277
332
|
const subjects = [];
|
|
278
333
|
for (const m of tl.steps[g.i].marks ?? []) {
|
|
279
334
|
const t = toOut(m.t);
|
|
@@ -297,7 +352,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
297
352
|
if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
|
|
298
353
|
else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
|
|
299
354
|
}
|
|
300
|
-
for (const [i, c] of captions.entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
|
|
355
|
+
for (const [i, c] of (cfg.captions ? captions : []).entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
|
|
301
356
|
// Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
|
|
302
357
|
// CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
|
|
303
358
|
const u = H / 1080;
|
|
@@ -313,7 +368,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
313
368
|
const image = c.imageFile && fs.existsSync(c.imageFile)
|
|
314
369
|
? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
|
|
315
370
|
const think = c.think ? `<div class="a rise" style="animation-delay:${thinkAt.toFixed(2)}s;position:absolute;left:50%;bottom:${H * 0.1}px;transform:translateX(-50%);width:${W * 0.3}px;text-align:center;font-size:${26 * H / 1080}px;color:${CK.muted}">
|
|
316
|
-
<div style="margin-bottom:${12 * H / 1080}px">${cfg.
|
|
371
|
+
<div style="margin-bottom:${12 * H / 1080}px">${cfg.labels.think}</div>
|
|
317
372
|
<div style="height:${8 * H / 1080}px;border-radius:99px;background:${CK.track};overflow:hidden"><div style="height:100%;background:${accent};transform-origin:left;animation:drain ${c.think}s linear ${thinkAt.toFixed(2)}s both"></div></div></div>
|
|
318
373
|
<style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
|
|
319
374
|
const logo = fs.existsSync(logoPath)
|
|
@@ -379,7 +434,16 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
379
434
|
blur: { dark: true, bg: null },
|
|
380
435
|
};
|
|
381
436
|
const st = STYLES[cfg.frame.style], bg = cfg.frame.background ?? st.bg;
|
|
382
|
-
|
|
437
|
+
// an app window: the app's icon and name in the title bar instead of an address bar
|
|
438
|
+
let app = null;
|
|
439
|
+
if (cfg.frame.window === 'app') {
|
|
440
|
+
const MIME = { svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', png: 'image/png' };
|
|
441
|
+
const [file, type] = cfg.frame.iconFile ? [cfg.frame.iconFile, MIME[path.extname(cfg.frame.iconFile).slice(1).toLowerCase()] ?? 'image/png']
|
|
442
|
+
: tl.app?.icon ? [path.join(cfg.work, tl.app.icon.file), tl.app.icon.type] : [];
|
|
443
|
+
const icon = file && fs.existsSync(file) ? `data:${type};base64,${fs.readFileSync(file).toString('base64')}` : null;
|
|
444
|
+
app = { icon, title: cfg.frame.title ?? tl.app?.title ?? (() => { try { return new URL(cfg.url).host.replace(/^www\./, ''); } catch { return ''; } })() };
|
|
445
|
+
}
|
|
446
|
+
const R = Math.round(14 * u);
|
|
383
447
|
const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
|
|
384
448
|
const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
|
|
385
449
|
if (bg) await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg}"></body>`, path.join(cfg.work, 'frame-bg.png'), false);
|
|
@@ -387,8 +451,16 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
387
451
|
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${FH + BAR}px;border-radius:${R}px;background:#fff;box-shadow:0 ${30 * u}px ${80 * u}px rgba(0,0,0,${st.dark ? '.5' : '.25'}),0 0 0 1px rgba(255,255,255,.08)"></div>
|
|
388
452
|
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;border-radius:${R}px ${R}px 0 0;background:${barBg};display:flex;align-items:center;border-bottom:1px solid ${line}">
|
|
389
453
|
<div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
|
|
390
|
-
|
|
454
|
+
${app ? `<div style="position:absolute;left:0;right:0;top:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:${8 * u}px;font-size:${15 * u}px;font-weight:600;color:${barFg}">
|
|
455
|
+
${app.icon ? `<img src="${app.icon}" style="width:${18 * u}px;height:${18 * u}px;object-fit:contain;border-radius:${4 * u}px">` : ''}<span style="max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(app.title)}</span></div>` : ''}
|
|
391
456
|
</div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
|
|
457
|
+
// the address bar follows the page: one pill per address it shows
|
|
458
|
+
for (const [k, a] of addresses.entries()) {
|
|
459
|
+
await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
|
|
460
|
+
<div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;display:flex;align-items:center;justify-content:center">
|
|
461
|
+
<div style="font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px;max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.text)}</div>
|
|
462
|
+
</div></body>`, path.join(cfg.work, `frame-url${k}.png`), true);
|
|
463
|
+
}
|
|
392
464
|
// alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
|
|
393
465
|
await pg.setViewportSize({ width: FW, height: FH });
|
|
394
466
|
await pg.setContent(`<body style="margin:0;width:${FW}px;height:${FH}px;background:#000"><div style="width:100%;height:100%;background:#fff;border-radius:0 0 ${R}px ${R}px"></div></body>`);
|
|
@@ -416,12 +488,17 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
416
488
|
graph += `[${blur ? 'v0a' : 'v0'}]scale=${FW}:${FH}:flags=lanczos,format=rgba[pg0];[${mask}:v]format=gray,scale=${FW}:${FH}[pm];[pg0][pm]alphamerge[pg];\n` +
|
|
417
489
|
`[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
|
|
418
490
|
last = 'vf';
|
|
491
|
+
for (const [k, a] of addresses.entries()) {
|
|
492
|
+
inputs.push('-loop', '1', '-t', `${(a.t1 - a.t0 + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `frame-url${k}.png`));
|
|
493
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.t0.toFixed(3)}/TB[u${k}];[${last}][u${k}]overlay=0:0:eof_action=pass:enable='between(t,${a.t0.toFixed(3)},${a.t1.toFixed(3)})'[vu${k}];\n`;
|
|
494
|
+
last = `vu${k}`; idx++;
|
|
495
|
+
}
|
|
419
496
|
}
|
|
420
497
|
// docs: a second, caption-free copy for the per-step clips and stills (the article carries the
|
|
421
498
|
// text), so crops never have to dodge a caption
|
|
422
499
|
const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
|
|
423
500
|
if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
|
|
424
|
-
for (const [i, c] of captions.entries()) {
|
|
501
|
+
for (const [i, c] of (cfg.captions ? captions : []).entries()) {
|
|
425
502
|
const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
|
|
426
503
|
// each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
|
|
427
504
|
// video, once per caption, made long tutorials take minutes to compose)
|
|
@@ -432,7 +509,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
432
509
|
}
|
|
433
510
|
for (const [k, c] of cards.entries()) {
|
|
434
511
|
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
|
|
435
|
-
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.
|
|
512
|
+
graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
|
|
436
513
|
`[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
|
|
437
514
|
last = `v${idx}`;
|
|
438
515
|
}
|
|
@@ -445,7 +522,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
445
522
|
}
|
|
446
523
|
if (in0) {
|
|
447
524
|
inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'intro', '%04d.jpg'));
|
|
448
|
-
graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.
|
|
525
|
+
graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.2).toFixed(3)}:d=0.2:alpha=1[in];\n` +
|
|
449
526
|
`[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
|
|
450
527
|
last = `v${idx}`;
|
|
451
528
|
}
|
|
@@ -497,8 +574,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
497
574
|
...(tl.gotos ?? tl.navs.filter((n) => !tl.clicks.some((c) => n - c >= 0 && n - c < 2) && !tl.steps.some((s) => (s.waits ?? []).some(([a, b]) => n >= a - 0.5 && n <= b + 0.5))))
|
|
498
575
|
.map(toOut).filter((t) => t != null && t > 0.2),
|
|
499
576
|
...segs.filter((g) => g.afterHidden).map((g) => g.o0),
|
|
500
|
-
...(in0 ? [INTRO - 0.
|
|
501
|
-
...cards.flatMap((c) => [c.t0, c.t1 - 0.
|
|
577
|
+
...(in0 ? [INTRO - 0.2] : []),
|
|
578
|
+
...cards.flatMap((c) => [c.t0, c.t1 - 0.15]),
|
|
502
579
|
...(ec ? [VIDEO - 0.25] : []),
|
|
503
580
|
]),
|
|
504
581
|
risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
|
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,17 @@ import path from 'node:path';
|
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { VOICE_DEFAULTS } from './voice.mjs';
|
|
6
6
|
|
|
7
|
+
// Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
|
|
8
|
+
export const LABELS = {
|
|
9
|
+
en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
|
|
10
|
+
zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
|
|
11
|
+
es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
|
|
12
|
+
pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
|
|
13
|
+
fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
|
|
14
|
+
de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
|
|
15
|
+
ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
|
|
16
|
+
};
|
|
17
|
+
|
|
7
18
|
// Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
|
|
8
19
|
// and in the working directory; variables already set in the environment win.
|
|
9
20
|
export function loadEnv(dir) {
|
|
@@ -99,7 +110,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
99
110
|
});
|
|
100
111
|
|
|
101
112
|
let endCard = raw.end_card === false ? null : raw.end_card ?? null;
|
|
102
|
-
|
|
113
|
+
// auto: as long as its narration, bullets and call to action need, and never under 2.5s
|
|
114
|
+
if (endCard) endCard = { duration: 'auto', ...endCard };
|
|
103
115
|
// An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
|
|
104
116
|
let intro = raw.intro === false ? null : raw.intro ?? null;
|
|
105
117
|
if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
|
|
@@ -112,11 +124,15 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
112
124
|
// follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
|
|
113
125
|
const follow0 = pick('follow');
|
|
114
126
|
const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
|
|
115
|
-
// frame: false | true | a style name | { style, background: <css
|
|
116
|
-
// studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window)
|
|
127
|
+
// frame: false | true | a style name | { style, background: <css>, window, title, icon }. Styles: aurora
|
|
128
|
+
// (dark, grain), studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window).
|
|
129
|
+
// window: browser (an address bar) or app (an installed web app / desktop app: its icon and name in the
|
|
130
|
+
// title bar, no address bar; both are read from the page's web app manifest unless given)
|
|
117
131
|
const styleOf = (f) => (typeof f === 'string' ? f : f?.style) ?? (typeof P.frame === 'string' ? P.frame : 'aurora');
|
|
118
|
-
const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
|
|
132
|
+
const frame = frame0 ? { background: null, window: 'browser', ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
|
|
119
133
|
if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
|
|
134
|
+
if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
|
|
135
|
+
if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
|
|
120
136
|
|
|
121
137
|
let voice = null;
|
|
122
138
|
if (raw.voice) {
|
|
@@ -162,9 +178,13 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
162
178
|
endCard,
|
|
163
179
|
intro,
|
|
164
180
|
article: raw.article ?? null,
|
|
181
|
+
// captions: false keeps the caption pills off the video (chapters and SRT still use them),
|
|
182
|
+
// e.g. when burned-in subtitles already carry the words
|
|
183
|
+
captions: raw.captions !== false,
|
|
165
184
|
cards: raw.cards ?? 'auto',
|
|
166
185
|
// language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
|
|
167
186
|
lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
|
|
187
|
+
labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
|
|
168
188
|
frame,
|
|
169
189
|
follow,
|
|
170
190
|
music,
|
package/src/doctor.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
|
|
8
9
|
|
|
9
10
|
const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
10
11
|
|
|
@@ -47,7 +48,15 @@ export async function doctor({ log = console.log } = {}) {
|
|
|
47
48
|
|
|
48
49
|
try {
|
|
49
50
|
const v = execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version (\S+)/)?.[1];
|
|
50
|
-
|
|
51
|
+
// a build can lack pieces Storyshot uses: say which, and what that affects
|
|
52
|
+
const missing = [...NEEDED.filters.filter((f) => !hasFilter(f)), ...NEEDED.encoders.filter((e) => !hasEncoder(e))];
|
|
53
|
+
if (missing.length) {
|
|
54
|
+
lines.push(`✖ ffmpeg ${v ?? ''} is missing ${missing.join(', ')}`);
|
|
55
|
+
todo.push(`install an ffmpeg build with ${missing.join(', ')} (${process.platform === 'darwin' ? 'e.g. brew install ffmpeg-full, then put "$(brew --prefix ffmpeg-full)/bin" first on your PATH, as it is keg-only; or a static build from https://ffmpeg.org/download.html' : 'e.g. a static build from https://ffmpeg.org/download.html'})`);
|
|
56
|
+
} else lines.push(`✔ ffmpeg ${v ?? ''}`.trim());
|
|
57
|
+
for (const o of OPTIONAL) {
|
|
58
|
+
if (o.filter ? !hasFilter(o.filter) : !hasEncoder(o.encoder)) lines.push(`· ffmpeg has no ${o.filter ? `${o.filter} filter` : `${o.encoder} encoder`} — ${o.for}`);
|
|
59
|
+
}
|
|
51
60
|
} catch {
|
|
52
61
|
lines.push('✖ ffmpeg not found');
|
|
53
62
|
todo.push(ffmpegInstallCommand());
|
package/src/ffmpeg.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// What the installed ffmpeg can do: builds differ (Homebrew's default ffmpeg has no libass, so no
|
|
2
|
+
// `ass` filter for burned-in subtitles).
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const cache = {};
|
|
6
|
+
const list = (kind) => {
|
|
7
|
+
if (!cache[kind]) {
|
|
8
|
+
try {
|
|
9
|
+
const out = execFileSync('ffmpeg', ['-hide_banner', `-${kind}`], { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
10
|
+
// " .. zoompan V->V ..." (filters) or " V....D libx264 ..." (encoders): the name is the second column
|
|
11
|
+
cache[kind] = new Set(out.split('\n').map((l) => l.trim().split(/\s+/)[1]).filter(Boolean));
|
|
12
|
+
} catch { cache[kind] = new Set(); }
|
|
13
|
+
}
|
|
14
|
+
return cache[kind];
|
|
15
|
+
};
|
|
16
|
+
export const hasFilter = (name) => list('filters').has(name);
|
|
17
|
+
export const hasEncoder = (name) => list('encoders').has(name);
|
|
18
|
+
|
|
19
|
+
// what Storyshot needs from ffmpeg, and what each optional piece is for
|
|
20
|
+
export const NEEDED = {
|
|
21
|
+
filters: ['zoompan', 'overlay', 'alphamerge', 'gblur', 'fade', 'tpad', 'loudnorm', 'alimiter', 'aresample', 'atempo', 'silenceremove', 'areverse', 'palettegen', 'paletteuse'],
|
|
22
|
+
encoders: ['libx264', 'aac'],
|
|
23
|
+
};
|
|
24
|
+
export const OPTIONAL = [
|
|
25
|
+
{ filter: 'ass', for: '`subtitles: burn` uses its own renderer instead (the word highlight steps word by word rather than sweeping)' },
|
|
26
|
+
{ encoder: 'libvpx-vp9', for: 'webm clips (clips: webm)' },
|
|
27
|
+
];
|
package/src/inspect.mjs
CHANGED
|
@@ -67,10 +67,12 @@ function collect() {
|
|
|
67
67
|
clean(el.closest('label')?.innerText) || '';
|
|
68
68
|
// a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
|
|
69
69
|
const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
|
|
70
|
+
// styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
|
|
71
|
+
const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
|
|
70
72
|
const interactive = [...document.querySelectorAll(NATIVE)]
|
|
71
|
-
.filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
73
|
+
.filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
72
74
|
.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]),
|
|
73
|
-
options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(el) }));
|
|
75
|
+
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) }));
|
|
74
76
|
// custom clickables: outermost cursor:pointer elements that aren't native controls
|
|
75
77
|
const pointer = [];
|
|
76
78
|
for (const el of document.querySelectorAll('body *')) {
|
package/src/outputs.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import { hasFilter } from './ffmpeg.mjs';
|
|
5
7
|
|
|
6
8
|
const plain = (s) => String(s).replace(/\*\*/g, '');
|
|
7
9
|
const srtTime = (t) => {
|
|
@@ -27,7 +29,7 @@ export async function writeOutputs(cfg, { captions, cards = [], segs, clean, nar
|
|
|
27
29
|
if (cfg.chapters && (captions.length || cards.length)) {
|
|
28
30
|
// section cards make the chapters when there are any; otherwise one chapter per caption
|
|
29
31
|
const lines = cards.length
|
|
30
|
-
? [...(cards[0].t > 1 ? [`0:00 ${cfg.
|
|
32
|
+
? [...(cards[0].t > 1 ? [`0:00 ${cfg.labels.intro}`] : []), ...cards.map((c, k) => `${clock(k === 0 && cards[0].t <= 1 ? 0 : c.t)} ${plain(c.title)}`)]
|
|
31
33
|
: captions.map((c, k) => `${clock(k === 0 ? 0 : c.t[0])} ${cfg.stepNumbers ? `${c.n}. ` : ''}${plain(c.text)}`);
|
|
32
34
|
fs.writeFileSync(`${base}.chapters.txt`, `${lines.join('\n')}\n`);
|
|
33
35
|
files.push(`${base}.chapters.txt`);
|
|
@@ -80,15 +82,22 @@ export async function writeOutputs(cfg, { captions, cards = [], segs, clean, nar
|
|
|
80
82
|
|
|
81
83
|
// `subtitles: burn`: the narration burned into the video, a few words at a time, each word lighting
|
|
82
84
|
// up as it is spoken (karaoke). Word timing is estimated from word length within each spoken line.
|
|
83
|
-
export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
|
|
85
|
+
export async function burnSubtitles(cfg, narration, { log = console.log, renderer = hasFilter('ass') ? 'ass' : 'png' } = {}) {
|
|
84
86
|
if (!narration.length) return log('note: subtitles: burn needs narration (say: lines and a voice); nothing burned in');
|
|
85
87
|
const { width: W, height: H } = cfg.output;
|
|
86
88
|
const bgr = (hex, a = '00') => { const h = hex.replace('#', ''); return `&H${a}${h.slice(4, 6)}${h.slice(2, 4)}${h.slice(0, 2)}`.toUpperCase(); };
|
|
87
89
|
const t = (s) => { const cs = Math.round(s * 100); return `${Math.floor(cs / 360000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, '0')}:${String(Math.floor(cs / 100) % 60).padStart(2, '0')}.${String(cs % 100).padStart(2, '0')}`; };
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
// Chinese, Japanese, Korean: no spaces between words, so lines are cut by characters (one line of
|
|
91
|
+
// at most ~18), in a lighter weight and a font that has the glyphs
|
|
92
|
+
const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
|
|
93
|
+
const size = Math.round(H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
|
|
94
|
+
const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
|
|
95
|
+
const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
|
|
90
96
|
for (const line of narration) {
|
|
91
|
-
|
|
97
|
+
// CJK: one token per character (Latin words inside stay whole), punctuation stays with the one before
|
|
98
|
+
const words = cjk
|
|
99
|
+
? (plain(line.text).match(/[A-Za-z0-9.%+-]+[,。!?;:、,.!?;:))」』]*|\S[,。!?;:、,.!?;:))」』]*/g) ?? [])
|
|
100
|
+
: plain(line.text).split(/\s+/).filter(Boolean);
|
|
92
101
|
const weight = (w) => w.replace(/[^\p{L}\p{N}]/gu, '').length + 2;
|
|
93
102
|
const total = words.reduce((a, w) => a + weight(w), 0), span = line.end - line.start;
|
|
94
103
|
let at = line.start;
|
|
@@ -97,13 +106,76 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
|
|
|
97
106
|
let chunk = [];
|
|
98
107
|
const flush = () => {
|
|
99
108
|
if (!chunk.length) return;
|
|
100
|
-
|
|
101
|
-
events.push(`Dialogue: 0,${t(s0)},${t(e0 + 0.05)},Sub,,0,0,0,,${chunk.map((x) => `{\\kf${Math.round(x.d * 100)}}${x.w.replace(/[{}]/g, '')}`).join(' ')}`);
|
|
109
|
+
cues.push({ words: chunk });
|
|
102
110
|
chunk = [];
|
|
103
111
|
};
|
|
104
|
-
|
|
112
|
+
const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
|
|
113
|
+
for (const x of timed) {
|
|
114
|
+
// a CJK line ends at a sentence break, or at a comma once it is half full, or at 18 characters
|
|
115
|
+
if (cjk && chunk.length && len() + x.w.length > 18) flush();
|
|
116
|
+
chunk.push(x);
|
|
117
|
+
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >= 9) : chunk.length >= 7 || /[.!?;:]$/.test(x.w)) flush();
|
|
118
|
+
}
|
|
105
119
|
flush();
|
|
106
120
|
}
|
|
121
|
+
const tmp = cfg.out.replace(/(\.[^.]+)$/, '.burn$1');
|
|
122
|
+
const encode = ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'copy', '-movflags', '+faststart', tmp];
|
|
123
|
+
const edgeCol = cfg.theme.ink, sweep = cfg.theme.accent;
|
|
124
|
+
if (renderer === 'png') {
|
|
125
|
+
// no libass in this ffmpeg: draw each state of each line (the words spoken so far in the accent
|
|
126
|
+
// colour) in Chromium, string them into one transparent track and overlay it
|
|
127
|
+
const dir = path.join(cfg.work, 'subs');
|
|
128
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
const esc = (x) => x.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
131
|
+
const font = `'${fontName}',${cjk ? "'PingFang SC','Hiragino Sans GB','Noto Sans CJK SC','Microsoft YaHei'," : ''}-apple-system,'Segoe UI','Helvetica Neue',sans-serif`;
|
|
132
|
+
const head = cjk ? '' : `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;700&display=block">`;
|
|
133
|
+
const browser = await chromium.launch();
|
|
134
|
+
const pg = await browser.newPage({ viewport: { width: W, height: H } });
|
|
135
|
+
const shot = async (html, file) => {
|
|
136
|
+
await pg.setContent(`<html><head>${head}</head><body style="margin:0;background:transparent">${html}</body></html>`, { waitUntil: 'networkidle' }).catch(() => {});
|
|
137
|
+
await pg.evaluate(() => document.fonts.ready);
|
|
138
|
+
await pg.screenshot({ path: file, omitBackground: true });
|
|
139
|
+
};
|
|
140
|
+
const list = ['ffconcat version 1.0'];
|
|
141
|
+
let at = 0, n = 0;
|
|
142
|
+
const push = (file, until) => { if (until - at > 0.001) { list.push(`file '${file}'`, `duration ${(until - at).toFixed(3)}`); at = until; } };
|
|
143
|
+
await shot('', path.join(dir, 'blank.png'));
|
|
144
|
+
// libass sizes a font by its line height (ascent + descent), CSS by its em: match libass
|
|
145
|
+
await pg.setContent(`<html><head>${head}</head><body></body></html>`, { waitUntil: 'networkidle' }).catch(() => {});
|
|
146
|
+
const em = await pg.evaluate(async (font) => {
|
|
147
|
+
await document.fonts.load(`100px ${font}`);
|
|
148
|
+
const m = document.createElement('canvas').getContext('2d');
|
|
149
|
+
m.font = `100px ${font}`;
|
|
150
|
+
const r = m.measureText('Hg中');
|
|
151
|
+
return 100 / ((r.fontBoundingBoxAscent + r.fontBoundingBoxDescent) || 100);
|
|
152
|
+
}, font);
|
|
153
|
+
const px = Math.round(size * em);
|
|
154
|
+
for (const cue of cues) {
|
|
155
|
+
const end = cue.words.at(-1).start + cue.words.at(-1).d + 0.05;
|
|
156
|
+
if (cue.words[0].start > at) push('blank.png', cue.words[0].start);
|
|
157
|
+
for (const [j, x] of cue.words.entries()) {
|
|
158
|
+
const file = `s${String(n++).padStart(5, '0')}.png`;
|
|
159
|
+
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:${Math.round(H * 0.06)}px;text-align:center;
|
|
161
|
+
font-family:${font};font-size:${px}px;font-weight:${cjk ? 400 : 700};line-height:1.2;
|
|
162
|
+
-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
|
+
push(file, j + 1 < cue.words.length ? cue.words[j + 1].start : end);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await browser.close();
|
|
167
|
+
push('blank.png', at + 1);
|
|
168
|
+
list.push("file 'blank.png'"); // the last entry's duration only counts when a file follows it
|
|
169
|
+
fs.writeFileSync(path.join(dir, 'list.txt'), list.join('\n') + '\n');
|
|
170
|
+
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-f', 'concat', '-safe', '0', '-i', path.join(dir, 'list.txt'),
|
|
171
|
+
'-filter_complex', '[1:v]format=rgba[s];[0:v][s]overlay=0:0:eof_action=pass,format=yuv420p[v]', '-map', '[v]', '-map', '0:a?', ...encode]);
|
|
172
|
+
fs.renameSync(tmp, cfg.out);
|
|
173
|
+
return log(`subtitles: burned in ${cues.length} lines with word-by-word highlight (drawn by Storyshot: this ffmpeg has no libass)`);
|
|
174
|
+
}
|
|
175
|
+
const events = cues.map(({ words }) => {
|
|
176
|
+
const s0 = words[0].start, e0 = words.at(-1).start + words.at(-1).d;
|
|
177
|
+
return `Dialogue: 0,${t(s0)},${t(e0 + 0.05)},Sub,,0,0,0,,${words.map((x) => `{\\kf${Math.round(x.d * 100)}}${x.w.replace(/[{}]/g, '')}`).join(cjk ? '' : ' ')}`;
|
|
178
|
+
});
|
|
107
179
|
const ass = `[Script Info]
|
|
108
180
|
ScriptType: v4.00+
|
|
109
181
|
PlayResX: ${W}
|
|
@@ -112,17 +184,17 @@ WrapStyle: 0
|
|
|
112
184
|
|
|
113
185
|
[V4+ Styles]
|
|
114
186
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
115
|
-
Style: Sub,${
|
|
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)},${Math.round(H * 0.06)},1
|
|
116
188
|
|
|
117
189
|
[Events]
|
|
118
190
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
119
191
|
${events.join('\n')}
|
|
120
192
|
`;
|
|
121
|
-
const file = path.join(cfg.work, 'subtitles.ass')
|
|
193
|
+
const file = path.join(cfg.work, 'subtitles.ass');
|
|
122
194
|
fs.writeFileSync(file, ass);
|
|
123
195
|
// the filter argument is parsed by ffmpeg: escape the characters it treats specially in paths
|
|
124
196
|
const arg = file.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
125
|
-
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`,
|
|
197
|
+
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`, ...encode]);
|
|
126
198
|
fs.renameSync(tmp, cfg.out);
|
|
127
199
|
log(`subtitles: burned in ${events.length} lines with word-by-word highlight`);
|
|
128
200
|
}
|
package/src/record.mjs
CHANGED
|
@@ -260,13 +260,13 @@ function pageScript({ scrollOffset }) {
|
|
|
260
260
|
window.__demoMenuHover = (i) => { const m = document.getElementById('__demo_menu'); if (m) [...m.children].forEach((o, k) => { o.style.background = k === i ? '#e0ecff' : ''; }); };
|
|
261
261
|
window.__demoMenuClose = () => document.getElementById('__demo_menu')?.remove();
|
|
262
262
|
// downloads leave no trace in a recording: show a browser-style "downloaded" bubble instead
|
|
263
|
-
window.__demoDownload = (name) => {
|
|
263
|
+
window.__demoDownload = (name, label = 'Downloaded') => {
|
|
264
264
|
const el = document.createElement('div');
|
|
265
|
-
el.innerHTML = `<span style="display:inline-grid;place-items:center;width:30px;height:30px;border-radius:50%;background:#22c55e;color:#fff;font-weight:700">✓</span><span><b style="display:block;font-weight:600">${name.replace(/[<&]/g, '')}</b><span style="color:#64748b;font-size:14px"
|
|
265
|
+
el.innerHTML = `<span style="display:inline-grid;place-items:center;width:30px;height:30px;border-radius:50%;background:#22c55e;color:#fff;font-weight:700">✓</span><span><b style="display:block;font-weight:600">${name.replace(/[<&]/g, '')}</b><span style="color:#64748b;font-size:14px">${label}</span></span>`;
|
|
266
266
|
Object.assign(el.style, { position: 'fixed', right: '24px', top: '20px', zIndex: '2147483646', pointerEvents: 'none', display: 'flex', alignItems: 'center', gap: '12px',
|
|
267
267
|
background: '#fff', color: '#0f172a', font: '16px -apple-system, "Segoe UI", sans-serif', padding: '12px 18px 12px 14px', borderRadius: '12px', boxShadow: '0 12px 32px rgba(15,23,42,.25)', border: '1px solid #e2e8f0' });
|
|
268
268
|
document.documentElement.appendChild(el);
|
|
269
|
-
el.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none', offset: 0.1 }, { opacity: 1, offset: 0.
|
|
269
|
+
el.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none', offset: 0.1 }, { opacity: 1, offset: 0.9 }, { opacity: 0 }], { duration: 4500, easing: 'ease-out' }).onfinish = () => el.remove();
|
|
270
270
|
};
|
|
271
271
|
window.__demoUnnote = (instant) => {
|
|
272
272
|
// detach it first, so a note or mark added during the fade gets a fresh layer instead of vanishing with this one
|
|
@@ -343,7 +343,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
343
343
|
await context.addInitScript(pageScript, { scrollOffset: cfg.scrollOffset });
|
|
344
344
|
const page = await context.newPage();
|
|
345
345
|
page.on('download', (d) => {
|
|
346
|
-
page.evaluate((n) => window.__demoDownload?.(n), d.suggestedFilename()).catch(() => {});
|
|
346
|
+
page.evaluate(([n, l]) => window.__demoDownload?.(n, l), [d.suggestedFilename(), cfg.labels.downloaded]).catch(() => {});
|
|
347
347
|
marks.push({ t: Date.now() / 1000, box: { x: cfg.viewport.width - 360, y: 10, width: 340, height: 80 } }); // so docs crops include the bubble
|
|
348
348
|
});
|
|
349
349
|
const sleep = (ms) => page.waitForTimeout(ms);
|
|
@@ -551,13 +551,18 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
551
551
|
await press();
|
|
552
552
|
}
|
|
553
553
|
const text = String(v.text);
|
|
554
|
-
|
|
555
|
-
|
|
554
|
+
// the paste worked if the page received a paste event carrying the text (code editors keep their
|
|
555
|
+
// text outside the focused element, so comparing its value would miss it and paste twice)
|
|
556
|
+
const copied = await page.evaluate((t) => navigator.clipboard.writeText(t).then(() => {
|
|
557
|
+
window.__demoPasted = false;
|
|
558
|
+
document.addEventListener('paste', (e) => { window.__demoPasted = !!e.clipboardData?.getData('text'); }, { capture: true, once: true });
|
|
559
|
+
return true;
|
|
560
|
+
}, () => false), text).catch(() => false);
|
|
556
561
|
if (!fast) await page.evaluate((l) => window.__demoKeys(l), process.platform === 'darwin' ? '⌘ V' : 'Ctrl V').catch(() => {});
|
|
557
562
|
if (copied) await page.keyboard.press('ControlOrMeta+V');
|
|
558
563
|
await sleep(200);
|
|
559
|
-
const
|
|
560
|
-
if (!
|
|
564
|
+
const pasted = copied && await page.evaluate(() => window.__demoPasted === true).catch(() => false);
|
|
565
|
+
if (!pasted) await page.keyboard.insertText(text); // no clipboard access: insert it in one go
|
|
561
566
|
if (lit) await spotOff();
|
|
562
567
|
await settle();
|
|
563
568
|
await beat();
|
|
@@ -572,8 +577,16 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
572
577
|
const from = await locate(page, value.target).evaluate((el) => {
|
|
573
578
|
const r = el.type === 'range' ? el : el.querySelector?.('input[type=range]');
|
|
574
579
|
if (r) return (Number(r.value) - Number(r.min || 0)) / ((Number(r.max || 100) - Number(r.min || 0)) || 1);
|
|
575
|
-
const
|
|
576
|
-
|
|
580
|
+
const aria = el.hasAttribute('aria-valuenow') ? el : el.querySelector?.('[aria-valuenow]');
|
|
581
|
+
if (aria) {
|
|
582
|
+
const now = Number(aria.getAttribute('aria-valuenow')), lo = Number(aria.getAttribute('aria-valuemin') ?? 0), hi = Number(aria.getAttribute('aria-valuemax') ?? 100);
|
|
583
|
+
if (Number.isFinite(now)) return (now - lo) / ((hi - lo) || 1);
|
|
584
|
+
}
|
|
585
|
+
// custom sliders (e.g. Desmos): start where the knob is drawn, so the value doesn't jump first
|
|
586
|
+
const t = el.getBoundingClientRect();
|
|
587
|
+
const knob = [...el.querySelectorAll('[role=slider], [class*=thumb], [class*=handle], [class*=knob]')]
|
|
588
|
+
.map((k) => k.getBoundingClientRect()).find((k) => k.width > 0 && k.width < t.width * 0.5);
|
|
589
|
+
return knob ? (knob.x + knob.width / 2 - t.x) / (t.width || 1) : 0.5;
|
|
577
590
|
}).catch(() => 0.5);
|
|
578
591
|
const x0 = b.x + b.width * from, x1 = b.x + b.width * Math.max(0, Math.min(1, value.to)), y = b.y + b.height / 2;
|
|
579
592
|
await moveTo(x0, y, value.ms ?? cfg.pace.cursor);
|
|
@@ -706,6 +719,36 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
706
719
|
await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
|
|
707
720
|
.catch((e) => log(`warning: could not capture logo (${e.message.split('\n')[0]})`));
|
|
708
721
|
}
|
|
722
|
+
// an app window shows the app's icon and name: from the web app manifest, else the page's own
|
|
723
|
+
let app = null;
|
|
724
|
+
if (cfg.frame?.window === 'app' && !fast) {
|
|
725
|
+
const found = await page.evaluate(async () => {
|
|
726
|
+
const abs = (u, base) => { try { return new URL(u, base).href; } catch { return null; } };
|
|
727
|
+
const size = (s) => Math.max(0, ...String(s ?? '').split(/\s+/).map((x) => (x === 'any' ? 1e4 : parseInt(x, 10) || 0)));
|
|
728
|
+
let name = null, icon = null;
|
|
729
|
+
const link = document.querySelector('link[rel="manifest"]');
|
|
730
|
+
if (link) {
|
|
731
|
+
try {
|
|
732
|
+
const m = await (await fetch(link.href)).json();
|
|
733
|
+
name = m.short_name ?? m.name ?? null;
|
|
734
|
+
const best = (m.icons ?? []).filter((i) => !/monochrome/.test(i.purpose ?? '')).sort((a, b) => size(b.sizes) - size(a.sizes))[0];
|
|
735
|
+
if (best) icon = abs(best.src, link.href);
|
|
736
|
+
} catch {}
|
|
737
|
+
}
|
|
738
|
+
const links = [...document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')].sort((a, b) => size(b.sizes) - size(a.sizes));
|
|
739
|
+
icon ??= links[0]?.href ?? abs('/favicon.ico', location.href);
|
|
740
|
+
return { name: name ?? (document.querySelector('meta[name="application-name"]')?.content || document.title || null), icon };
|
|
741
|
+
}).catch(() => ({}));
|
|
742
|
+
app = { title: found.name ?? null, icon: null };
|
|
743
|
+
if (!cfg.frame.iconFile && found.icon) {
|
|
744
|
+
const r = await page.request.get(found.icon).catch(() => null);
|
|
745
|
+
if (r?.ok()) {
|
|
746
|
+
const type = (r.headers()['content-type'] ?? 'image/png').split(';')[0];
|
|
747
|
+
fs.writeFileSync(path.join(cfg.work, 'app-icon'), await r.body());
|
|
748
|
+
app.icon = { file: 'app-icon', type };
|
|
749
|
+
} else log(`warning: could not fetch the app icon (${found.icon})`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
709
752
|
await nudge();
|
|
710
753
|
await sleep(600);
|
|
711
754
|
|
|
@@ -734,8 +777,12 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
734
777
|
frames.push({ file, t: metadata.timestamp });
|
|
735
778
|
cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
736
779
|
});
|
|
737
|
-
const navs = [];
|
|
738
|
-
page.on('framenavigated', (f) => {
|
|
780
|
+
const navs = [], urls = [{ t: now(), url: page.url() }]; // urls: what the frame's address bar shows
|
|
781
|
+
page.on('framenavigated', (f) => {
|
|
782
|
+
if (f !== page.mainFrame()) return;
|
|
783
|
+
navs.push(now());
|
|
784
|
+
if (f.url() !== urls.at(-1).url) urls.push({ t: now(), url: f.url() });
|
|
785
|
+
});
|
|
739
786
|
await cdp.send('Page.startScreencast', {
|
|
740
787
|
format: 'jpeg', quality: 92, maxWidth: cfg.output.width, maxHeight: cfg.output.height, everyNthFrame: 1,
|
|
741
788
|
});
|
|
@@ -777,7 +824,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
777
824
|
await cdp.send('Page.stopScreencast');
|
|
778
825
|
await browser.close();
|
|
779
826
|
|
|
780
|
-
const timeline = { frames, steps, clicks, navs, gotos, scale: cfg.scale };
|
|
827
|
+
const timeline = { frames, steps, clicks, navs, gotos, urls, app, scale: cfg.scale };
|
|
781
828
|
fs.writeFileSync(path.join(cfg.work, 'timeline.json'), JSON.stringify(timeline, null, 1));
|
|
782
829
|
log(`recorded ${frames.length} frames`);
|
|
783
830
|
return timeline;
|
package/src/voice.mjs
CHANGED
|
@@ -94,6 +94,7 @@ export async function speak(cfg, line, { log = console.log } = {}) {
|
|
|
94
94
|
|
|
95
95
|
// Speed a clip up (factor > 1) with ffmpeg's pitch-preserving atempo.
|
|
96
96
|
export function tempo(pcm, factor) {
|
|
97
|
+
if (!(factor >= 0.5 && factor <= 100)) throw new Error(`internal: speed factor ${factor} is outside what ffmpeg's atempo accepts (0.5–100)`);
|
|
97
98
|
const out = execFileSync('ffmpeg', ['-loglevel', 'error', '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, '-i', 'pipe:0',
|
|
98
99
|
'-af', `atempo=${factor.toFixed(4)}`, '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, 'pipe:1'], { input: Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength), maxBuffer: 1 << 28 });
|
|
99
100
|
return new Float32Array(out.buffer, out.byteOffset, out.length / 4);
|