@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
- async function render(loaded: LoadedScript, o: { out?: string; json?: boolean }) {
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
- const { probeVideo } = await import('../ffmpeg');
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
- const { buildTimeline } = await import('../timeline/build');
94
- issues.push(...lintTiming(buildTimeline(loaded.script, events), events.steps));
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
- program
117
- .command('render <script>')
118
- .description('render the polished video from the last take (no re-recording)')
119
- .option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
120
- .option('--json', 'print { video, durationMs } as JSON')
121
- .action(async (file: string, o: { out?: string; json?: boolean }) => {
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 say(`\n✓ ${result.video}`);
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: { out?: string; headed?: boolean; json?: boolean }) => {
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 say(`\n✓ ${video.video}`);
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
+ }