@mineo71/showtime 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +78 -14
- package/docs/script.schema.json +178 -0
- package/package.json +1 -1
- package/skill/SKILL.md +40 -7
- package/src/audio/decode.ts +19 -0
- package/src/audio/mix.ts +72 -0
- package/src/audio/sfx.ts +55 -0
- package/src/audio/soundtrack.ts +49 -0
- package/src/audio/tts.ts +186 -0
- package/src/audio/wav.ts +96 -0
- package/src/cli/main.ts +53 -21
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +28 -1
- package/src/paths.ts +13 -0
- package/src/pipeline.ts +89 -20
- package/src/render/remotion/Intro.tsx +1 -1
- package/src/render/remotion/Outro.tsx +1 -1
- package/src/render/remotion/Overlays.tsx +40 -16
- package/src/render/remotion/ShowtimeVideo.tsx +47 -16
- package/src/render/render.ts +1 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/record.ts +1 -0
- package/src/runner/steps.ts +1 -0
- package/src/script/config.ts +24 -8
- package/src/script/load.ts +4 -1
- package/src/script/schema.ts +53 -0
- package/src/timeline/build.ts +75 -15
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +3 -2
- package/src/timeline/layout.ts +53 -4
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- package/src/timeline/types.ts +38 -1
package/src/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:
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
await
|
|
41
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
59
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
60
|
-
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
|
-
|
|
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:
|
|
86
|
+
style={{ position: 'absolute', left: 0, top: 0, width: content.width, height: content.height }}
|
|
73
87
|
/>
|
|
74
|
-
|
|
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
|
|
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
|
);
|
package/src/render/render.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/src/runner/events.ts
CHANGED
|
@@ -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
|
|
package/src/runner/record.ts
CHANGED
|
@@ -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 });
|
package/src/runner/steps.ts
CHANGED
|
@@ -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
|
}
|
package/src/script/config.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
56
|
-
|
|
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
|
}
|
package/src/script/load.ts
CHANGED
|
@@ -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)) {
|
package/src/script/schema.ts
CHANGED
|
@@ -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) {
|