@mineo71/showtime 0.2.1 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +155 -19
  3. package/docs/script.schema.json +395 -0
  4. package/package.json +1 -1
  5. package/skill/SKILL.md +70 -13
  6. package/src/audio/decode.ts +19 -0
  7. package/src/audio/mix.ts +72 -0
  8. package/src/audio/sfx.ts +55 -0
  9. package/src/audio/soundtrack.ts +49 -0
  10. package/src/audio/tts.ts +186 -0
  11. package/src/audio/wav.ts +96 -0
  12. package/src/cli/format.ts +10 -0
  13. package/src/cli/main.ts +125 -40
  14. package/src/convert.ts +48 -0
  15. package/src/ffmpeg.ts +21 -0
  16. package/src/lint.ts +128 -0
  17. package/src/logo.ts +20 -0
  18. package/src/paths.ts +19 -0
  19. package/src/pipeline.ts +97 -19
  20. package/src/render/remotion/Intro.tsx +3 -1
  21. package/src/render/remotion/Logo.tsx +6 -0
  22. package/src/render/remotion/Outro.tsx +38 -0
  23. package/src/render/remotion/Overlays.tsx +41 -16
  24. package/src/render/remotion/ShowtimeVideo.tsx +56 -18
  25. package/src/render/render.ts +1 -1
  26. package/src/runner/browser.ts +47 -6
  27. package/src/runner/check.ts +10 -1
  28. package/src/runner/events.ts +2 -0
  29. package/src/runner/hooks.ts +111 -0
  30. package/src/runner/record.ts +53 -34
  31. package/src/runner/steps.ts +4 -1
  32. package/src/script/config.ts +74 -0
  33. package/src/script/hash.ts +7 -1
  34. package/src/script/load.ts +15 -2
  35. package/src/script/schema.ts +115 -1
  36. package/src/setup.ts +13 -4
  37. package/src/sheet.ts +47 -0
  38. package/src/timeline/build.ts +113 -13
  39. package/src/timeline/camera.ts +34 -11
  40. package/src/timeline/fades.ts +3 -0
  41. package/src/timeline/frames.ts +15 -4
  42. package/src/timeline/layout.ts +70 -0
  43. package/src/timeline/narration.ts +160 -0
  44. package/src/timeline/timemap.ts +40 -5
  45. package/src/timeline/types.ts +44 -1
package/src/cli/main.ts CHANGED
@@ -1,17 +1,68 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { Command } from 'commander';
3
3
  import { workPaths } from '../paths';
4
- import { loadScript } from '../script/load';
4
+ import { loadScript, type LoadedScript } from '../script/load';
5
5
  import { scriptJsonSchema } from '../script/schema';
6
- import { formatCheck } from './format';
6
+ import type { TakeEvents } from '../timeline/types';
7
+ import { aspectOf, formatOf } from '../convert';
8
+ import { formatCheck, formatLint } from './format';
7
9
 
8
- const say = (line: string) => process.stdout.write(`${line}\n`);
9
- const progress = (p: number) => {
10
- if (process.stdout.isTTY) process.stdout.write(`\rRendering ${Math.round(p * 100)}%`);
10
+ const say = (line: string): void => {
11
+ process.stdout.write(`${line}\n`);
11
12
  };
13
+ /** With --json, stdout carries exactly one JSON document. */
14
+ const emit = (data: unknown): void => say(JSON.stringify(data, null, 2));
15
+ const progressFor = (json?: boolean) =>
16
+ json
17
+ ? undefined
18
+ : (p: number) => {
19
+ if (process.stdout.isTTY) process.stdout.write(`\rRendering ${Math.round(p * 100)}%`);
20
+ };
12
21
 
13
22
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
14
23
 
24
+ const takeSummary = (loaded: LoadedScript, ev: TakeEvents) => ({
25
+ take: workPaths(loaded).take,
26
+ durationMs: ev.video.durationMs,
27
+ steps: ev.steps.length,
28
+ });
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) {
57
+ const { renderDemo } = await import('../pipeline');
58
+ return renderDemo(loaded, { ...renderOptions(o), onProgress: progressFor(o.json), log: o.json ? undefined : say });
59
+ }
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
+
15
66
  const program = new Command('showtime')
