@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/src/lint.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import type { Script, Step } from './script/schema';
2
+ import { stepKey } from './timeline/narration';
2
3
  import { outputDurationMs, sourceToOutput } from './timeline/timemap';
3
4
  import type { StepEvent, Timeline } from './timeline/types';
4
5
 
5
6
  export interface Issue {
6
- rule: 'caption-length' | 'pause-for-loading' | 'show-ignored' | 'no-intro' | 'caption-too-short' | 'video-length';
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';
7
15
  level: 'warn' | 'info';
8
16
  /** `scene#step` (1-based), `setup#n`, or `script` */
9
17
  where: string;
@@ -12,6 +20,9 @@ export interface Issue {
12
20
 
13
21
  const MAX_CAPTION_WORDS = 6;
14
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;
15
26
  const ACTIONS = new Set<Step['do']>(['click', 'press', 'type', 'select']);
16
27
  const TARGETED = new Set<Step['do']>(['click', 'type', 'hover', 'select']);
17
28
 
@@ -88,6 +99,22 @@ export function lintTiming(timeline: Timeline, steps: StepEvent[]): Issue[] {
88
99
  });
89
100
  }
90
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
+ });
91
118
  const totalMs = (timeline.intro?.durationMs ?? 0) + outputDurationMs(timeline.segments) + (timeline.outro?.durationMs ?? 0);
92
119
  if (totalMs > 60_000) {
93
120
  issues.push({
package/src/paths.ts CHANGED
@@ -1,5 +1,6 @@
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;
@@ -14,6 +15,9 @@ export interface WorkPaths {
14
15
  stills: string;
15
16
  /** contact sheet of the stills */
16
17
  sheet: string;
18
+ /** voice cache and the mixed soundtrack */
19
+ audio: string;
20
+ mix: string;
17
21
  video: string;
18
22
  }
19
23
 
@@ -31,6 +35,15 @@ export function workPaths(loaded: LoadedScript): WorkPaths {
31
35
  timeline: path.join(root, 'timeline.json'),
32
36
  stills: path.join(root, 'frames'),
33
37
  sheet: path.join(root, 'frames', 'sheet.png'),
38
+ audio: path.join(root, 'audio'),
39
+ mix: path.join(root, 'audio', 'mix.wav'),
34
40
  video: path.join(loaded.dir, `${loaded.name}.mp4`),
35
41
  };
36
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,47 +1,116 @@
1
1
  import { mkdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
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';
3
7
  import { logoDataUri } from './logo';
4
- import { workPaths } from './paths';
8
+ import { stillsFor, workPaths } from './paths';
5
9
  import { renderStills, renderVideo } from './render/render';
6
10
  import { contactSheet } from './sheet';
7
11
  import type { LoadedScript } from './script/load';
8
12
  import { loadTake } from './take';
9
13
  import { buildTimeline } from './timeline/build';
10
- import { qaShots } from './timeline/frames';
11
- 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';
12
17
 
13
- 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> }> {
14
30
  const events = await loadTake(loaded);
31
+ const clips = opts.voices === false ? {} : await ensureVoices(loaded, { log: opts.log });
15
32
  const { logo } = loaded.script.meta;
16
- const timeline = buildTimeline(loaded.script, events, { logo: logo ? await logoDataUri(logo) : undefined });
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) {
17
43
  const paths = workPaths(loaded);
18
44
  await mkdir(paths.root, { recursive: true });
19
45
  await writeFile(paths.timeline, JSON.stringify(timeline, null, 2));
20
- return { timeline, events };
21
46
  }
22
47
 
23
- export async function renderDemo(
24
- loaded: LoadedScript,
25
- opts: { out?: string; onProgress?: (progress: number) => void } = {},
26
- ): Promise<string> {
27
- 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 });
28
75
  const paths = workPaths(loaded);
29
- const out = path.resolve(opts.out ?? paths.video);
30
76
  await mkdir(path.dirname(out), { recursive: true });
31
- await renderVideo(timeline, paths.take, out, opts.onProgress);
32
- 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 };
33
101
  }
34
102
 
