@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.
- package/CHANGELOG.md +32 -0
- package/README.md +155 -19
- package/docs/script.schema.json +395 -0
- package/package.json +1 -1
- package/skill/SKILL.md +70 -13
- 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/format.ts +10 -0
- package/src/cli/main.ts +125 -40
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +128 -0
- package/src/logo.ts +20 -0
- package/src/paths.ts +19 -0
- package/src/pipeline.ts +97 -19
- package/src/render/remotion/Intro.tsx +3 -1
- package/src/render/remotion/Logo.tsx +6 -0
- package/src/render/remotion/Outro.tsx +38 -0
- package/src/render/remotion/Overlays.tsx +41 -16
- package/src/render/remotion/ShowtimeVideo.tsx +56 -18
- package/src/render/render.ts +1 -1
- package/src/runner/browser.ts +47 -6
- package/src/runner/check.ts +10 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/hooks.ts +111 -0
- package/src/runner/record.ts +53 -34
- package/src/runner/steps.ts +4 -1
- package/src/script/config.ts +74 -0
- package/src/script/hash.ts +7 -1
- package/src/script/load.ts +15 -2
- package/src/script/schema.ts +115 -1
- package/src/setup.ts +13 -4
- package/src/sheet.ts +47 -0
- package/src/timeline/build.ts +113 -13
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +15 -4
- package/src/timeline/layout.ts +70 -0
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- 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 {
|
|
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) =>
|
|
9
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
.command('render <script>')
|
|
70
|
-
|
|
71
|
-
.option('
|
|
72
|
-
.action(async (file: string, o:
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
82
|
-
.
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
183
|
+
if (o.json) emit({ check: report });
|
|
184
|
+
else say(formatCheck(report));
|
|
100
185
|
process.exitCode = 1;
|
|
101
186
|
return;
|
|
102
187
|
}
|
|
103
|
-
|
|
188
|
+
log?.(`✓ check passed (${report.steps.length} steps)`);
|
|
104
189
|
const { record } = await import('../runner/record');
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
await
|
|
37
|
-
|
|
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
|
}
|