16
67
  .description('AI-directed product demo videos: JSON script in, polished MP4 out')
17
68
  .version(version);
@@ -21,23 +72,27 @@ program
21
72
  .description('download the browsers showTime records with and link the skill into Claude Code and Codex')
22
73
  .option('--agent <agent>', 'claude, codex or all', 'all')
23
74
  .option('--no-browsers', 'only link the skill')
24
- .action(async (o: { agent: string; browsers: boolean }) => {
75
+ .option('--json', 'print the result as JSON')
76
+ .action(async (o: { agent: string; browsers: boolean; json?: boolean }) => {
25
77
  const { AGENTS, installBrowsers, linkSkill } = await import('../setup');
26
78
  const agents = o.agent === 'all' ? AGENTS : AGENTS.filter((a) => a === o.agent);
27
79
  if (!agents.length) throw new Error(`--agent must be ${AGENTS.join(', ')} or all`);
28
80
  if (o.browsers) {
29
- say('Downloading Chromium and the Remotion renderer browser…');
30
- await installBrowsers();
81
+ if (!o.json) say('Downloading Chromium and the Remotion renderer browser…');
82
+ await installBrowsers({ quiet: o.json });
31
83
  }
32
- for (const dest of linkSkill(agents)) say(`✓ skill linked: ${dest}`);
84
+ const links = linkSkill(agents);
85
+ if (o.json) return emit({ links, browsers: o.browsers });
86
+ for (const dest of links) say(`✓ skill linked: ${dest}`);
33
87
  say('Ready. Ask your agent for a demo, or run `showtime make <script>`.');
34
88
  });
35
89
 
36
90
  program
37
91
  .command('schema')
38
92
  .description('print the JSON Schema of demo scripts')
93
+ .option('--json', 'accepted for symmetry; the output is always JSON')
39
94
  .action(() => {
40
- say(JSON.stringify(scriptJsonSchema(), null, 2));
95
+ emit(scriptJsonSchema());
41
96
  });
42
97
 
43
98
  program
@@ -49,64 +104,94 @@ program
49
104
  const loaded = await loadScript(file);
50
105
  const { check } = await import('../runner/check');
51
106
  const report = await check(loaded, { headed: o.headed, log: o.json ? undefined : say });
52
- say(o.json ? JSON.stringify(report, null, 2) : formatCheck(report));
107
+ if (o.json) emit(report);
108
+ else say(formatCheck(report));
53
109
  if (!report.ok) process.exitCode = 1;
54
110
  });
55
111
 
112
+ program
113
+ .command('lint <script>')
114
+ .description('flag script problems, and timing problems when a fresh take exists')
115
+ .option('--json', 'print { issues, timing } as JSON')
116
+ .action(async (file: string, o: { json?: boolean }) => {
117
+ const loaded = await loadScript(file);
118
+ const { lintScript, lintTiming } = await import('../lint');
119
+ const issues = lintScript(loaded.script);
120
+ const { loadTake } = await import('../take');
121
+ const events = await loadTake(loaded).catch(() => null);
122
+ if (events) {
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));
127
+ }
128
+ if (o.json) emit({ issues, timing: events !== null });
129
+ else say(formatLint(issues, events !== null));
130
+ if (issues.some((i) => i.level === 'warn')) process.exitCode = 1;
131
+ });
132
+
56
133
  program
57
134
  .command('record <script>')
58
135
  .description('record a take (raw video + event log) into .showtime/<name>/take')
59
136
  .option('--headed', 'show the browser')
60
137
  .option('--keep-frames', 'keep the captured JPEG frames')