35
103
  /** QA stills (intro, one per step, outro) and a contact sheet of all of them. */
36
- export async function renderQaFrames(loaded: LoadedScript): Promise<{ sheet: string; frames: string[] }> {
37
- const { timeline, events } = await timelineFor(loaded);
104
+ export async function renderQaFrames(loaded: LoadedScript, opts: TimelineOptions = {}): Promise<{ sheet: string; frames: string[] }> {
105
+ const { timeline, events } = await timelineFor(loaded, opts);
38
106
  const paths = workPaths(loaded);
39
- await rm(paths.stills, { recursive: true, force: true });
40
- await mkdir(paths.stills, { recursive: true });
41
- const frames = await 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));
42
111
  const sheet = await contactSheet(
43
112
  frames.map((file) => ({ file, label: path.basename(file, '.png') })),
44
- paths.sheet,
113
+ stills.sheet,
45
114
  );
46
115
  return { sheet, frames };
47
116
  }
@@ -20,7 +20,7 @@ export const Intro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t,
20
20
  textAlign: 'center',
21
21
  }}
22
22
  >
23
- <div style={{ opacity: Math.min(enter, exit), transform: `scale(${0.94 + 0.06 * enter})` }}>
23
+ <div style={{ maxWidth: '86%', opacity: Math.min(enter, exit), transform: `scale(${0.94 + 0.06 * enter})` }}>
24
24
  <Logo src={t.logo} />
25
25
  <div style={{ fontSize: 88, fontWeight: 800, letterSpacing: -1.5 }}>{t.intro?.title}</div>
26
26
  {t.intro?.subtitle ? <div style={{ marginTop: 18, fontSize: 36, opacity: 0.75 }}>{t.intro.subtitle}</div> : null}
@@ -11,7 +11,7 @@ export const Outro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t,
11
11
  <AbsoluteFill
12
12
  style={{ background: t.style.background, fontFamily: t.style.font, alignItems: 'center', justifyContent: 'center', color: '#fff', textAlign: 'center' }}
13
13
  >
14
- <div style={{ opacity: enter, transform: `translateY(${(1 - enter) * 24}px)` }}>
14
+ <div style={{ maxWidth: '86%', opacity: enter, transform: `translateY(${(1 - enter) * 24}px)` }}>
15
15
  <Logo src={t.logo} />
16
16
  <div style={{ fontSize: 80, fontWeight: 800, letterSpacing: -1.5 }}>{o?.title}</div>
17
17
  {o?.subtitle ? <div style={{ marginTop: 18, fontSize: 34, opacity: 0.75 }}>{o.subtitle}</div> : null}
@@ -1,5 +1,6 @@
1
- import type { FC } from 'react';
1
+ import type { CSSProperties, FC } from 'react';
2
2
  import { ripplesAt } from '../../timeline/cursor';
3
+ import { CAPTION_FADE_MS, SPOTLIGHT_FADE_MS } from '../../timeline/fades';
3
4
  import { clamp, easeOutCubic } from '../../timeline/math';
4
5
  import type { Timeline } from '../../timeline/types';
5
6
 
@@ -25,7 +26,7 @@ export const Spotlights: FC<LayerProps> = ({ timeline, srcMs }) => (
25
26
  boxShadow: '0 0 0 9999px rgba(2, 6, 23, 0.55)',
26
27
  outline: `3px solid ${timeline.style.accent}`,
27
28
  outlineOffset: 2,
28
- opacity: envelope(srcMs, s.startMs, s.endMs, 250),
29
+ opacity: envelope(srcMs, s.startMs, s.endMs, SPOTLIGHT_FADE_MS),
29
30
  }}
30
31
  />
31
32
  ))}
@@ -55,23 +56,19 @@ export const Ripples: FC<LayerProps> = ({ timeline, srcMs }) => (
55
56
  </>
56
57
  );
57
58
 
