@mineo71/showtime 0.3.0 → 0.4.0
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/CHANGELOG.md +15 -0
- package/README.md +78 -14
- package/docs/script.schema.json +178 -0
- package/package.json +1 -1
- package/skill/SKILL.md +40 -7
- package/src/audio/decode.ts +19 -0
- package/src/audio/mix.ts +72 -0
- package/src/audio/sfx.ts +55 -0
- package/src/audio/soundtrack.ts +49 -0
- package/src/audio/tts.ts +186 -0
- package/src/audio/wav.ts +96 -0
- package/src/cli/main.ts +53 -21
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +28 -1
- package/src/paths.ts +13 -0
- package/src/pipeline.ts +89 -20
- package/src/render/remotion/Intro.tsx +1 -1
- package/src/render/remotion/Outro.tsx +1 -1
- package/src/render/remotion/Overlays.tsx +40 -16
- package/src/render/remotion/ShowtimeVideo.tsx +47 -16
- package/src/render/render.ts +1 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/record.ts +1 -0
- package/src/runner/steps.ts +1 -0
- package/src/script/config.ts +24 -8
- package/src/script/load.ts +4 -1
- package/src/script/schema.ts +53 -0
- package/src/timeline/build.ts +75 -15
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +3 -2
- package/src/timeline/layout.ts +53 -4
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- package/src/timeline/types.ts +38 -1
package/src/audio/mix.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The soundtrack mixer: voices, a looped music bed that ducks under them, and click/key sounds.
|
|
2
|
+
|
|
3
|
+
/** Music level while a voice plays. */
|
|
4
|
+
export const DUCK = 0.3;
|
|
5
|
+
export const DUCK_RAMP_MS = 300;
|
|
6
|
+
export const MUSIC_FADE_IN_MS = 800;
|
|
7
|
+
export const MUSIC_FADE_OUT_MS = 2000;
|
|
8
|
+
/** Peak the finished track is scaled under when parts overlap. */
|
|
9
|
+
const CEILING = 0.98;
|
|
10
|
+
|
|
11
|
+
export interface MixPlan {
|
|
12
|
+
rate: number;
|
|
13
|
+
totalMs: number;
|
|
14
|
+
/** mono voice clips; `atMs` from the start of the track */
|
|
15
|
+
voices: { samples: Float32Array; atMs: number }[];
|
|
16
|
+
/** one or two channels, looped */
|
|
17
|
+
music?: { channels: Float32Array[]; volume: number };
|
|
18
|
+
sounds: { samples: Float32Array; atMs: number; gain: number }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
22
|
+
|
|
23
|
+
/** Music gain (before its volume) at `tMs`: fade in, fade out, and ducking around voice spans. */
|
|
24
|
+
export function musicGain(tMs: number, spans: { startMs: number; endMs: number }[], totalMs: number): number {
|
|
25
|
+
const fade = Math.min(clamp01(tMs / MUSIC_FADE_IN_MS), clamp01((totalMs - tMs) / MUSIC_FADE_OUT_MS));
|
|
26
|
+
let duck = 0;
|
|
27
|
+
for (const s of spans) {
|
|
28
|
+
duck = Math.max(duck, clamp01(Math.min((tMs - (s.startMs - DUCK_RAMP_MS)) / DUCK_RAMP_MS, (s.endMs + DUCK_RAMP_MS - tMs) / DUCK_RAMP_MS)));
|
|
29
|
+
}
|
|
30
|
+
return fade * (1 - (1 - DUCK) * duck);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addMono(into: Float32Array[], samples: Float32Array, at: number, gain: number) {
|
|
34
|
+
const end = Math.min(into[0].length, at + samples.length);
|
|
35
|
+
for (let i = Math.max(0, at); i < end; i++) {
|
|
36
|
+
const v = samples[i - at] * gain;
|
|
37
|
+
into[0][i] += v;
|
|
38
|
+
into[1][i] += v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mix the plan into a stereo track of `totalMs`. */
|
|
43
|
+
export function mixTrack(plan: MixPlan): Float32Array[] {
|
|
44
|
+
const { rate } = plan;
|
|
45
|
+
const n = Math.round((plan.totalMs / 1000) * rate);
|
|
46
|
+
const out = [new Float32Array(n), new Float32Array(n)];
|
|
47
|
+
const at = (ms: number) => Math.round((ms / 1000) * rate);
|
|
48
|
+
|
|
49
|
+
if (plan.music && plan.music.channels[0]?.length) {
|
|
50
|
+
const src = plan.music.channels;
|
|
51
|
+
const len = src[0].length;
|
|
52
|
+
const spans = plan.voices.map((v) => ({ startMs: v.atMs, endMs: v.atMs + (v.samples.length / rate) * 1000 }));
|
|
53
|
+
// Gain per millisecond is plenty for ramps of hundreds of ms.
|
|
54
|
+
const gains = Float32Array.from({ length: Math.ceil(plan.totalMs) + 1 }, (_, ms) => musicGain(ms, spans, plan.totalMs) * plan.music!.volume);
|
|
55
|
+
for (let i = 0; i < n; i++) {
|
|
56
|
+
const g = gains[Math.floor((i / rate) * 1000)];
|
|
57
|
+
const j = i % len;
|
|
58
|
+
out[0][i] += src[0][j] * g;
|
|
59
|
+
out[1][i] += (src[1] ?? src[0])[j] * g;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const v of plan.voices) addMono(out, v.samples, at(v.atMs), 1);
|
|
63
|
+
for (const s of plan.sounds) addMono(out, s.samples, at(s.atMs), s.gain);
|
|
64
|
+
|
|
65
|
+
let peak = 0;
|
|
66
|
+
for (const ch of out) for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(ch[i]));
|
|
67
|
+
if (peak > CEILING) {
|
|
68
|
+
const k = CEILING / peak;
|
|
69
|
+
for (const ch of out) for (let i = 0; i < n; i++) ch[i] *= k;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
package/src/audio/sfx.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Click and keystroke sounds, synthesized so the package ships no audio files.
|
|
2
|
+
|
|
3
|
+
/** Small deterministic PRNG (mulberry32), so every render sounds the same. */
|
|
4
|
+
function prng(seed: number) {
|
|
5
|
+
let a = seed >>> 0;
|
|
6
|
+
return () => {
|
|
7
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
8
|
+
let t = a;
|
|
9
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
10
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
11
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Voice {
|
|
16
|
+
ms: number;
|
|
17
|
+
seed: number;
|
|
18
|
+
noiseDecayMs: number;
|
|
19
|
+
tone: number;
|
|
20
|
+
toneDecayMs: number;
|
|
21
|
+
thump: number;
|
|
22
|
+
peak: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function synth(rate: number, v: Voice): Float32Array {
|
|
26
|
+
const n = Math.round((v.ms / 1000) * rate);
|
|
27
|
+
const out = new Float32Array(n);
|
|
28
|
+
const rand = prng(v.seed);
|
|
29
|
+
const fade = Math.round(0.005 * rate);
|
|
30
|
+
let low = 0;
|
|
31
|
+
let max = 0;
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
const t = i / rate;
|
|
34
|
+
// A one-pole low-pass takes the hiss off the noise burst.
|
|
35
|
+
low += 0.35 * (rand() * 2 - 1 - low);
|
|
36
|
+
const s =
|
|
37
|
+
low * Math.exp(-t / (v.noiseDecayMs / 1000)) +
|
|
38
|
+
0.6 * Math.sin(2 * Math.PI * v.tone * t) * Math.exp(-t / (v.toneDecayMs / 1000)) +
|
|
39
|
+
v.thump * Math.sin(2 * Math.PI * 140 * t) * Math.exp(-t / 0.012);
|
|
40
|
+
out[i] = s * Math.min(1, (n - 1 - i) / fade);
|
|
41
|
+
max = Math.max(max, Math.abs(out[i]));
|
|
42
|
+
}
|
|
43
|
+
if (max > 0) for (let i = 0; i < n; i++) out[i] *= v.peak / max;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A mouse click: a soft tick with a little body, 45 ms. */
|
|
48
|
+
export const clickSamples = (rate: number) =>
|
|
49
|
+
synth(rate, { ms: 45, seed: 7, noiseDecayMs: 4, tone: 1800, toneDecayMs: 9, thump: 0.5, peak: 0.9 });
|
|
50
|
+
|
|
51
|
+
/** A keystroke: a lighter, shorter tick; variants differ a little so typing doesn't drone. */
|
|
52
|
+
export const keySamples = (rate: number, variant: number) =>
|
|
53
|
+
synth(rate, { ms: 26, seed: 101 + variant, noiseDecayMs: 3, tone: 2600 + 350 * (variant % 4), toneDecayMs: 5, thump: 0.15, peak: 0.7 });
|
|
54
|
+
|
|
55
|
+
export const KEY_VARIANTS = 4;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { workPaths } from '../paths';
|
|
4
|
+
import type { LoadedScript } from '../script/load';
|
|
5
|
+
import { introFrames, totalFrames } from '../timeline/frames';
|
|
6
|
+
import type { Timeline } from '../timeline/types';
|
|
7
|
+
import { decodeAudio } from './decode';
|
|
8
|
+
import { mixTrack } from './mix';
|
|
9
|
+
import { clickSamples, KEY_VARIANTS, keySamples } from './sfx';
|
|
10
|
+
import type { VoiceClip } from './tts';
|
|
11
|
+
import { RATE, readWav, writeWav } from './wav';
|
|
12
|
+
|
|
13
|
+
export const CLICK_GAIN = 0.5;
|
|
14
|
+
export const KEY_GAIN = 0.2;
|
|
15
|
+
/** Music is normalised to this loudness before its volume applies (voices sit at -16 LUFS). */
|
|
16
|
+
const MUSIC_LUFS = -20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mix narration, music and sounds into `.showtime/<name>/audio/mix.wav`, as long as the whole video
|
|
20
|
+
* (cards included). Returns its path relative to the renderer's public folder, or null when the
|
|
21
|
+
* video has no sound.
|
|
22
|
+
*/
|
|
23
|
+
export async function mixSoundtrack(loaded: LoadedScript, t: Timeline, clips: Record<string, VoiceClip>): Promise<string | null> {
|
|
24
|
+
const music = loaded.script.audio.music;
|
|
25
|
+
if (!t.narration.length && !t.sounds.length && !music) return null;
|
|
26
|
+
const frameMs = 1000 / t.fps;
|
|
27
|
+
const introMs = introFrames(t) * frameMs;
|
|
28
|
+
const voices = await Promise.all(
|
|
29
|
+
t.narration.map(async (n) => ({ samples: (await readWav(clips[n.where].file)).channels[0], atMs: introMs + n.atMs })),
|
|
30
|
+
);
|
|
31
|
+
const click = clickSamples(RATE);
|
|
32
|
+
const keys = Array.from({ length: KEY_VARIANTS }, (_, i) => keySamples(RATE, i));
|
|
33
|
+
const sounds = t.sounds.map((s, i) =>
|
|
34
|
+
s.kind === 'click'
|
|
35
|
+
? { samples: click, atMs: introMs + s.atMs, gain: CLICK_GAIN }
|
|
36
|
+
: { samples: keys[i % KEY_VARIANTS], atMs: introMs + s.atMs, gain: KEY_GAIN },
|
|
37
|
+
);
|
|
38
|
+
const track = mixTrack({
|
|
39
|
+
rate: RATE,
|
|
40
|
+
totalMs: totalFrames(t) * frameMs,
|
|
41
|
+
voices,
|
|
42
|
+
sounds,
|
|
43
|
+
music: music ? { channels: await decodeAudio(music.file, { channels: 2, lufs: MUSIC_LUFS }), volume: music.volume } : undefined,
|
|
44
|
+
});
|
|
45
|
+
const paths = workPaths(loaded);
|
|
46
|
+
await mkdir(paths.audio, { recursive: true });
|
|
47
|
+
await writeWav(paths.mix, track);
|
|
48
|
+
return path.relative(paths.root, paths.mix).split(path.sep).join('/');
|
|
49
|
+
}
|
package/src/audio/tts.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { workPaths } from '../paths';
|
|
7
|
+
import type { LoadedScript } from '../script/load';
|
|
8
|
+
import type { Voice } from '../script/schema';
|
|
9
|
+
import { stepKey } from '../timeline/narration';
|
|
10
|
+
import { normalizeVoice } from './decode';
|
|
11
|
+
import { wavDurationMs } from './wav';
|
|
12
|
+
|
|
13
|
+
export const TTS_TIMEOUT_MS = 120_000;
|
|
14
|
+
/** `say` and `espeak-ng` speak at about this many words per minute at speed 1. */
|
|
15
|
+
const BASE_WPM = 175;
|
|
16
|
+
|
|
17
|
+
export interface EngineOptions {
|
|
18
|
+
/** Where API keys and base URLs come from (process.env by default). */
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VoiceClip {
|
|
24
|
+
/** normalised mono 48 kHz WAV in the voice cache */
|
|
25
|
+
file: string;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cache key of a line: every voice setting plus the text. */
|
|
30
|
+
export function voiceKey(voice: Voice, text: string): string {
|
|
31
|
+
const parts = [voice.engine, voice.name ?? null, voice.speed, voice.model ?? null, voice.instructions ?? null, voice.command ?? null, text];
|
|
32
|
+
return createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tail = (s: string) => {
|
|
36
|
+
const t = s.trim().slice(-600);
|
|
37
|
+
return t ? `\n${t}` : '';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Run a program (or a shell command) with optional stdin; reject with the exit code and output tail. */
|
|
41
|
+
function exec(cmd: string, args: string[], o: { input?: string; shell?: boolean; env?: NodeJS.ProcessEnv; timeoutMs: number }): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(cmd, args, { shell: o.shell ?? false, env: o.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
44
|
+
let output = '';
|
|
45
|
+
const keep = (d: Buffer) => {
|
|
46
|
+
output = (output + d.toString()).slice(-4000);
|
|
47
|
+
};
|
|
48
|
+
child.stdout.on('data', keep);
|
|
49
|
+
child.stderr.on('data', keep);
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
child.kill('SIGKILL');
|
|
52
|
+
reject(new Error(`timed out after ${o.timeoutMs / 1000} s${tail(output)}`));
|
|
53
|
+
}, o.timeoutMs);
|
|
54
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
reject(err.code === 'ENOENT' ? new Error(`${cmd} not found`) : err);
|
|
57
|
+
});
|
|
58
|
+
child.on('close', (code) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
if (code === 0) resolve();
|
|
61
|
+
else reject(new Error(`exit code ${code}${tail(output)}`));
|
|
62
|
+
});
|
|
63
|
+
child.stdin.on('error', () => undefined); // a command that ignores stdin is fine
|
|
64
|
+
child.stdin.end(o.input ?? '');
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const quote = (s: string) => (process.platform === 'win32' ? `"${s}"` : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
69
|
+
|
|
70
|
+
async function post(url: string, headers: Record<string, string>, body: unknown, out: string, timeoutMs: number): Promise<void> {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}${tail(await res.text().catch(() => ''))}`);
|
|
78
|
+
await writeFile(out, Buffer.from(await res.arrayBuffer()));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function keyFrom(env: NodeJS.ProcessEnv, name: string): string {
|
|
82
|
+
const key = env[name];
|
|
83
|
+
if (!key) throw new Error(`${name} is not set. Export it in the shell that runs showtime.`);
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Speak `text` with the voice's engine into `<base>.<ext>` and return that path. The text never
|
|
89
|
+
* reaches a command line: `say`/`espeak-ng` read it from a file, `command` from stdin.
|
|
90
|
+
*/
|
|
91
|
+
export async function synthesize(voice: Voice, text: string, base: string, opts: EngineOptions = {}): Promise<string> {
|
|
92
|
+
const env = opts.env ?? process.env;
|
|
93
|
+
const timeoutMs = opts.timeoutMs ?? TTS_TIMEOUT_MS;
|
|
94
|
+
switch (voice.engine) {
|
|
95
|
+
case 'system': {
|
|
96
|
+
const txt = `${base}.txt`;
|
|
97
|
+
await writeFile(txt, text);
|
|
98
|
+
const rate = voice.speed === 1 ? [] : [String(Math.round(BASE_WPM * voice.speed))];
|
|
99
|
+
if (process.platform === 'darwin') {
|
|
100
|
+
const out = `${base}.aiff`;
|
|
101
|
+
const args = ['-f', txt, '-o', out, ...(voice.name ? ['-v', voice.name] : []), ...(rate.length ? ['-r', ...rate] : [])];
|
|
102
|
+
await exec('say', args, { timeoutMs, env });
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
if (process.platform === 'linux') {
|
|
106
|
+
const out = `${base}.wav`;
|
|
107
|
+
const args = ['-f', txt, '-w', out, ...(voice.name ? ['-v', voice.name] : []), ...(rate.length ? ['-s', ...rate] : [])];
|
|
108
|
+
await exec('espeak-ng', args, { timeoutMs, env }).catch((err: Error) => {
|
|
109
|
+
throw new Error(`${err.message}. Install espeak-ng (apt install espeak-ng) or set audio.voice.engine to openai, elevenlabs or command.`);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('the system voice works on macOS and Linux; set audio.voice.engine to openai, elevenlabs or command.');
|
|
114
|
+
}
|
|
115
|
+
case 'openai': {
|
|
116
|
+
const key = keyFrom(env, 'OPENAI_API_KEY');
|
|
117
|
+
const url = `${(env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, '')}/audio/speech`;
|
|
118
|
+
const out = `${base}.wav`;
|
|
119
|
+
const body = {
|
|
120
|
+
model: voice.model ?? 'gpt-4o-mini-tts',
|
|
121
|
+
voice: voice.name ?? 'alloy',
|
|
122
|
+
input: text,
|
|
123
|
+
response_format: 'wav',
|
|
124
|
+
speed: voice.speed,
|
|
125
|
+
...(voice.instructions ? { instructions: voice.instructions } : {}),
|
|
126
|
+
};
|
|
127
|
+
await post(url, { authorization: `Bearer ${key}` }, body, out, timeoutMs);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
case 'elevenlabs': {
|
|
131
|
+
const key = keyFrom(env, 'ELEVENLABS_API_KEY');
|
|
132
|
+
const root = (env.ELEVENLABS_BASE_URL ?? 'https://api.elevenlabs.io').replace(/\/$/, '');
|
|
133
|
+
const url = `${root}/v1/text-to-speech/${encodeURIComponent(voice.name ?? '')}?output_format=mp3_44100_128`;
|
|
134
|
+
const out = `${base}.mp3`;
|
|
135
|
+
const body = { text, model_id: voice.model ?? 'eleven_multilingual_v2', voice_settings: { speed: Math.min(1.2, Math.max(0.7, voice.speed)) } };
|
|
136
|
+
await post(url, { 'xi-api-key': key }, body, out, timeoutMs);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
case 'command': {
|
|
140
|
+
// .wav so tools that pick the format from the extension (say -o, sox) write something ffmpeg reads.
|
|
141
|
+
const out = `${base}.wav`;
|
|
142
|
+
const command = (voice.command ?? '').replaceAll('{out}', quote(out));
|
|
143
|
+
await exec(command, [], { shell: true, input: text, env: { ...env, SHOWTIME_OUT: out }, timeoutMs });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Synthesize every `show.say` line that is not cached yet, and return all lines by step. Lines are
|
|
151
|
+
* normalised (loudness, mono 48 kHz WAV) into `.showtime/<name>/audio/voice-<key>.wav`.
|
|
152
|
+
*/
|
|
153
|
+
export async function ensureVoices(
|
|
154
|
+
loaded: LoadedScript,
|
|
155
|
+
opts: EngineOptions & { log?: (line: string) => void } = {},
|
|
156
|
+
): Promise<Record<string, VoiceClip>> {
|
|
157
|
+
const { voice } = loaded.script.audio;
|
|
158
|
+
const dir = workPaths(loaded).audio;
|
|
159
|
+
const clips: Record<string, VoiceClip> = {};
|
|
160
|
+
for (const scene of loaded.script.scenes) {
|
|
161
|
+
for (const [index, step] of scene.steps.entries()) {
|
|
162
|
+
const text = step.show?.say;
|
|
163
|
+
if (!text) continue;
|
|
164
|
+
const where = stepKey(scene.id, index);
|
|
165
|
+
const key = voiceKey(voice, text);
|
|
166
|
+
const file = path.join(dir, `voice-${key}.wav`);
|
|
167
|
+
if (!existsSync(file)) {
|
|
168
|
+
await mkdir(dir, { recursive: true });
|
|
169
|
+
opts.log?.(`Voice for ${where}…`);
|
|
170
|
+
const base = path.join(dir, `tmp-${key}`);
|
|
171
|
+
try {
|
|
172
|
+
const raw = await synthesize(voice, text, base, opts);
|
|
173
|
+
await normalizeVoice(raw, `${file}.part`);
|
|
174
|
+
await rename(`${file}.part`, file);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw new Error(`audio.voice (${voice.engine}) failed for ${where}: ${(err as Error).message}`);
|
|
177
|
+
} finally {
|
|
178
|
+
for (const ext of ['.txt', '.aiff', '.wav', '.mp3']) await rm(`${base}${ext}`, { force: true });
|
|
179
|
+
await rm(`${file}.part`, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
clips[where] = { file, durationMs: await wavDurationMs(file) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return clips;
|
|
186
|
+
}
|
package/src/audio/wav.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { open, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/** Sample rate of everything showTime mixes. */
|
|
4
|
+
export const RATE = 48_000;
|
|
5
|
+
const HEADER = 44;
|
|
6
|
+
|
|
7
|
+
/** 16-bit PCM WAV from channels of float samples in [-1, 1] (out-of-range samples clip). */
|
|
8
|
+
export function encodeWav(channels: Float32Array[], rate = RATE): Buffer {
|
|
9
|
+
const n = channels[0]?.length ?? 0;
|
|
10
|
+
const ch = channels.length;
|
|
11
|
+
const data = n * ch * 2;
|
|
12
|
+
const buf = Buffer.alloc(HEADER + data);
|
|
13
|
+
buf.write('RIFF', 0, 'ascii');
|
|
14
|
+
buf.writeUInt32LE(36 + data, 4);
|
|
15
|
+
buf.write('WAVE', 8, 'ascii');
|
|
16
|
+
buf.write('fmt ', 12, 'ascii');
|
|
17
|
+
buf.writeUInt32LE(16, 16);
|
|
18
|
+
buf.writeUInt16LE(1, 20); // PCM
|
|
19
|
+
buf.writeUInt16LE(ch, 22);
|
|
20
|
+
buf.writeUInt32LE(rate, 24);
|
|
21
|
+
buf.writeUInt32LE(rate * ch * 2, 28);
|
|
22
|
+
buf.writeUInt16LE(ch * 2, 32);
|
|
23
|
+
buf.writeUInt16LE(16, 34);
|
|
24
|
+
buf.write('data', 36, 'ascii');
|
|
25
|
+
buf.writeUInt32LE(data, 40);
|
|
26
|
+
let o = HEADER;
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
for (let c = 0; c < ch; c++) {
|
|
29
|
+
const v = Math.max(-1, Math.min(1, channels[c][i]));
|
|
30
|
+
buf.writeInt16LE(Math.round(v * 32767), o);
|
|
31
|
+
o += 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return buf;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Format {
|
|
38
|
+
channels: number;
|
|
39
|
+
rate: number;
|
|
40
|
+
dataStart: number;
|
|
41
|
+
dataBytes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Find the fmt and data chunks of a 16-bit PCM WAV. */
|
|
45
|
+
function parseHeader(buf: Buffer): Format {
|
|
46
|
+
const bad = () => new Error('not a 16-bit PCM WAV file');
|
|
47
|
+
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') throw bad();
|
|
48
|
+
let o = 12;
|
|
49
|
+
let fmt: Omit<Format, 'dataStart' | 'dataBytes'> | null = null;
|
|
50
|
+
while (o + 8 <= buf.length) {
|
|
51
|
+
const id = buf.toString('ascii', o, o + 4);
|
|
52
|
+
const size = buf.readUInt32LE(o + 4);
|
|
53
|
+
if (id === 'fmt ') {
|
|
54
|
+
if (buf.readUInt16LE(o + 8) !== 1 || buf.readUInt16LE(o + 22) !== 16) throw bad();
|
|
55
|
+
fmt = { channels: buf.readUInt16LE(o + 10), rate: buf.readUInt32LE(o + 12) };
|
|
56
|
+
} else if (id === 'data') {
|
|
57
|
+
if (!fmt) throw bad();
|
|
58
|
+
return { ...fmt, dataStart: o + 8, dataBytes: Math.min(size, buf.length - o - 8) };
|
|
59
|
+
}
|
|
60
|
+
o += 8 + size + (size % 2);
|
|
61
|
+
}
|
|
62
|
+
throw bad();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function decodeWav(buf: Buffer): { rate: number; channels: Float32Array[] } {
|
|
66
|
+
const f = parseHeader(buf);
|
|
67
|
+
const n = Math.floor(f.dataBytes / (2 * f.channels));
|
|
68
|
+
const channels = Array.from({ length: f.channels }, () => new Float32Array(n));
|
|
69
|
+
let o = f.dataStart;
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
for (let c = 0; c < f.channels; c++) {
|
|
72
|
+
channels[c][i] = buf.readInt16LE(o) / 32767;
|
|
73
|
+
o += 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { rate: f.rate, channels };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const writeWav = (file: string, channels: Float32Array[], rate = RATE) => writeFile(file, encodeWav(channels, rate));
|
|
80
|
+
|
|
81
|
+
export const readWav = async (file: string) => decodeWav(await readFile(file));
|
|
82
|
+
|
|
83
|
+
/** Length of a WAV file from its header alone. */
|
|
84
|
+
export async function wavDurationMs(file: string): Promise<number> {
|
|
85
|
+
const fh = await open(file, 'r');
|
|
86
|
+
try {
|
|
87
|
+
const head = Buffer.alloc(4096);
|
|
88
|
+
const { bytesRead } = await fh.read(head, 0, head.length, 0);
|
|
89
|
+
const f = parseHeader(head.subarray(0, bytesRead));
|
|
90
|
+
const { size } = await fh.stat();
|
|
91
|
+
const bytes = Math.min(size - f.dataStart, head.readUInt32LE(f.dataStart - 4));
|
|
92
|
+
return Math.round((bytes / (2 * f.channels) / f.rate) * 1000);
|
|
93
|
+
} finally {
|
|
94
|
+
await fh.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { workPaths } from '../paths';
|
|
|
4
4
|
import { loadScript, type LoadedScript } from '../script/load';
|
|
5
5
|
import { scriptJsonSchema } from '../script/schema';
|
|
6
6
|
import type { TakeEvents } from '../timeline/types';
|
|
7
|
+
import { aspectOf, formatOf } from '../convert';
|
|
7
8
|
import { formatCheck, formatLint } from './format';
|
|
8
9
|
|
|
9
10
|
const say = (line: string): void => {
|
|
@@ -26,13 +27,42 @@ const takeSummary = (loaded: LoadedScript, ev: TakeEvents) => ({
|
|
|
26
27
|
steps: ev.steps.length,
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
interface RenderFlags {
|
|
31
|
+
out?: string;
|
|
32
|
+
json?: boolean;
|
|
33
|
+
aspect: string;
|
|
34
|
+
format?: string;
|
|
35
|
+
width?: string;
|
|
36
|
+
audio: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Validate the output flags up front, before any recording or rendering starts. */
|
|
40
|
+
function renderOptions(o: RenderFlags) {
|
|
41
|
+
const width = o.width === undefined ? undefined : Number(o.width);
|
|
42
|
+
if (width !== undefined && !(Number.isInteger(width) && width >= 16 && width <= 3840)) {
|
|
43
|
+
throw new Error(`--width must be a whole number of pixels between 16 and 3840 (got "${o.width}")`);
|
|
44
|
+
}
|
|
45
|
+
return { out: o.out, aspect: aspectOf(o.aspect), format: formatOf(o.out, o.format), width, audio: o.audio };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const withRenderFlags = (c: Command) =>
|
|
49
|
+
c
|
|
50
|
+
.option('-o, --out <file>', 'output file (default: <script-name>[-9x16|-1x1].<format> next to the script)')
|
|
51
|
+
.option('--aspect <ratio>', '16:9, 9:16 or 1:1', '16:9')
|
|
52
|
+
.option('--format <format>', 'mp4, gif or webp (default: from --out, else mp4)')
|
|
53
|
+
.option('--width <px>', 'GIF/WebP width (default: 960, 540 or 720 by aspect)')
|
|
54
|
+
.option('--no-audio', 'leave out narration, music and sounds (narration still paces burned-in subtitles)');
|
|
55
|
+
|
|
56
|
+
async function render(loaded: LoadedScript, o: RenderFlags) {
|
|
30
57
|
const { renderDemo } = await import('../pipeline');
|
|
31
|
-
|
|
32
|
-
const video = await renderDemo(loaded, { out: o.out, onProgress: progressFor(o.json) });
|
|
33
|
-
return { video, durationMs: (await probeVideo(video)).durationMs };
|
|
58
|
+
return renderDemo(loaded, { ...renderOptions(o), onProgress: progressFor(o.json), log: o.json ? undefined : say });
|
|
34
59
|
}
|
|
35
60
|
|
|
61
|
+
const sayRendered = (r: { video: string; subtitles: string | null }) => {
|
|
62
|
+
say(`\n✓ ${r.video}`);
|
|
63
|
+
if (r.subtitles) say(`✓ subtitles: ${r.subtitles}`);
|
|
64
|
+
};
|
|
65
|
+
|
|
36
66
|
const program = new Command('showtime')
|
|
37
67
|
.description('AI-directed product demo videos: JSON script in, polished MP4 out')
|
|
38
68
|
.version(version);
|
|
@@ -90,8 +120,10 @@ program
|
|
|
90
120
|
const { loadTake } = await import('../take');
|
|
91
121
|
const events = await loadTake(loaded).catch(() => null);
|
|
92
122
|
if (events) {
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
// The timeline as rendered: narration is synthesized (cached) so its holds count.
|
|
124
|
+
const { timelineFor } = await import('../pipeline');
|
|
125
|
+
const { timeline } = await timelineFor(loaded, { log: o.json ? undefined : say });
|
|
126
|
+
issues.push(...lintTiming(timeline, events.steps));
|
|
95
127
|
}
|
|
96
128
|
if (o.json) emit({ issues, timing: events !== null });
|
|
97
129
|
else say(formatLint(issues, events !== null));
|
|
@@ -113,36 +145,36 @@ program
|
|
|
113
145
|
else say(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s, ${take.steps} steps → ${take.take}`);
|
|
114
146
|
});
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
.command('render <script>')
|
|
118
|
-
|
|
119
|
-
.option('
|
|
120
|
-
.
|
|
121
|
-
|
|
148
|
+
withRenderFlags(
|
|
149
|
+
program.command('render <script>').description('render the polished video from the last take (no re-recording)'),
|
|
150
|
+
)
|
|
151
|
+
.option('--json', 'print { video, durationMs, subtitles } as JSON')
|
|
152
|
+
.action(async (file: string, o: RenderFlags) => {
|
|
153
|
+
renderOptions(o);
|
|
122
154
|
const result = await render(await loadScript(file), o);
|
|
123
155
|
if (o.json) emit(result);
|
|
124
|
-
else
|
|
156
|
+
else sayRendered(result);
|
|
125
157
|
});
|
|
126
158
|
|
|
127
159
|
program
|
|
128
160
|
.command('frames <script>')
|
|
129
161
|
.description('render QA stills (intro, one per step, outro) and a contact sheet of them')
|
|
162
|
+
.option('--aspect <ratio>', '16:9, 9:16 or 1:1', '16:9')
|
|
130
163
|
.option('--json', 'print { sheet, frames } as JSON')
|
|
131
|
-
.action(async (file: string, o: { json?: boolean }) => {
|
|
164
|
+
.action(async (file: string, o: { aspect: string; json?: boolean }) => {
|
|
165
|
+
const aspect = aspectOf(o.aspect);
|
|
132
166
|
const { renderQaFrames } = await import('../pipeline');
|
|
133
|
-
const { sheet, frames } = await renderQaFrames(await loadScript(file));
|
|
167
|
+
const { sheet, frames } = await renderQaFrames(await loadScript(file), { aspect, log: o.json ? undefined : say });
|
|
134
168
|
if (o.json) return emit({ sheet, frames });
|
|
135
169
|
say(`sheet: ${sheet}`);
|
|
136
170
|
say(frames.join('\n'));
|
|
137
171
|
});
|
|
138
172
|
|
|
139
|
-
program
|
|
140
|
-
.command('make <script>')
|
|
141
|
-
.description('check → record → render')
|
|
142
|
-
.option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
|
|
173
|
+
withRenderFlags(program.command('make <script>').description('check → record → render'))
|
|
143
174
|
.option('--headed', 'show the browser')
|
|
144
175
|
.option('--json', 'print { check, take, video } as JSON')
|
|
145
|
-
.action(async (file: string, o:
|
|
176
|
+
.action(async (file: string, o: RenderFlags & { headed?: boolean }) => {
|
|
177
|
+
renderOptions(o);
|
|
146
178
|
const loaded = await loadScript(file);
|
|
147
179
|
const log = o.json ? undefined : say;
|
|
148
180
|
const { check } = await import('../runner/check');
|
|
@@ -159,7 +191,7 @@ program
|
|
|
159
191
|
log?.(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s`);
|
|
160
192
|
const video = await render(loaded, o);
|
|
161
193
|
if (o.json) emit({ check: report, take, video });
|
|
162
|
-
else
|
|
194
|
+
else sayRendered(video);
|
|
163
195
|
});
|
|
164
196
|
|
|
165
197
|
program.parseAsync().catch((err: Error) => {
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { run } from './ffmpeg';
|
|
3
|
+
import type { Aspect } from './timeline/types';
|
|
4
|
+
|
|
5
|
+
export type Format = 'mp4' | 'gif' | 'webp';
|
|
6
|
+
const FORMATS: readonly Format[] = ['mp4', 'gif', 'webp'];
|
|
7
|
+
const ASPECTS: readonly Aspect[] = ['16:9', '9:16', '1:1'];
|
|
8
|
+
|
|
9
|
+
/** GIF/WebP width when --width is not given. */
|
|
10
|
+
export const ANIMATION_WIDTH: Record<Aspect, number> = { '16:9': 960, '9:16': 540, '1:1': 720 };
|
|
11
|
+
const GIF_FPS = 15;
|
|
12
|
+
const WEBP_FPS = 20;
|
|
13
|
+
const WEBP_QUALITY = 76;
|
|
14
|
+
|
|
15
|
+
export function aspectOf(flag = '16:9'): Aspect {
|
|
16
|
+
if (!(ASPECTS as readonly string[]).includes(flag)) throw new Error(`--aspect must be 16:9, 9:16 or 1:1 (got "${flag}")`);
|
|
17
|
+
return flag as Aspect;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The --format flag, else the extension of -o, else mp4. */
|
|
21
|
+
export function formatOf(out?: string, flag?: string): Format {
|
|
22
|
+
if (flag !== undefined) {
|
|
23
|
+
if (!(FORMATS as readonly string[]).includes(flag)) throw new Error(`--format must be mp4, gif or webp (got "${flag}")`);
|
|
24
|
+
return flag as Format;
|
|
25
|
+
}
|
|
26
|
+
if (!out) return 'mp4';
|
|
27
|
+
const ext = path.extname(out).slice(1).toLowerCase();
|
|
28
|
+
if ((FORMATS as readonly string[]).includes(ext)) return ext as Format;
|
|
29
|
+
throw new Error(`Cannot tell the format from "${out}": name it .mp4, .gif or .webp, or pass --format`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<name>.mp4`, `<name>-9x16.mp4`, `<name>-1x1.gif`… next to the script. */
|
|
33
|
+
export function defaultOutput(loaded: { dir: string; name: string }, aspect: Aspect, format: Format): string {
|
|
34
|
+
const suffix = aspect === '16:9' ? '' : `-${aspect.replace(':', 'x')}`;
|
|
35
|
+
return path.join(loaded.dir, `${loaded.name}${suffix}.${format}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An MP4 as a looping GIF with one palette for the whole clip. */
|
|
39
|
+
export async function toGif(src: string, out: string, width: number): Promise<void> {
|
|
40
|
+
const vf = `fps=${GIF_FPS},scale=${width}:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
|
|
41
|
+
await run('ffmpeg', ['-v', 'error', '-y', '-i', src, '-vf', vf, '-loop', '0', out]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An MP4 as a looping animated WebP. */
|
|
45
|
+
export async function toWebp(src: string, out: string, width: number): Promise<void> {
|
|
46
|
+
const vf = `fps=${WEBP_FPS},scale=${width}:-1:flags=lanczos`;
|
|
47
|
+
await run('ffmpeg', ['-v', 'error', '-y', '-i', src, '-vf', vf, '-c:v', 'libwebp_anim', '-quality', String(WEBP_QUALITY), '-loop', '0', out]);
|
|
48
|
+
}
|
package/src/ffmpeg.ts
CHANGED
|
@@ -14,6 +14,21 @@ export function run(cmd: string, args: string[]): Promise<string> {
|
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Run a command and collect its binary stdout. */
|
|
18
|
+
export function runBuffer(cmd: string, args: string[]): Promise<Buffer> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
const out: Buffer[] = [];
|
|
22
|
+
let err = '';
|
|
23
|
+
child.stdout.on('data', (d: Buffer) => out.push(d));
|
|
24
|
+
child.stderr.on('data', (d) => (err += d));
|
|
25
|
+
child.on('error', reject);
|
|
26
|
+
child.on('close', (code) =>
|
|
27
|
+
code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`${cmd} exited with ${code}: ${err.trim().slice(-2000)}`)),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/** Run a command and stream data into its stdin; `feed` awaits back-pressure through `write`. */
|
|
18
33
|
export function runWithInput(
|
|
19
34
|
cmd: string,
|
|
@@ -70,3 +85,9 @@ export async function probeVideo(file: string): Promise<VideoProbe> {
|
|
|
70
85
|
const j = JSON.parse(out) as { streams: Array<{ width: number; height: number }>; format: { duration: string } };
|
|
71
86
|
return { width: j.streams[0].width, height: j.streams[0].height, durationMs: Math.round(Number(j.format.duration) * 1000) };
|
|
72
87
|
}
|
|
88
|
+
|
|
89
|
+
/** Whether a media file has an audio stream. */
|
|
90
|
+
export async function hasAudio(file: string): Promise<boolean> {
|
|
91
|
+
const out = await run('ffprobe', ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file]);
|
|
92
|
+
return out.trim().length > 0;
|
|
93
|
+
}
|