@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/runner/steps.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StepContext {
|
|
|
14
14
|
log: EventLog;
|
|
15
15
|
net: NetworkTracker;
|
|
16
16
|
cursor: Pt;
|
|
17
|
+
/** Relative `goto` URLs resolve against this (app.url); the current page when absent. */
|
|
18
|
+
baseUrl?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface StepWhere {
|
|
@@ -69,7 +71,7 @@ async function perform(ctx: StepContext, step: Step, where: StepWhere): Promise<
|
|
|
69
71
|
|
|
70
72
|
switch (step.do) {
|
|
71
73
|
case 'goto':
|
|
72
|
-
await page.goto(step.url, { waitUntil: 'load', timeout: WAIT_TIMEOUT_MS });
|
|
74
|
+
await page.goto(new URL(step.url, ctx.baseUrl ?? page.url()).href, { waitUntil: 'load', timeout: WAIT_TIMEOUT_MS });
|
|
73
75
|
break;
|
|
74
76
|
case 'click':
|
|
75
77
|
case 'hover':
|
|
@@ -191,6 +193,7 @@ async function typeText(ctx: StepContext, text: string, pace: 'human' | 'fast' |
|
|
|
191
193
|
const rand = rng(text);
|
|
192
194
|
for (const ch of text) {
|
|
193
195
|
await ctx.page.keyboard.type(ch);
|
|
196
|
+
ctx.log.keys.push(ctx.log.now());
|
|
194
197
|
await sleep(typeof pace === 'number' ? pace : 45 + rand() * 65);
|
|
195
198
|
}
|
|
196
199
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { AudioSchema, formatIssues, MetaSchema, ScriptError, StyleSchema } from './schema';
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILE = 'showtime.config.json';
|
|
8
|
+
|
|
9
|
+
type Json = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface BrandConfig {
|
|
12
|
+
style?: Json;
|
|
13
|
+
meta?: Json;
|
|
14
|
+
audio?: Json;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Validated on its own so mistakes are blamed on this file; the raw values are what get merged.
|
|
18
|
+
const ConfigSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
$schema: z.string().optional(),
|
|
21
|
+
style: StyleSchema.partial().optional(),
|
|
22
|
+
meta: MetaSchema.partial().optional(),
|
|
23
|
+
audio: AudioSchema.partial().optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
|
|
27
|
+
/** The nearest showtime.config.json from `dir` upward, or null. */
|
|
28
|
+
export function findConfig(dir: string): string | null {
|
|
29
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
30
|
+
const file = path.join(d, CONFIG_FILE);
|
|
31
|
+
if (existsSync(file)) return file;
|
|
32
|
+
if (path.dirname(d) === d) return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read a brand file; paths inside it (meta.logo, audio.music.file) resolve against its folder. */
|
|
37
|
+
export async function readConfig(file: string): Promise<BrandConfig> {
|
|
38
|
+
let raw: unknown;
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(await readFile(file, 'utf8'));
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new ScriptError(`Invalid JSON in ${file}: ${(err as Error).message}`);
|
|
43
|
+
}
|
|
44
|
+
const parsed = ConfigSchema.safeParse(raw);
|
|
45
|
+
if (!parsed.success) throw new ScriptError(`Invalid ${file}:\n${formatIssues(parsed.error.issues)}`);
|
|
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) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** meta with `logo` made absolute against `dir` (when it is a string). */
|
|
52
|
+
export function withLogoFrom(meta: Json | undefined, dir: string): Json | undefined {
|
|
53
|
+
if (!meta || typeof meta.logo !== 'string') return meta;
|
|
54
|
+
return { ...meta, logo: path.resolve(dir, meta.logo) };
|
|
55
|
+
}
|
|
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
|
+
|
|
64
|
+
const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
65
|
+
|
|
66
|
+
/** Brand defaults under the raw script: the script wins key by key in `style`, `meta` and `audio`. */
|
|
67
|
+
export function applyConfig(config: BrandConfig, raw: Json): Json {
|
|
68
|
+
const merged: Json = { ...raw };
|
|
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
|
+
}
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
package/src/script/hash.ts
CHANGED
|
@@ -19,9 +19,15 @@ function canonical(value: unknown): string {
|
|
|
19
19
|
return JSON.stringify(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** `app` without fields that sit at their defaults, so adding a field never invalidates older takes. */
|
|
23
|
+
function appOf(app: Script['app']): Record<string, unknown> {
|
|
24
|
+
const { mock, dialogs, ...rest } = app;
|
|
25
|
+
return { ...rest, ...(mock.length ? { mock } : {}), ...(dialogs !== 'accept' ? { dialogs } : {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function actionHash(script: Script): string {
|
|
23
29
|
const payload = {
|
|
24
|
-
app: script.app,
|
|
30
|
+
app: appOf(script.app),
|
|
25
31
|
setup: script.setup.map(actionsOf),
|
|
26
32
|
scenes: script.scenes.map((s) => ({ id: s.id, steps: s.steps.map(actionsOf) })),
|
|
27
33
|
};
|
package/src/script/load.ts
CHANGED
|
@@ -1,6 +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, withMusicFrom } from './config';
|
|
4
5
|
import { parseScript, ScriptError, type Script } from './schema';
|
|
5
6
|
|
|
6
7
|
export interface LoadedScript {
|
|
@@ -8,6 +9,8 @@ export interface LoadedScript {
|
|
|
8
9
|
scriptPath: string;
|
|
9
10
|
dir: string;
|
|
10
11
|
name: string;
|
|
12
|
+
/** The showtime.config.json whose defaults were applied, if any. */
|
|
13
|
+
config: string | null;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export async function loadScript(file: string): Promise<LoadedScript> {
|
|
@@ -24,13 +27,23 @@ export async function loadScript(file: string): Promise<LoadedScript> {
|
|
|
24
27
|
} catch (err) {
|
|
25
28
|
throw new ScriptError(`Invalid JSON in ${scriptPath}: ${(err as Error).message}`);
|
|
26
29
|
}
|
|
27
|
-
const script = parseScript(raw, scriptPath);
|
|
28
30
|
const dir = path.dirname(scriptPath);
|
|
31
|
+
const config = findConfig(dir);
|
|
32
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
33
|
+
const own = raw as Record<string, unknown>;
|
|
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);
|
|
36
|
+
if (config) raw = applyConfig(await readConfig(config), own);
|
|
37
|
+
}
|
|
38
|
+
const script = parseScript(raw, scriptPath);
|
|
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}`);
|
|
29
42
|
if (script.app.storageState) {
|
|
30
43
|
script.app.storageState = path.resolve(dir, script.app.storageState);
|
|
31
44
|
if (!existsSync(script.app.storageState)) {
|
|
32
45
|
throw new ScriptError(`app.storageState not found: ${script.app.storageState}`);
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
|
-
return { script, scriptPath, dir, name: path.basename(scriptPath).replace(/\.json$/i, '') };
|
|
48
|
+
return { script, scriptPath, dir, name: path.basename(scriptPath).replace(/\.json$/i, ''), config };
|
|
36
49
|
}
|
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
|
|
|
@@ -69,6 +74,21 @@ export const StyleSchema = z
|
|
|
69
74
|
})
|
|
70
75
|
.strict();
|
|
71
76
|
|
|
77
|
+
const headers = z.record(z.string(), z.string());
|
|
78
|
+
|
|
79
|
+
export const MockSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
url: z.string().min(1).describe('Playwright URL glob of the requests to answer, e.g. **/api/reports*'),
|
|
82
|
+
method: z.string().min(1).optional().describe('Only answer this HTTP method; others go to the network'),
|
|
83
|
+
status: z.number().int().min(100).max(599).default(200),
|
|
84
|
+
json: z.unknown().optional().describe('Response body, sent as application/json'),
|
|
85
|
+
body: z.string().optional().describe('Raw response body'),
|
|
86
|
+
headers: headers.optional(),
|
|
87
|
+
delayMs: z.number().int().min(0).max(30_000).optional().describe('Wait this long before answering'),
|
|
88
|
+
})
|
|
89
|
+
.strict()
|
|
90
|
+
.refine((m) => m.json === undefined || m.body === undefined, { message: 'a mock takes "json" or "body", not both' });
|
|
91
|
+
|
|
72
92
|
export const AppSchema = z
|
|
73
93
|
.object({
|
|
74
94
|
url: z.string().min(1),
|
|
@@ -85,6 +105,94 @@ export const AppSchema = z
|
|
|
85
105
|
.array(z.string().min(1))
|
|
86
106
|
.default([])
|
|
87
107
|
.describe('CSS selectors (not Playwright selectors) of elements to blur in the recording'),
|
|
108
|
+
mock: z.array(MockSchema).default([]).describe('Answer matching network requests with canned responses; the first match wins'),
|
|
109
|
+
clock: z
|
|
110
|
+
.string()
|
|
111
|
+
.refine((v) => !Number.isNaN(Date.parse(v)), { message: 'must be an ISO date-time, e.g. 2026-10-13T09:30:00Z' })
|
|
112
|
+
.optional()
|
|
113
|
+
.describe('Start the page clock at this ISO date-time; it then runs normally'),
|
|
114
|
+
dialogs: z.enum(['accept', 'dismiss']).default('accept').describe('How to answer alert/confirm/prompt dialogs'),
|
|
115
|
+
})
|
|
116
|
+
.strict();
|
|
117
|
+
|
|
118
|
+
export const HookSchema = z.union([
|
|
119
|
+
z.object({ run: z.string().min(1).describe('Shell command, run in the script folder') }).strict(),
|
|
120
|
+
z
|
|
121
|
+
.object({
|
|
122
|
+
request: z
|
|
123
|
+
.object({
|
|
124
|
+
url: z.string().min(1),
|
|
125
|
+
method: z.string().min(1).default('POST'),
|
|
126
|
+
json: z.unknown().optional(),
|
|
127
|
+
headers: headers.optional(),
|
|
128
|
+
})
|
|
129
|
+
.strict(),
|
|
130
|
+
})
|
|
131
|
+
.strict(),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
export const HooksSchema = z
|
|
135
|
+
.object({
|
|
136
|
+
before: z.array(HookSchema).default([]).describe('Run before every check and record, e.g. to reset demo data'),
|
|
137
|
+
after: z.array(HookSchema).default([]).describe('Run after every check and record, even when it failed'),
|
|
138
|
+
})
|
|
139
|
+
.strict();
|
|
140
|
+
|
|
141
|
+
export const MetaSchema = z
|
|
142
|
+
.object({
|
|
143
|
+
title: z.string().optional(),
|
|
144
|
+
subtitle: z.string().optional(),
|
|
145
|
+
logo: z.string().min(1).optional().describe('SVG/PNG/JPEG/WebP shown on the intro and outro cards'),
|
|
146
|
+
outro: z
|
|
147
|
+
.object({ title: z.string().min(1), subtitle: z.string().optional(), url: z.string().optional() })
|
|
148
|
+
.strict()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe('Closing card after the video'),
|
|
151
|
+
})
|
|
152
|
+
.strict();
|
|
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'),
|
|
88
196
|
})
|
|
89
197
|
.strict();
|
|
90
198
|
|
|
@@ -99,10 +207,12 @@ export const ScriptSchema = z
|
|
|
99
207
|
.object({
|
|
100
208
|
$schema: z.string().optional(),
|
|
101
209
|
version: z.literal(1),
|
|
102
|
-
meta:
|
|
210
|
+
meta: MetaSchema.default({}),
|
|
103
211
|
app: AppSchema,
|
|
212
|
+
hooks: HooksSchema.prefault({}),
|
|
104
213
|
setup: z.array(StepSchema).default([]),
|
|
105
214
|
style: StyleSchema.prefault({}),
|
|
215
|
+
audio: AudioSchema.prefault({}),
|
|
106
216
|
scenes: z.array(SceneSchema).min(1),
|
|
107
217
|
})
|
|
108
218
|
.strict()
|
|
@@ -121,6 +231,10 @@ export type ScriptInput = z.input<typeof ScriptSchema>;
|
|
|
121
231
|
export type Step = z.output<typeof StepSchema>;
|
|
122
232
|
export type Show = z.output<typeof ShowSchema>;
|
|
123
233
|
export type Style = z.output<typeof StyleSchema>;
|
|
234
|
+
export type Hook = z.output<typeof HookSchema>;
|
|
235
|
+
export type Mock = z.output<typeof MockSchema>;
|
|
236
|
+
export type Audio = z.output<typeof AudioSchema>;
|
|
237
|
+
export type Voice = z.output<typeof VoiceSchema>;
|
|
124
238
|
|
|
125
239
|
export class ScriptError extends Error {
|
|
126
240
|
constructor(message: string) {
|
package/src/setup.ts
CHANGED
|
@@ -26,16 +26,25 @@ export function linkSkill(agents: Agent[], home = homedir(), source = SKILL_DIR)
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Download the Chromium build this copy of Playwright drives, and Remotion's headless shell.
|
|
31
|
+
* `quiet` sends their progress output to stderr, keeping stdout clean for --json.
|
|
32
|
+
*/
|
|
33
|
+
export async function installBrowsers(opts: { quiet?: boolean } = {}): Promise<void> {
|
|
31
34
|
// Our own Playwright CLI: a bare `npx playwright` may resolve another version with another Chromium.
|
|
32
35
|
const require = createRequire(import.meta.url);
|
|
33
36
|
const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
34
37
|
await new Promise<void>((resolve, reject) => {
|
|
35
|
-
const child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' });
|
|
38
|
+
const child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: ['ignore', opts.quiet ? 2 : 'inherit', 'inherit'] });
|
|
36
39
|
child.on('error', reject);
|
|
37
40
|
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`playwright install chromium exited with code ${code}`))));
|
|
38
41
|
});
|
|
39
42
|
const { ensureBrowser } = await import('@remotion/renderer');
|
|
40
|
-
|
|
43
|
+
const log = console.log;
|
|
44
|
+
if (opts.quiet) console.log = console.error;
|
|
45
|
+
try {
|
|
46
|
+
await ensureBrowser();
|
|
47
|
+
} finally {
|
|
48
|
+
console.log = log;
|
|
49
|
+
}
|
|
41
50
|
}
|
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
|
|
6
|
+
export const SHEET_COLUMNS = 4;
|
|
7
|
+
export const SHEET_WIDTH = 1920;
|
|
8
|
+
|
|
9
|
+
export interface SheetItem {
|
|
10
|
+
file: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
15
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ENTITIES[c]);
|
|
16
|
+
|
|
17
|
+
/** A page laying the stills out in a labelled grid; images load by file name from the page's own folder. */
|
|
18
|
+
export function sheetHtml(items: SheetItem[]): string {
|
|
19
|
+
const figures = items
|
|
20
|
+
.map((i) => `<figure><img src="${encodeURIComponent(path.basename(i.file))}"><figcaption>${esc(i.label)}</figcaption></figure>`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
return `<!doctype html>
|
|
23
|
+
<html><head><meta charset="utf-8"><style>
|
|
24
|
+
body { margin: 0; background: #0b0f17; color: #e2e8f0; font: 600 18px/1.3 -apple-system, "Segoe UI", system-ui, sans-serif; }
|
|
25
|
+
.grid { display: grid; grid-template-columns: repeat(${SHEET_COLUMNS}, 1fr); gap: 16px; padding: 24px; width: ${SHEET_WIDTH}px; box-sizing: border-box; }
|
|
26
|
+
figure { margin: 0; }
|
|
27
|
+
img { display: block; width: 100%; border-radius: 6px; }
|
|
28
|
+
figcaption { margin-top: 8px; }
|
|
29
|
+
</style></head><body><div class="grid">
|
|
30
|
+
${figures}
|
|
31
|
+
</div></body></html>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render the stills (all in one folder) into one labelled PNG at `out`, next to them. */
|
|
35
|
+
export async function contactSheet(items: SheetItem[], out: string): Promise<string> {
|
|
36
|
+
const html = path.join(path.dirname(out), 'sheet.html');
|
|
37
|
+
await writeFile(html, sheetHtml(items));
|
|
38
|
+
const browser = await chromium.launch();
|
|
39
|
+
try {
|
|
40
|
+
const page = await browser.newPage({ viewport: { width: SHEET_WIDTH, height: 600 } });
|
|
41
|
+
await page.goto(pathToFileURL(html).href, { waitUntil: 'load' });
|
|
42
|
+
await page.screenshot({ path: out, fullPage: true });
|
|
43
|
+
} finally {
|
|
44
|
+
await browser.close();
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/src/timeline/build.ts
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import type { Script, Step } from '../script/schema';
|
|
2
|
-
import { buildCamera, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
|
|
2
|
+
import { buildCamera, cameraAt, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
|
|
3
|
+
import { BROWSER_BAR, OUTPUT_SIZES, projectRect, viewLayout, visibleFraction, type View } from './layout';
|
|
3
4
|
import { clamp } from './math';
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
5
|
+
import { RIPPLE_MS } from './cursor';
|
|
6
|
+
import { CAPTION_FADE_MS, SPOTLIGHT_FADE_MS } from './fades';
|
|
7
|
+
import { soundEvents, stepKey, subtitleCues, withNarration, type Span, type Typing } from './narration';
|
|
8
|
+
import { buildSegments, sourceToOutput, type SpeedSpan } from './timemap';
|
|
9
|
+
import type { Aspect, CameraKey, Caption, NarrationLine, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
|
|
6
10
|
|
|
7
11
|
export const AUTO_SPEED_MIN_IDLE_MS = 800;
|
|
8
12
|
export const AUTO_SPEED_TARGET_MS = 600;
|
|
9
13
|
export const INTRO_MS = 2200;
|
|
10
14
|
export const CAPTION_MIN_MS = 1500;
|
|
11
15
|
export const SPOTLIGHT_PAD = 8;
|
|
16
|
+
export const OUTRO_MS = 2600;
|
|
17
|
+
/** Bottom captions own the output below this fraction of its height. */
|
|
18
|
+
export const CAPTION_BAND = 0.78;
|
|
12
19
|
|
|
13
20
|
const ZOOMABLE = new Set<Step['do']>(['click', 'type', 'select', 'hover']);
|
|
14
21
|
|
|
15
22
|
export interface BuildOptions {
|
|
16
23
|
fps?: number;
|
|
17
|
-
|
|
24
|
+
/** 16:9 by default; 9:16 and 1:1 crop the viewport and follow the action. */
|
|
25
|
+
aspect?: Aspect;
|
|
26
|
+
/** Logo as a data URI. */
|
|
27
|
+
logo?: string;
|
|
28
|
+
/** Length of each synthesized `show.say` line, by step (`scene#n`); lines without one are left out. */
|
|
29
|
+
voices?: Record<string, number>;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
function stepOf(script: Script, e: StepEvent): Step {
|
|
@@ -33,11 +45,36 @@ function displayAddress(url: string): string {
|
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
/** Top when the target, at any of the caption's start/middle/end, reaches into the bottom caption band. */
|
|
49
|
+
function captionPosition(
|
|
50
|
+
target: Rect | undefined,
|
|
51
|
+
c: { startMs: number; endMs: number },
|
|
52
|
+
camera: CameraKey[],
|
|
53
|
+
stage: { width: number; height: number },
|
|
54
|
+
win: View,
|
|
55
|
+
output: { width: number; height: number },
|
|
56
|
+
): Caption['position'] {
|
|
57
|
+
if (!target) return 'bottom';
|
|
58
|
+
const band = output.height * CAPTION_BAND;
|
|
59
|
+
const low = [c.startMs, (c.startMs + c.endMs) / 2, c.endMs].some((tMs) => {
|
|
60
|
+
const r = projectRect(target, cameraAt(camera, tMs), stage, win);
|
|
61
|
+
return r.y + r.height > band;
|
|
62
|
+
});
|
|
63
|
+
return low ? 'top' : 'bottom';
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
const pad = (r: Rect, p: number): Rect => ({ x: r.x - p, y: r.y - p, width: r.width + 2 * p, height: r.height + 2 * p });
|
|
37
67
|
|
|
38
68
|
export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions = {}): Timeline {
|
|
39
69
|
const { style } = script;
|
|
40
70
|
const stage = ev.viewport;
|
|
71
|
+
const aspect = opts.aspect ?? '16:9';
|
|
72
|
+
const output = OUTPUT_SIZES[aspect];
|
|
73
|
+
const win = viewLayout(output, stage, style.padding, style.chrome === 'browser' ? BROWSER_BAR : 0, aspect);
|
|
74
|
+
const visible = visibleFraction(win);
|
|
75
|
+
// Cropped formats follow every target; a zoom only adds what the crop doesn't already magnify.
|
|
76
|
+
const crop = aspect !== '16:9';
|
|
77
|
+
const cropDepth = Math.min(visible.x, visible.y);
|
|
41
78
|
const pairs = ev.steps.map((e) => ({ e, step: stepOf(script, e) }));
|
|
42
79
|
|
|
43
80
|
// Speed: auto idle first, explicit `show.speed` next, pauses last (later spans win).
|
|
@@ -56,14 +93,15 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
56
93
|
}
|
|
57
94
|
|
|
58
95
|
const regions: ZoomRegion[] = [];
|
|
59
|
-
const
|
|
96
|
+
const drafts: { startMs: number; endMs: number; text: string; target?: Rect }[] = [];
|
|
60
97
|
const spotlights: Spotlight[] = [];
|
|
61
98
|
for (const { e, step } of pairs) {
|
|
62
99
|
const zoom = step.show?.zoom;
|
|
63
100
|
const wantsZoom = typeof zoom === 'number' || (zoom === undefined && style.autoZoom && ZOOMABLE.has(step.do));
|
|
64
|
-
if (e.target && wantsZoom) {
|
|
65
|
-
const
|
|
66
|
-
|
|
101
|
+
if (e.target && (wantsZoom || crop)) {
|
|
102
|
+
const depth = wantsZoom ? (typeof zoom === 'number' ? zoom : zoomDepth(e.target, stage, style.maxZoom)) : 1;
|
|
103
|
+
const scale = crop ? Math.max(1, depth * cropDepth) : depth;
|
|
104
|
+
if (scale >= MIN_ZOOM || crop) {
|
|
67
105
|
regions.push({
|
|
68
106
|
startMs: e.actionMs,
|
|
69
107
|
endMs: e.settledMs,
|
|
@@ -74,30 +112,92 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
74
112
|
}
|
|
75
113
|
}
|
|
76
114
|
if (step.show?.caption) {
|
|
77
|
-
|
|
115
|
+
drafts.push({ startMs: e.startMs, endMs: Math.max(e.settledMs, e.startMs + CAPTION_MIN_MS), text: step.show.caption, target: e.target });
|
|
78
116
|
}
|
|
79
117
|
if (step.show?.spotlight && e.target) {
|
|
80
118
|
spotlights.push({ startMs: e.actionMs, endMs: e.settledMs, rect: pad(e.target, SPOTLIGHT_PAD) });
|
|
81
119
|
}
|
|
82
120
|
}
|
|
83
121
|
|
|
122
|
+
const lines = pairs.flatMap(({ e, step }) => {
|
|
123
|
+
const where = stepKey(e.sceneId, e.index);
|
|
124
|
+
const durationMs = opts.voices?.[where];
|
|
125
|
+
return step.show?.say && durationMs !== undefined ? [{ where, text: step.show.say, startMs: e.startMs, durationMs }] : [];
|
|
126
|
+
});
|
|
127
|
+
const subtitlesBurned = script.audio.subtitles === 'burn' && lines.length > 0;
|
|
128
|
+
|
|
129
|
+
const camera = buildCamera(regions, { visible, hold: crop });
|
|
130
|
+
// A caption ends when the next one starts: no overlap, so captions dip instead of colliding.
|
|
131
|
+
drafts.sort((a, b) => a.startMs - b.startMs);
|
|
132
|
+
drafts.forEach((d, i) => {
|
|
133
|
+
const next = drafts[i + 1];
|
|
134
|
+
if (next) d.endMs = Math.min(d.endMs, next.startMs);
|
|
135
|
+
});
|
|
136
|
+
const captions: Caption[] = drafts
|
|
137
|
+
.filter((d) => d.endMs > d.startMs)
|
|
138
|
+
.map((d) => ({
|
|
139
|
+
startMs: d.startMs,
|
|
140
|
+
endMs: d.endMs,
|
|
141
|
+
text: d.text,
|
|
142
|
+
// 9:16 captions sit in the band under the window, clear of the action.
|
|
143
|
+
position: subtitlesBurned ? 'top' : aspect === '9:16' ? 'bottom' : captionPosition(d.target, d, camera, stage, win, output),
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// Narration holds freeze the frame, so they go where nothing is moving: not during an action,
|
|
147
|
+
// a cursor move, a ripple, a camera move or a fade.
|
|
148
|
+
const fades = (x: { startMs: number; endMs: number }, ms: number) => [
|
|
149
|
+
{ startMs: x.startMs, endMs: x.startMs + ms },
|
|
150
|
+
{ startMs: x.endMs - ms, endMs: x.endMs },
|
|
151
|
+
];
|
|
152
|
+
const busy: Span[] = [
|
|
153
|
+
...pairs.filter(({ step }) => step.do !== 'pause').map(({ e }) => ({ startMs: e.startMs, endMs: e.endMs })),
|
|
154
|
+
...ev.moves,
|
|
155
|
+
...ev.clicks.map((c) => ({ startMs: c.tMs, endMs: c.tMs + RIPPLE_MS })),
|
|
156
|
+
...camera.slice(1).flatMap((b, i) => {
|
|
157
|
+
const a = camera[i];
|
|
158
|
+
return a.scale !== b.scale || a.cx !== b.cx || a.cy !== b.cy ? [{ startMs: a.tMs, endMs: b.tMs }] : [];
|
|
159
|
+
}),
|
|
160
|
+
...captions.flatMap((c) => fades(c, CAPTION_FADE_MS)),
|
|
161
|
+
...spotlights.flatMap((s) => fades(s, SPOTLIGHT_FADE_MS)),
|
|
162
|
+
];
|
|
163
|
+
const segments = withNarration(buildSegments(ev.video.durationMs, spans), lines, ev.video.durationMs, busy);
|
|
164
|
+
const narration: NarrationLine[] = lines.map((l) => ({
|
|
165
|
+
where: l.where,
|
|
166
|
+
text: l.text,
|
|
167
|
+
atMs: Math.round(sourceToOutput(segments, l.startMs)),
|
|
168
|
+
durationMs: l.durationMs,
|
|
169
|
+
}));
|
|
170
|
+
const subtitles = script.audio.subtitles === 'off' ? [] : narration.flatMap((n) => subtitleCues(n.text, n.atMs, n.durationMs));
|
|
171
|
+
const typing: Typing[] = pairs.flatMap(({ e, step }) =>
|
|
172
|
+
step.do === 'type' && step.pace !== 'fast' ? [{ startMs: e.actionMs, endMs: e.endMs, chars: [...step.text].length }] : [],
|
|
173
|
+
);
|
|
174
|
+
|
|
84
175
|
return {
|
|
85
176
|
version: 1,
|
|
86
177
|
fps: opts.fps ?? 30,
|
|
87
|
-
|
|
178
|
+
aspect,
|
|
179
|
+
output,
|
|
88
180
|
stage,
|
|
89
|
-
video: { src: ev.video.file
|
|
181
|
+
video: { src: `take/${ev.video.file}`, durationMs: ev.video.durationMs },
|
|
90
182
|
intro: script.meta.title ? { title: script.meta.title, subtitle: script.meta.subtitle, durationMs: INTRO_MS } : null,
|
|
91
183
|
chrome:
|
|
92
184
|
style.chrome === 'browser'
|
|
93
185
|
? { address: style.address ?? displayAddress(script.app.url), dark: script.app.colorScheme === 'dark' }
|
|
94
186
|
: null,
|
|
95
|
-
|
|
96
|
-
|
|
187
|
+
outro: script.meta.outro ? { ...script.meta.outro, durationMs: OUTRO_MS } : null,
|
|
188
|
+
logo: opts.logo ?? null,
|
|
189
|
+
segments,
|
|
190
|
+
camera,
|
|
97
191
|
moves: ev.moves,
|
|
98
192
|
clicks: ev.clicks,
|
|
99
193
|
captions,
|
|
100
194
|
spotlights,
|
|
195
|
+
narration,
|
|
196
|
+
subtitles,
|
|
197
|
+
subtitlesBurned,
|
|
198
|
+
sounds: script.audio.sounds ? soundEvents(ev, typing, segments) : [],
|
|
199
|
+
music: script.audio.music ? { volume: script.audio.music.volume } : null,
|
|
200
|
+
audio: null,
|
|
101
201
|
style,
|
|
102
202
|
};
|
|
103
203
|
}
|
package/src/timeline/camera.ts
CHANGED
|
@@ -19,14 +19,33 @@ export function zoomDepth(target: Rect, stage: { width: number; height: number }
|
|
|
19
19
|
return clamp(fit, 1, maxZoom);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** Share of the content visible at scale 1, per axis: 1 unless the format crops the viewport. */
|
|
23
|
+
export interface Visible {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FULL: Visible = { x: 1, y: 1 };
|
|
29
|
+
|
|
30
|
+
/** Keep the focus where the visible part stays inside the content. */
|
|
31
|
+
export function clampFocus(scale: number, cx: number, cy: number, visible: Visible = FULL): { cx: number; cy: number } {
|
|
32
|
+
const hx = Math.min(0.5, visible.x / (2 * scale));
|
|
33
|
+
const hy = Math.min(0.5, visible.y / (2 * scale));
|
|
34
|
+
return { cx: clamp(cx, hx, 1 - hx), cy: clamp(cy, hy, 1 - hy) };
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
const rest = (tMs: number): CameraKey => ({ tMs, scale: 1, cx: 0.5, cy: 0.5 });
|
|
28
38
|
|
|
29
|
-
export
|
|
39
|
+
export interface CameraOptions {
|
|
40
|
+
visible?: Visible;
|
|
41
|
+
/** Between regions keep the last focus (at scale 1) instead of returning to the centre: the follow camera of cropped formats. */
|
|
42
|
+
hold?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildCamera(regions: ZoomRegion[], opts: CameraOptions = {}): CameraKey[] {
|
|
46
|
+
const visible = opts.visible ?? FULL;
|
|
47
|
+
let last = { cx: 0.5, cy: 0.5 };
|
|
48
|
+
const restAt = (tMs: number): CameraKey => (opts.hold ? { tMs, scale: 1, ...clampFocus(1, last.cx, last.cy, visible) } : rest(tMs));
|
|
30
49
|
const rs = [...regions].sort((a, b) => a.startMs - b.startMs);
|
|
31
50
|
const keys: CameraKey[] = [];
|
|
32
51
|
rs.forEach((r, i) => {
|
|
@@ -34,14 +53,15 @@ export function buildCamera(regions: ZoomRegion[]): CameraKey[] {
|
|
|
34
53
|
const next = rs[i + 1];
|
|
35
54
|
const start = prev ? Math.max(r.startMs, prev.endMs) : r.startMs;
|
|
36
55
|
const end = Math.max(start, r.endMs);
|
|
37
|
-
const focus = clampFocus(r.scale, r.cx, r.cy);
|
|
56
|
+
const focus = clampFocus(r.scale, r.cx, r.cy, visible);
|
|
38
57
|
if (!prev || start - prev.endMs > CONNECT_GAP_MS) {
|
|
39
58
|
const lastT = keys.length ? keys[keys.length - 1].tMs : 0;
|
|
40
|
-
keys.push(
|
|
59
|
+
keys.push(restAt(Math.max(lastT, start - ZOOM_LEAD_MS)));
|
|
41
60
|
}
|
|
42
61
|
keys.push({ tMs: start, scale: r.scale, ...focus });
|
|
43
62
|
keys.push({ tMs: end, scale: r.scale, ...focus });
|
|
44
|
-
|
|
63
|
+
last = focus;
|
|
64
|
+
if (!next || next.startMs - end > CONNECT_GAP_MS) keys.push(restAt(end + ZOOM_LEAD_MS));
|
|
45
65
|
});
|
|
46
66
|
return keys;
|
|
47
67
|
}
|
|
@@ -60,10 +80,13 @@ export function cameraAt(keys: CameraKey[], tMs: number): CameraKey {
|
|
|
60
80
|
return { ...keys[keys.length - 1], tMs };
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
/**
|
|
84
|
+
* CSS transform (transform-origin 0 0) for content of `content` size that puts the focus point at
|
|
85
|
+
* the centre of a width×height window (content defaults to the window's size).
|
|
86
|
+
*/
|
|
87
|
+
export function cameraTransform(cam: CameraKey, width: number, height: number, content = { width, height }): string {
|
|
88
|
+
const tx = width / 2 - cam.cx * content.width * cam.scale;
|
|
89
|
+
const ty = height / 2 - cam.cy * content.height * cam.scale;
|
|
67
90
|
const r = (v: number) => Math.round(v * 100) / 100 + 0;
|
|
68
91
|
return `translate(${r(tx)}px, ${r(ty)}px) scale(${r(cam.scale * 1000) / 1000})`;
|
|
69
92
|
}
|