58
- /** `top` is where a top caption's box starts (output px); bottom captions sit at a fixed inset. */
59
- export const Captions: FC<LayerProps & { top: number }> = ({ timeline, srcMs, top }) => {
59
+ /** Where an overlay row sits: an inset from the top or bottom, or a band (top + height / top + bottom). */
60
+ type Place = Pick<CSSProperties, 'top' | 'bottom' | 'height' | 'alignItems'>;
61
+
62
+ const row = (place: Place): CSSProperties => ({ position: 'absolute', left: 0, right: 0, display: 'flex', justifyContent: 'center', ...place });
63
+
64
+ /** The active caption, placed at `top` or `bottom` by its position. */
65
+ export const Captions: FC<LayerProps & { top: Place; bottom: Place; fontSize: number }> = ({ timeline, srcMs, top, bottom, fontSize }) => {
60
66
  const active = timeline.captions.filter((c) => srcMs >= c.startMs && srcMs <= c.endMs);
61
67
  const c = active[active.length - 1];
62
68
  if (!c) return null;
63
- const o = envelope(srcMs, c.startMs, c.endMs, 220);
69
+ const o = envelope(srcMs, c.startMs, c.endMs, CAPTION_FADE_MS);
64
70
  return (
65
- <div
66
- style={{
67
- position: 'absolute',
68
- left: 0,
69
- right: 0,
70
- ...(c.position === 'top' ? { top } : { bottom: Math.round(timeline.output.height * 0.075) }),
71
- display: 'flex',
72
- justifyContent: 'center',
73
- }}
74
- >
71
+ <div style={row(c.position === 'top' ? top : bottom)}>
75
72
  <div
76
73
  style={{
77
74
  maxWidth: '70%',
@@ -80,7 +77,7 @@ export const Captions: FC<LayerProps & { top: number }> = ({ timeline, srcMs, to
80
77
  background: 'rgba(15, 23, 42, 0.9)',
81
78
  color: '#fff',
82
79
  fontFamily: timeline.style.font,
83
- fontSize: 36,
80
+ fontSize,
84
81
  fontWeight: 600,
85
82
  lineHeight: 1.25,
86
83
  textAlign: 'center',
@@ -94,3 +91,30 @@ export const Captions: FC<LayerProps & { top: number }> = ({ timeline, srcMs, to
94
91
  </div>
95
92
  );
96
93
  };
94
+
95
+ /** Burned-in narration subtitles. `videoMs` is output time from the start of the video. */
96
+ export const Subtitles: FC<{ timeline: Timeline; videoMs: number; place: Place; fontSize: number }> = ({ timeline, videoMs, place, fontSize }) => {
97
+ if (!timeline.subtitlesBurned) return null;
98
+ const c = timeline.subtitles.find((s) => videoMs >= s.startMs && videoMs < s.endMs);
99
+ if (!c) return null;
100
+ return (
101
+ <div style={row(place)}>
102
+ <div
103
+ style={{
104
+ maxWidth: '80%',
105
+ padding: '10px 24px',
106
+ borderRadius: 12,
107
+ background: 'rgba(0, 0, 0, 0.72)',
108
+ color: '#fff',
109
+ fontFamily: timeline.style.font,
110
+ fontSize,
111
+ fontWeight: 500,
112
+ lineHeight: 1.35,
113
+ textAlign: 'center',
114
+ }}
115
+ >
116
+ {c.text}
117
+ </div>
118
+ </div>
119
+ );
120
+ };
@@ -1,20 +1,28 @@
1
1
  import type { FC } from 'react';
2
- import { AbsoluteFill, Easing, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
2
+ import { AbsoluteFill, Easing, Freeze, Html5Audio, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
3
3
  import { cameraAt, cameraTransform } from '../../timeline/camera';
4
4
  import { introFrames, segmentPlan, sourceMsAtFrame, videoEndFrame } from '../../timeline/frames';
5
- import { BROWSER_BAR, windowLayout } from '../../timeline/layout';
5
+ import { BROWSER_BAR, viewLayout } from '../../timeline/layout';
6
6
  import type { Timeline } from '../../timeline/types';
7
7
  import { BrowserBar } from './BrowserBar';
8
8
  import { Cursor } from './Cursor';
9
9
  import { Intro } from './Intro';
10
10
  import { Outro } from './Outro';
11
- import { Captions, Ripples, Spotlights } from './Overlays';
11
+ import { Captions, Ripples, Spotlights, Subtitles } from './Overlays';
12
12
 
13
13
  export type ShowtimeVideoProps = { timeline: Timeline };
14
14
 
15
15
  const SHADOW = '0 30px 80px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.25)';
16
16
 
17
- export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
17
+ /** The soundtrack plays across every frame; the picture switches between cards and the take. */
18
+ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => (
19
+ <>
20
+ {t.audio ? <Html5Audio src={staticFile(t.audio.src)} /> : null}
21
+ <Picture timeline={t} />
22
+ </>
23
+ );
24
+
25
+ const Picture: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
18
26
  const frame = useCurrentFrame();
19
27
  const intro = introFrames(t);
20
28
  if (frame < intro) return <Intro timeline={t} frame={frame} />;
@@ -22,9 +30,15 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
22
30
  if (t.outro && frame >= end) return <Outro timeline={t} frame={frame - end} />;
23
31
 
24
32
  const src = sourceMsAtFrame(t, frame) ?? 0;
25
- const win = windowLayout(t.output, t.stage, t.style.padding, t.chrome ? BROWSER_BAR : 0);
33
+ const win = viewLayout(t.output, t.stage, t.style.padding, t.chrome ? BROWSER_BAR : 0, t.aspect);
26
34
  const cam = cameraAt(t.camera, src);
27
- const k = win.width / t.stage.width;
35
+ const { content } = win;
36
+ const k = content.width / t.stage.width;
37
+ const H = t.output.height;
38
+ // 9:16 puts captions and subtitles in the bands above and below the window; the others overlay it.
39
+ const bands = t.aspect === '9:16';
40
+ const above = { top: 0, height: win.y - win.bar, alignItems: 'center' } as const;
41
+ const below = { top: win.y + win.height, bottom: 0, alignItems: 'center' } as const;
28
42
  // After an intro card the window rises into place instead of cutting in.
29
43
  const enter = intro
30
44
  ? interpolate(frame - intro, [0, 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic) })
@@ -56,23 +70,28 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
56
70
  position: 'absolute',
57
71
  left: 0,
58
72
  top: 0,
59
- width: win.width,
60
- height: win.height,
73
+ width: content.width,
74
+ height: content.height,
61
75
  transformOrigin: '0 0',
62
- transform: cameraTransform(cam, win.width, win.height),
76
+ transform: cameraTransform(cam, win.width, win.height, content),
63
77
  }}
64
78
  >
65
- {segmentPlan(t).map((p) => (
66
- <Sequence key={p.fromFrame} from={p.fromFrame} durationInFrames={p.durationInFrames} layout="none">
79
+ {segmentPlan(t).map((p) => {
80
+ const video = (
67
81
  <OffthreadVideo
68
82
  src={staticFile(t.video.src)}
69
83
  trimBefore={p.trimBefore}
70
- playbackRate={p.speed}
84
+ playbackRate={p.speed || 1}
71
85
  muted
72
- style={{ position: 'absolute', left: 0, top: 0, width: win.width, height: win.height }}
86
+ style={{ position: 'absolute', left: 0, top: 0, width: content.width, height: content.height }}
73
87
  />
74
- </Sequence>
75
- ))}
88
+ );
89
+ return (
90
+ <Sequence key={p.fromFrame} from={p.fromFrame} durationInFrames={p.durationInFrames} layout="none">
91
+ {p.speed === 0 ? <Freeze frame={0}>{video}</Freeze> : video}
92
+ </Sequence>
93
+ );
94
+ })}
76
95
  <div
77
96
  style={{
78
97
  position: 'absolute',
@@ -92,7 +111,19 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
92
111
  </div>
93
112
  </div>
94
113
  <div style={{ opacity: leave }}>
95
- <Captions timeline={t} srcMs={src} top={win.y + Math.round(t.output.height * 0.03)} />
114
+ <Captions
115
+ timeline={t}
116
+ srcMs={src}
117
+ top={bands ? above : { top: win.y + Math.round(H * 0.03) }}
118
+ bottom={bands ? below : { bottom: Math.round(H * 0.075) }}
119
+ fontSize={bands ? 46 : 36}
120
+ />
121
+ <Subtitles
122
+ timeline={t}
123
+ videoMs={((frame - intro) * 1000) / t.fps}
124
+ place={bands ? below : { bottom: Math.round(H * 0.06) }}
125
+ fontSize={bands ? 40 : 34}
126
+ />
96
127
  </div>
97
128
  </AbsoluteFill>
98
129
  );
@@ -8,7 +8,7 @@ import type { Timeline } from '../timeline/types';
8
8
  const ENTRY = fileURLToPath(new URL('./remotion/index.ts', import.meta.url));
9
9
  const COMPOSITION_ID = 'ShowtimeVideo';
10
10
 
11
- /** Bundle the composition with the take directory as public dir, so the raw video is staticFile('raw.mp4'). */
11
+ /** Bundle the composition with `.showtime/<name>/` as public dir: the take is staticFile('take/raw.mp4'). */
12
12
  async function prepare(timeline: Timeline, publicDir: string) {
13
13
  const serveUrl = await bundle({ entryPoint: ENTRY, publicDir, symlinkPublicDir: true });
14
14
  const inputProps = { timeline };
@@ -6,6 +6,8 @@ export class EventLog {
6
6
  readonly moves: Move[] = [];
7
7
  readonly clicks: Click[] = [];
8
8
  readonly idle: Interval[] = [];
9
+ /** keystroke times of human-paced typing */
10
+ readonly keys: number[] = [];
9
11
 
10
12
  constructor(private readonly t0: number = Date.now()) {}
11
13
 
@@ -102,6 +102,7 @@ export async function record(loaded: LoadedScript, opts: RecordOptions = {}): Pr
102
102
  moves: shot.log.moves,
103
103
  clicks: shot.log.clicks,
104
104
  idle: shot.log.idle,
105
+ keys: shot.log.keys,
105
106
  };
106
107
  await writeFile(path.join(tmp, 'events.json'), JSON.stringify(events, null, 2));
107
108
  if (!opts.keepFrames) await rm(frames, { recursive: true, force: true });
@@ -193,6 +193,7 @@ async function typeText(ctx: StepContext, text: string, pace: 'human' | 'fast' |
193
193
  const rand = rng(text);
194
194
  for (const ch of text) {
195
195
  await ctx.page.keyboard.type(ch);
196
+ ctx.log.keys.push(ctx.log.now());
196
197
  await sleep(typeof pace === 'number' ? pace : 45 + rand() * 65);
197
198
  }
198
199
  }
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod';
5
- import { formatIssues, MetaSchema, ScriptError, StyleSchema } from './schema';
5
+ import { AudioSchema, formatIssues, MetaSchema, ScriptError, StyleSchema } from './schema';
6
6
 
7
7
  export const CONFIG_FILE = 'showtime.config.json';
8
8
 
@@ -11,11 +11,17 @@ type Json = Record<string, unknown>;
11
11
  export interface BrandConfig {
12
12
  style?: Json;
13
13
  meta?: Json;
14
+ audio?: Json;
14
15
  }
15
16
 
16
17
  // Validated on its own so mistakes are blamed on this file; the raw values are what get merged.
17
18
  const ConfigSchema = z
18
- .object({ $schema: z.string().optional(), style: StyleSchema.partial().optional(), meta: MetaSchema.partial().optional() })
19
+ .object({
20
+ $schema: z.string().optional(),
21
+ style: StyleSchema.partial().optional(),
22
+ meta: MetaSchema.partial().optional(),
23
+ audio: AudioSchema.partial().optional(),
24
+ })
19
25
  .strict();
20
26
 
21
27
  /** The nearest showtime.config.json from `dir` upward, or null. */
@@ -27,7 +33,7 @@ export function findConfig(dir: string): string | null {
27
33
  }
28
34
  }
29
35
 
30
- /** Read a brand file; paths inside it (meta.logo) resolve against its folder. */
36
+ /** Read a brand file; paths inside it (meta.logo, audio.music.file) resolve against its folder. */
31
37
  export async function readConfig(file: string): Promise<BrandConfig> {
32
38
  let raw: unknown;
33
39
  try {
@@ -37,8 +43,9 @@ export async function readConfig(file: string): Promise<BrandConfig> {
37
43
  }
38
44
  const parsed = ConfigSchema.safeParse(raw);
39
45
  if (!parsed.success) throw new ScriptError(`Invalid ${file}:\n${formatIssues(parsed.error.issues)}`);
40
- const { style, meta } = raw as BrandConfig;
41
- return { style, meta: withLogoFrom(meta, path.dirname(file)) };
46
+ const { style, meta, audio } = raw as BrandConfig;
47
+ const dir = path.dirname(file);
48
+ return { style, meta: withLogoFrom(meta, dir), audio: withMusicFrom(audio, dir) };
42
49
  }
43
50
 
44
51
  /** meta with `logo` made absolute against `dir` (when it is a string). */
@@ -47,12 +54,21 @@ export function withLogoFrom(meta: Json | undefined, dir: string): Json | undefi
47
54
  return { ...meta, logo: path.resolve(dir, meta.logo) };
48
55
  }
49
56
 
57
+ /** audio with `music.file` made absolute against `dir` (when it is a string). */
58
+ export function withMusicFrom(audio: Json | undefined, dir: string): Json | undefined {
59
+ const music = audio?.music;
60
+ if (!isObject(music) || typeof music.file !== 'string') return audio;
61
+ return { ...audio, music: { ...music, file: path.resolve(dir, music.file) } };
62
+ }
63
+
50
64
  const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
51
65
 
52
- /** Brand defaults under the raw script: the script wins key by key in `style` and `meta`. */
66
+ /** Brand defaults under the raw script: the script wins key by key in `style`, `meta` and `audio`. */
53
67
  export function applyConfig(config: BrandConfig, raw: Json): Json {
54
68
  const merged: Json = { ...raw };
55
- if (config.style) merged.style = isObject(raw.style) ? { ...config.style, ...raw.style } : (raw.style ?? config.style);
56
- if (config.meta) merged.meta = isObject(raw.meta) ? { ...config.meta, ...raw.meta } : (raw.meta ?? config.meta);
69
+ for (const key of ['style', 'meta', 'audio'] as const) {
70
+ const defaults = config[key];
71
+ if (defaults) merged[key] = isObject(raw[key]) ? { ...defaults, ...raw[key] } : (raw[key] ?? defaults);
72
+ }
57
73
  return merged;
58
74
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { applyConfig, findConfig, readConfig, withLogoFrom } from './config';
4
+ import { applyConfig, findConfig, readConfig, withLogoFrom, withMusicFrom } from './config';
5
5
  import { parseScript, ScriptError, type Script } from './schema';
6
6
 
7
7
  export interface LoadedScript {
@@ -32,10 +32,13 @@ export async function loadScript(file: string): Promise<LoadedScript> {
32
32
  if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
33
33
  const own = raw as Record<string, unknown>;
34
34
  if (own.meta && typeof own.meta === 'object') own.meta = withLogoFrom(own.meta as Record<string, unknown>, dir);
35
+ if (own.audio && typeof own.audio === 'object') own.audio = withMusicFrom(own.audio as Record<string, unknown>, dir);
35
36
  if (config) raw = applyConfig(await readConfig(config), own);
36
37
  }
37
38
  const script = parseScript(raw, scriptPath);
38
39
  if (script.meta.logo && !existsSync(script.meta.logo)) throw new ScriptError(`meta.logo not found: ${script.meta.logo}`);
40
+ const music = script.audio.music?.file;
41
+ if (music && !existsSync(music)) throw new ScriptError(`audio.music.file not found: ${music}`);
39
42
  if (script.app.storageState) {
40
43
  script.app.storageState = path.resolve(dir, script.app.storageState);
41
44
  if (!existsSync(script.app.storageState)) {
@@ -15,6 +15,11 @@ export const ShowSchema = z
15
15
  spotlight: z.boolean().optional().describe('Dim everything except the target'),
16
16
  hold: z.number().int().min(0).max(30_000).optional().describe('Extra real-time ms after the step'),
17
17
  speed: z.number().min(0.25).max(16).optional().describe('Playback speed for this step'),
18
+ say: z
19
+ .string()
20
+ .min(1)
21
+ .optional()
22
+ .describe('Narration spoken from the start of this step; the video waits for it before the next narrated step'),
18
23
  })
19
24
  .strict();
20
25
 
@@ -146,6 +151,51 @@ export const MetaSchema = z
146
151
  })
147
152
  .strict();
148
153
 
154
+ export const VoiceSchema = z
155
+ .object({
156
+ engine: z
157
+ .enum(['system', 'openai', 'elevenlabs', 'command'])
158
+ .default('system')
159
+ .describe('system: macOS say / Linux espeak-ng; openai and elevenlabs read OPENAI_API_KEY / ELEVENLABS_API_KEY'),
160
+ name: z.string().min(1).optional().describe('Voice name (system, openai) or voice id (elevenlabs)'),
161
+ speed: z.number().min(0.5).max(2).default(1),
162
+ model: z.string().min(1).optional().describe('openai: gpt-4o-mini-tts by default; elevenlabs: eleven_multilingual_v2'),
163
+ instructions: z.string().min(1).optional().describe('openai only: how to speak, e.g. "warm and confident"'),
164
+ command: z
165
+ .string()
166
+ .min(1)
167
+ .optional()
168
+ .describe('command engine: shell command that reads the text on stdin and writes audio to {out}'),
169
+ })
170
+ .strict()
171
+ .superRefine((v, ctx) => {
172
+ if (v.engine === 'elevenlabs' && !v.name) {
173
+ ctx.addIssue({ code: 'custom', path: ['name'], message: 'elevenlabs needs a voice id in "name"' });
174
+ }
175
+ if (v.engine === 'command' && !v.command) {
176
+ ctx.addIssue({ code: 'custom', path: ['command'], message: 'the command engine needs "command"' });
177
+ }
178
+ });
179
+
180
+ export const AudioSchema = z
181
+ .object({
182
+ voice: VoiceSchema.prefault({}).describe('Text-to-speech for show.say'),
183
+ music: z
184
+ .object({
185
+ file: z.string().min(1).describe('Audio file, relative to the file that names it'),
186
+ volume: z.number().min(0).max(1).default(0.25),
187
+ })
188
+ .strict()
189
+ .optional()
190
+ .describe('Music bed under the whole video; ducks while the voice speaks'),
191
+ sounds: z.boolean().default(false).describe('Click and keystroke sounds'),
192
+ subtitles: z
193
+ .enum(['off', 'file', 'burn'])
194
+ .default('file')
195
+ .describe('Narration subtitles: an .srt next to the video (file), also drawn on the video (burn), or none'),
196
+ })
197
+ .strict();
198
+
149
199
  export const SceneSchema = z
150
200
  .object({
151
201
  id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'scene id may contain letters, digits, "-" and "_"'),
@@ -162,6 +212,7 @@ export const ScriptSchema = z
162
212
  hooks: HooksSchema.prefault({}),
163
213
  setup: z.array(StepSchema).default([]),
164
214
  style: StyleSchema.prefault({}),
215
+ audio: AudioSchema.prefault({}),
165
216
  scenes: z.array(SceneSchema).min(1),
166
217
  })
167
218
  .strict()
@@ -182,6 +233,8 @@ export type Show = z.output<typeof ShowSchema>;
182
233
  export type Style = z.output<typeof StyleSchema>;
183
234
  export type Hook = z.output<typeof HookSchema>;
184
235
  export type Mock = z.output<typeof MockSchema>;
236
+ export type Audio = z.output<typeof AudioSchema>;
237
+ export type Voice = z.output<typeof VoiceSchema>;
185
238
 
186
239
  export class ScriptError extends Error {
187
240
  constructor(message: string) {