61
- .action(async (file: string, o: { headed?: boolean; keepFrames?: boolean }) => {
138
+ .option('--json', 'print { take, durationMs, steps } as JSON')
139
+ .action(async (file: string, o: { headed?: boolean; keepFrames?: boolean; json?: boolean }) => {
62
140
  const loaded = await loadScript(file);
63
141
  const { record } = await import('../runner/record');
64
- const ev = await record(loaded, { headed: o.headed, keepFrames: o.keepFrames, log: say });
65
- say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s, ${ev.steps.length} steps → ${workPaths(loaded).take}`);
142
+ const ev = await record(loaded, { headed: o.headed, keepFrames: o.keepFrames, log: o.json ? undefined : say });
143
+ const take = takeSummary(loaded, ev);
144
+ if (o.json) emit(take);
145
+ else say(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s, ${take.steps} steps → ${take.take}`);
66
146
  });
67
147
 
68
- program
69
- .command('render <script>')
70
- .description('render the polished video from the last take (no re-recording)')
71
- .option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
72
- .action(async (file: string, o: { out?: string }) => {
73
- const loaded = await loadScript(file);
74
- const { renderDemo } = await import('../pipeline');
75
- const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
76
- say(`\n✓ ${out}`);
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);
154
+ const result = await render(await loadScript(file), o);
155
+ if (o.json) emit(result);
156
+ else sayRendered(result);
77
157
  });
78
158
 
79
159
  program
80
160
  .command('frames <script>')
