storyshot 0.4.1 → 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 +2 -2
- package/package.json +1 -1
- package/src/audio.mjs +8 -2
- package/src/build.mjs +1 -1
- package/src/check.mjs +16 -0
- package/src/doctor.mjs +10 -1
- package/src/ffmpeg.mjs +27 -0
- package/src/outputs.mjs +66 -7
- 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
|
|
@@ -256,7 +256,7 @@ captions: true # false keeps caption pills off
|
|
|
256
256
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
257
257
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
258
258
|
stepNumbers: false # numbered caption badges
|
|
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)
|
|
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
|
|
260
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
|
|
261
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 }
|
|
262
262
|
article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
|
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": {
|
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));
|
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
|
@@ -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/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/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) => {
|
|
@@ -80,7 +82,7 @@ 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(); };
|
|
@@ -90,7 +92,7 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
|
|
|
90
92
|
const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
|
|
91
93
|
const size = Math.round(H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
|
|
92
94
|
const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
|
|
93
|
-
const
|
|
95
|
+
const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
|
|
94
96
|
for (const line of narration) {
|
|
95
97
|
// CJK: one token per character (Latin words inside stay whole), punctuation stays with the one before
|
|
96
98
|
const words = cjk
|
|
@@ -104,8 +106,7 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
|
|
|
104
106
|
let chunk = [];
|
|
105
107
|
const flush = () => {
|
|
106
108
|
if (!chunk.length) return;
|
|
107
|
-
|
|
108
|
-
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(cjk ? '' : ' ')}`);
|
|
109
|
+
cues.push({ words: chunk });
|
|
109
110
|
chunk = [];
|
|
110
111
|
};
|
|
111
112
|
const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
|
|
@@ -117,6 +118,64 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
|
|
|
117
118
|
}
|
|
118
119
|
flush();
|
|
119
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
|
+
});
|
|
120
179
|
const ass = `[Script Info]
|
|
121
180
|
ScriptType: v4.00+
|
|
122
181
|
PlayResX: ${W}
|
|
@@ -125,17 +184,17 @@ WrapStyle: 0
|
|
|
125
184
|
|
|
126
185
|
[V4+ Styles]
|
|
127
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
|
|
128
|
-
Style: Sub,${fontName},${size},${bgr(
|
|
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
|
|
129
188
|
|
|
130
189
|
[Events]
|
|
131
190
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
132
191
|
${events.join('\n')}
|
|
133
192
|
`;
|
|
134
|
-
const file = path.join(cfg.work, 'subtitles.ass')
|
|
193
|
+
const file = path.join(cfg.work, 'subtitles.ass');
|
|
135
194
|
fs.writeFileSync(file, ass);
|
|
136
195
|
// the filter argument is parsed by ffmpeg: escape the characters it treats specially in paths
|
|
137
196
|
const arg = file.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
138
|
-
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`,
|
|
197
|
+
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`, ...encode]);
|
|
139
198
|
fs.renameSync(tmp, cfg.out);
|
|
140
199
|
log(`subtitles: burned in ${events.length} lines with word-by-word highlight`);
|
|
141
200
|
}
|
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);
|