81
- .description('render QA stills (intro + one per step) for visual review')
82
- .action(async (file: string) => {
83
- const loaded = await loadScript(file);
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')
163
+ .option('--json', 'print { sheet, frames } as JSON')
164
+ .action(async (file: string, o: { aspect: string; json?: boolean }) => {
165
+ const aspect = aspectOf(o.aspect);
84
166
  const { renderQaFrames } = await import('../pipeline');
85
- const files = await renderQaFrames(loaded);
86
- say(files.join('\n'));
167
+ const { sheet, frames } = await renderQaFrames(await loadScript(file), { aspect, log: o.json ? undefined : say });
168
+ if (o.json) return emit({ sheet, frames });
169
+ say(`sheet: ${sheet}`);
170
+ say(frames.join('\n'));
87
171
  });
88
172
 
89
- program
90
- .command('make <script>')
91
- .description('check → record → render')
92
- .option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
173
+ withRenderFlags(program.command('make <script>').description('check → record → render'))
93
174
  .option('--headed', 'show the browser')
94
- .action(async (file: string, o: { out?: string; headed?: boolean }) => {
175
+ .option('--json', 'print { check, take, video } as JSON')
176
+ .action(async (file: string, o: RenderFlags & { headed?: boolean }) => {
177
+ renderOptions(o);
95
178
  const loaded = await loadScript(file);
179
+ const log = o.json ? undefined : say;
96
180
  const { check } = await import('../runner/check');
97
181
  const report = await check(loaded, { headed: o.headed });
98
182
  if (!report.ok) {
99
- say(formatCheck(report));
183
+ if (o.json) emit({ check: report });
184
+ else say(formatCheck(report));
100
185
  process.exitCode = 1;
101
186
  return;
102
187
  }
103
- say(`✓ check passed (${report.steps.length} steps)`);
188
+ log?.(`✓ check passed (${report.steps.length} steps)`);
104
189
  const { record } = await import('../runner/record');
105
- const ev = await record(loaded, { headed: o.headed, log: say });
106
- say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s`);
107
- const { renderDemo } = await import('../pipeline');
108
- const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
109
- say(`\n✓ ${out}`);
190
+ const take = takeSummary(loaded, await record(loaded, { headed: o.headed, log }));
191
+ log?.(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s`);
192
+ const video = await render(loaded, o);
193
+ if (o.json) emit({ check: report, take, video });
194
+ else sayRendered(video);
110
195
  });
111
196
 
112
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
+ }
package/src/lint.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { Script, Step } from './script/schema';
2
+ import { stepKey } from './timeline/narration';
3
+ import { outputDurationMs, sourceToOutput } from './timeline/timemap';
4
+ import type { StepEvent, Timeline } from './timeline/types';
5
+
6
+ export interface Issue {
7
+ rule:
8
+ | 'caption-length'
9
+ | 'pause-for-loading'
10
+ | 'show-ignored'
11
+ | 'no-intro'
12
+ | 'caption-too-short'
13
+ | 'video-length'
14
+ | 'narration-stretch';
15
+ level: 'warn' | 'info';
16
+ /** `scene#step` (1-based), `setup#n`, or `script` */
17
+ where: string;
18
+ message: string;
19
+ }
20
+
21
+ const MAX_CAPTION_WORDS = 6;
22
+ const LOADING_PAUSE_MS = 1500;
23
+ /** Holding the frame this long for a voice is worth a note; twice as long, a warning. */
24
+ const STRETCH_INFO_MS = 1500;
25
+ const STRETCH_WARN_MS = 3000;
26
+ const ACTIONS = new Set<Step['do']>(['click', 'press', 'type', 'select']);
27
+ const TARGETED = new Set<Step['do']>(['click', 'type', 'hover', 'select']);
28
+
29
+ const words = (text: string) => text.trim().split(/\s+/).filter(Boolean).length;
30
+ const hasTarget = (s: Step) => TARGETED.has(s.do) || (s.do === 'scroll' && s.target !== undefined);
31
+ /** Reading time for a caption: half a second to notice it, a quarter per word, never under 1.2 s. */
32
+ export const readingMs = (text: string) => Math.max(1200, 500 + 250 * words(text));
33
+
34
+ /** Checks that need only the script. */
35
+ export function lintScript(script: Script): Issue[] {
36
+ const issues: Issue[] = [];
37
+ if (!script.meta.title) {
38
+ issues.push({ rule: 'no-intro', level: 'info', where: 'script', message: 'No meta.title, so the video starts without an intro card.' });
39
+ }
40
+ script.setup.forEach((step, i) => {
41
+ if (step.show) {
42
+ issues.push({
43
+ rule: 'show-ignored',
44
+ level: 'warn',
45
+ where: `setup#${i + 1}`,
46
+ message: 'Setup steps run before the camera starts, so their `show` does nothing.',
47
+ });
48
+ }
49
+ });
50
+ for (const scene of script.scenes) {
51
+ scene.steps.forEach((step, i) => {
52
+ const where = `${scene.id}#${i + 1}`;
53
+ const caption = step.show?.caption;
54
+ if (caption && words(caption) > MAX_CAPTION_WORDS) {
55
+ issues.push({
56
+ rule: 'caption-length',
57
+ level: 'warn',
58
+ where,
59
+ message: `Caption has ${words(caption)} words; keep it to ${MAX_CAPTION_WORDS} or fewer: "${caption}"`,
60
+ });
61
+ }
62
+ const zooms = typeof step.show?.zoom === 'number';
63
+ if ((zooms || step.show?.spotlight) && !hasTarget(step)) {
64
+ issues.push({
65
+ rule: 'show-ignored',
66
+ level: 'warn',
67
+ where,
68
+ message: `\`${step.do}\` has no target, so ${zooms ? 'zoom' : 'spotlight'} does nothing here.`,
69
+ });
70
+ }
71
+ const prev = scene.steps[i - 1];
72
+ if (step.do === 'pause' && step.ms >= LOADING_PAUSE_MS && prev && ACTIONS.has(prev.do)) {
73
+ issues.push({
74
+ rule: 'pause-for-loading',
75
+ level: 'warn',
76
+ where,
77
+ message: 'A long pause right after an action usually waits for loading. Use { "do": "wait", "for": "<what appears>" } so it is sped up.',
78
+ });
79
+ }
80
+ });
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ /** Checks on the edited result: caption reading time and overall length (output time). */
86
+ export function lintTiming(timeline: Timeline, steps: StepEvent[]): Issue[] {
87
+ const issues: Issue[] = [];
88
+ for (const c of timeline.captions) {
89
+ const shown = sourceToOutput(timeline.segments, c.endMs) - sourceToOutput(timeline.segments, c.startMs);
90
+ const need = readingMs(c.text);
91
+ if (shown < need) {
92
+ const step = steps.find((s) => s.startMs === c.startMs);
93
+ issues.push({
94
+ rule: 'caption-too-short',
95
+ level: 'warn',
96
+ where: step ? `${step.sceneId}#${step.index + 1}` : 'script',
97
+ // Round shown down and needed up, so the numbers never read as equal.
98
+ message: `"${c.text}" is on screen for ${(Math.floor(shown / 100) / 10).toFixed(1)} s but needs about ${(Math.ceil(need / 100) / 10).toFixed(1)} s to read. Add show.hold or slow the step down.`,
99
+ });
100
+ }
101
+ }
102
+ // Each line's holds sit between its step's start and the next narrated step's start.
103
+ const starts = timeline.narration.map((n) => steps.find((s) => stepKey(s.sceneId, s.index) === n.where)?.startMs ?? Infinity);
104
+ timeline.narration.forEach((n, i) => {
105
+ const until = starts[i + 1] ?? Infinity;
106
+ const held = timeline.segments
107
+ .filter((s) => s.speed === 0 && s.srcStartMs >= starts[i] && s.srcStartMs < until)
108
+ .reduce((ms, s) => ms + (s.holdMs ?? 0), 0);
109
+ if (held > STRETCH_INFO_MS) {
110
+ issues.push({
111
+ rule: 'narration-stretch',
112
+ level: held > STRETCH_WARN_MS ? 'warn' : 'info',
113
+ where: n.where,
114
+ message: `"${n.text}" holds the frame for ${(held / 1000).toFixed(1)} s while the voice finishes. Shorten the line or slow the steps.`,
115
+ });
116
+ }
117
+ });
118
+ const totalMs = (timeline.intro?.durationMs ?? 0) + outputDurationMs(timeline.segments) + (timeline.outro?.durationMs ?? 0);
119
+ if (totalMs > 60_000) {
120
+ issues.push({
121
+ rule: 'video-length',
122
+ level: totalMs > 120_000 ? 'warn' : 'info',
123
+ where: 'script',
124
+ message: `The video runs ${Math.round(totalMs / 1000)} s; pitch demos land best under a minute.`,
125
+ });
126
+ }
127
+ return issues;
128
+ }
package/src/logo.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const TYPES: Record<string, string> = {
5
+ '.svg': 'image/svg+xml',
6
+ '.png': 'image/png',
7
+ '.jpg': 'image/jpeg',
8
+ '.jpeg': 'image/jpeg',
9
+ '.webp': 'image/webp',
10
+ };
11
+ const MAX_BYTES = 2_000_000;
12
+
13
+ /** The logo file as a data URI, so timeline.json carries it to the renderer without extra files. */
14
+ export async function logoDataUri(file: string): Promise<string> {
15
+ const type = TYPES[path.extname(file).toLowerCase()];
16
+ if (!type) throw new Error(`meta.logo must be an SVG, PNG, JPEG or WebP file: ${file}`);
17
+ const data = await readFile(file);
18
+ if (data.length > MAX_BYTES) throw new Error(`meta.logo is larger than 2 MB: ${file}`);
19
+ return `data:${type};base64,${data.toString('base64')}`;
20
+ }
package/src/paths.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  import path from 'node:path';
2
2
  import type { LoadedScript } from './script/load';
3
+ import type { Aspect } from './timeline/types';
3
4
 
4
5
  export interface WorkPaths {
5
6
  root: string;
6
7
  check: string;
7
8
  take: string;
9
+ /** a take being recorded; swapped in for `take` only when it succeeds */
10
+ takeTmp: string;
8
11
  rawFrames: string;
9
12
  raw: string;
10
13
  events: string;
11
14
  timeline: string;
12
15
  stills: string;
16
+ /** contact sheet of the stills */
17
+ sheet: string;
18
+ /** voice cache and the mixed soundtrack */
19
+ audio: string;
20
+ mix: string;
13
21
  video: string;
14
22
  }
15
23
 
@@ -20,11 +28,22 @@ export function workPaths(loaded: LoadedScript): WorkPaths {
20
28
  root,
21
29
  check: path.join(root, 'check'),
22
30
  take,
31
+ takeTmp: path.join(root, 'take.tmp'),
23
32
  rawFrames: path.join(take, 'frames'),
24
33
  raw: path.join(take, 'raw.mp4'),
25
34
  events: path.join(take, 'events.json'),
26
35
  timeline: path.join(root, 'timeline.json'),
27
36
  stills: path.join(root, 'frames'),
37
+ sheet: path.join(root, 'frames', 'sheet.png'),
38
+ audio: path.join(root, 'audio'),
39
+ mix: path.join(root, 'audio', 'mix.wav'),
28
40
  video: path.join(loaded.dir, `${loaded.name}.mp4`),
29
41
  };
30
42
  }
43
+
44
+ /** QA stills of one format: `frames/` for 16:9, `frames-9x16/` and `frames-1x1/` for the crops. */
45
+ export function stillsFor(paths: WorkPaths, aspect: Aspect): { dir: string; sheet: string } {
46
+ if (aspect === '16:9') return { dir: paths.stills, sheet: paths.sheet };
47
+ const dir = `${paths.stills}-${aspect.replace(':', 'x')}`;
48
+ return { dir, sheet: path.join(dir, 'sheet.png') };
49
+ }
package/src/pipeline.ts CHANGED
@@ -1,38 +1,116 @@
1
1
  import { mkdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { workPaths } from './paths';
3
+ import { mixSoundtrack } from './audio/soundtrack';
4
+ import { ensureVoices, type VoiceClip } from './audio/tts';
5
+ import { ANIMATION_WIDTH, defaultOutput, formatOf, toGif, toWebp, type Format } from './convert';
6
+ import { probeVideo } from './ffmpeg';
7
+ import { logoDataUri } from './logo';
8
+ import { stillsFor, workPaths } from './paths';
4
9
  import { renderStills, renderVideo } from './render/render';
10
+ import { contactSheet } from './sheet';
5
11
  import type { LoadedScript } from './script/load';
6
12
  import { loadTake } from './take';
7
13
  import { buildTimeline } from './timeline/build';
8
- import { qaShots } from './timeline/frames';
9
- import type { TakeEvents, Timeline } from './timeline/types';
14
+ import { introFrames, qaShots } from './timeline/frames';
15
+ import { toSrt } from './timeline/narration';
16
+ import type { Aspect, TakeEvents, Timeline } from './timeline/types';
10
17
 
11
- export async function timelineFor(loaded: LoadedScript): Promise<{ timeline: Timeline; events: TakeEvents }> {
18
+ export interface TimelineOptions {
19
+ log?: (line: string) => void;
20
+ aspect?: Aspect;
21
+ /** false: leave narration out (no synthesis, no holds), for outputs nobody hears */
22
+ voices?: boolean;
23
+ }
24
+
25
+ /** The timeline for the last take, with narration synthesized (cached) and its holds in place. */
26
+ export async function timelineFor(
27
+ loaded: LoadedScript,
28
+ opts: TimelineOptions = {},
29
+ ): Promise<{ timeline: Timeline; events: TakeEvents; clips: Record<string, VoiceClip> }> {
12
30
  const events = await loadTake(loaded);
13
- const timeline = buildTimeline(loaded.script, events);
31
+ const clips = opts.voices === false ? {} : await ensureVoices(loaded, { log: opts.log });
32
+ const { logo } = loaded.script.meta;
33
+ const timeline = buildTimeline(loaded.script, events, {
34
+ aspect: opts.aspect,
35
+ logo: logo ? await logoDataUri(logo) : undefined,
36
+ voices: Object.fromEntries(Object.entries(clips).map(([where, c]) => [where, c.durationMs])),
37
+ });
38
+ await writeTimeline(loaded, timeline);
39
+ return { timeline, events, clips };
40
+ }
41
+
42
+ async function writeTimeline(loaded: LoadedScript, timeline: Timeline) {
14
43
  const paths = workPaths(loaded);
15
44
  await mkdir(paths.root, { recursive: true });
16
45
  await writeFile(paths.timeline, JSON.stringify(timeline, null, 2));
17
- return { timeline, events };
18
46
  }
19
47
 
20
- export async function renderDemo(
21
- loaded: LoadedScript,
22
- opts: { out?: string; onProgress?: (progress: number) => void } = {},
23
- ): Promise<string> {
24
- const { timeline } = await timelineFor(loaded);
48
+ export interface RenderOptions extends TimelineOptions {
49
+ /** default: `<name>[-9x16|-1x1].<format>` next to the script */
50
+ out?: string;
51
+ /** default: from `out`'s extension, else mp4 */
52
+ format?: Format;
53
+ /** GIF/WebP width in px */
54
+ width?: number;
55
+ onProgress?: (progress: number) => void;
56
+ /** false: no soundtrack, even with narration, music or sounds */
57
+ audio?: boolean;
58
+ }
59
+
60
+ export interface RenderResult {
61
+ video: string;
62
+ durationMs: number;
63
+ /** the .srt written next to an MP4 with narration subtitles */
64
+ subtitles: string | null;
65
+ }
66
+
67
+ export async function renderDemo(loaded: LoadedScript, opts: RenderOptions = {}): Promise<RenderResult> {
68
+ const aspect = opts.aspect ?? '16:9';
69
+ const format = opts.format ?? formatOf(opts.out);
70
+ const out = path.resolve(opts.out ?? defaultOutput(loaded, aspect, format));
71
+ // A silent output (GIF, WebP, --no-audio) only needs the voices to time burned-in subtitles.
72
+ const silent = format !== 'mp4' || opts.audio === false;
73
+ const voices = !silent || loaded.script.audio.subtitles === 'burn';
74
+ const { timeline, clips } = await timelineFor(loaded, { ...opts, voices });
25
75
  const paths = workPaths(loaded);
26
- const out = path.resolve(opts.out ?? paths.video);
27
76
  await mkdir(path.dirname(out), { recursive: true });
28
- await renderVideo(timeline, paths.take, out, opts.onProgress);
29
- return out;
77
+
78
+ if (format !== 'mp4') {
79
+ // GIF and WebP are cut from a silent MP4 render.
80
+ const mp4 = path.join(paths.root, `render-${aspect.replace(':', 'x')}.mp4`);
81
+ await renderVideo(timeline, paths.root, mp4, opts.onProgress);
82
+ const width = opts.width ?? ANIMATION_WIDTH[aspect];
83
+ await (format === 'gif' ? toGif(mp4, out, width) : toWebp(mp4, out, width));
84
+ return { video: out, durationMs: (await probeVideo(mp4)).durationMs, subtitles: null };
85
+ }
86
+
87
+ if (opts.audio !== false) {
88
+ const src = await mixSoundtrack(loaded, timeline, clips);
89
+ if (src) {
90
+ timeline.audio = { src };
91
+ await writeTimeline(loaded, timeline);
92
+ }
93
+ }
94
+ await renderVideo(timeline, paths.root, out, opts.onProgress);
95
+ let subtitles: string | null = null;
96
+ if (timeline.subtitles.length) {
97
+ subtitles = out.replace(/\.[^./\\]+$/, '') + '.srt';
98
+ await writeFile(subtitles, toSrt(timeline.subtitles, (introFrames(timeline) * 1000) / timeline.fps));
99
+ }
100
+ return { video: out, durationMs: (await probeVideo(out)).durationMs, subtitles };
30
101
  }
31
102
 
32
- export async function renderQaFrames(loaded: LoadedScript): Promise<string[]> {
33
- const { timeline, events } = await timelineFor(loaded);
103
+ /** QA stills (intro, one per step, outro) and a contact sheet of all of them. */
104
+ export async function renderQaFrames(loaded: LoadedScript, opts: TimelineOptions = {}): Promise<{ sheet: string; frames: string[] }> {
105
+ const { timeline, events } = await timelineFor(loaded, opts);
34
106
  const paths = workPaths(loaded);
35
- await rm(paths.stills, { recursive: true, force: true });
36
- await mkdir(paths.stills, { recursive: true });
37
- return renderStills(timeline, paths.take, paths.stills, qaShots(timeline, events.steps));
107
+ const stills = stillsFor(paths, timeline.aspect);
108
+ await rm(stills.dir, { recursive: true, force: true });
109
+ await mkdir(stills.dir, { recursive: true });
110
+ const frames = await renderStills(timeline, paths.root, stills.dir, qaShots(timeline, events.steps));
111
+ const sheet = await contactSheet(
112
+ frames.map((file) => ({ file, label: path.basename(file, '.png') })),
113
+ stills.sheet,
114
+ );
115
+ return { sheet, frames };
38
116
  }