@mineo71/showtime 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +155 -19
  3. package/docs/script.schema.json +395 -0
  4. package/package.json +1 -1
  5. package/skill/SKILL.md +70 -13
  6. package/src/audio/decode.ts +19 -0
  7. package/src/audio/mix.ts +72 -0
  8. package/src/audio/sfx.ts +55 -0
  9. package/src/audio/soundtrack.ts +49 -0
  10. package/src/audio/tts.ts +186 -0
  11. package/src/audio/wav.ts +96 -0
  12. package/src/cli/format.ts +10 -0
  13. package/src/cli/main.ts +125 -40
  14. package/src/convert.ts +48 -0
  15. package/src/ffmpeg.ts +21 -0
  16. package/src/lint.ts +128 -0
  17. package/src/logo.ts +20 -0
  18. package/src/paths.ts +19 -0
  19. package/src/pipeline.ts +97 -19
  20. package/src/render/remotion/Intro.tsx +3 -1
  21. package/src/render/remotion/Logo.tsx +6 -0
  22. package/src/render/remotion/Outro.tsx +38 -0
  23. package/src/render/remotion/Overlays.tsx +41 -16
  24. package/src/render/remotion/ShowtimeVideo.tsx +56 -18
  25. package/src/render/render.ts +1 -1
  26. package/src/runner/browser.ts +47 -6
  27. package/src/runner/check.ts +10 -1
  28. package/src/runner/events.ts +2 -0
  29. package/src/runner/hooks.ts +111 -0
  30. package/src/runner/record.ts +53 -34
  31. package/src/runner/steps.ts +4 -1
  32. package/src/script/config.ts +74 -0
  33. package/src/script/hash.ts +7 -1
  34. package/src/script/load.ts +15 -2
  35. package/src/script/schema.ts +115 -1
  36. package/src/setup.ts +13 -4
  37. package/src/sheet.ts +47 -0
  38. package/src/timeline/build.ts +113 -13
  39. package/src/timeline/camera.ts +34 -11
  40. package/src/timeline/fades.ts +3 -0
  41. package/src/timeline/frames.ts +15 -4
  42. package/src/timeline/layout.ts +70 -0
  43. package/src/timeline/narration.ts +160 -0
  44. package/src/timeline/timemap.ts +40 -5
  45. package/src/timeline/types.ts +44 -1
@@ -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
+ }
@@ -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
  };
@@ -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
  }
@@ -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: z.object({ title: z.string().optional(), subtitle: z.string().optional() }).strict().default({}),
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
- /** Download the Chromium build this copy of Playwright drives, and Remotion's headless shell. */
30
- export async function installBrowsers(): Promise<void> {
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
- await ensureBrowser();
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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
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
+ }
@@ -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 { buildSegments, type SpeedSpan } from './timemap';
5
- import type { Caption, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
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
- output?: { width: number; height: number };
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 captions: Caption[] = [];
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 scale = typeof zoom === 'number' ? zoom : zoomDepth(e.target, stage, style.maxZoom);
66
- if (scale >= MIN_ZOOM) {
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
- captions.push({ startMs: e.startMs, endMs: Math.max(e.settledMs, e.startMs + CAPTION_MIN_MS), text: step.show.caption });
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
- output: opts.output ?? { width: 1920, height: 1080 },
178
+ aspect,
179
+ output,
88
180
  stage,
89
- video: { src: ev.video.file, durationMs: ev.video.durationMs },
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
- segments: buildSegments(ev.video.durationMs, spans),
96
- camera: buildCamera(regions),
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
  }
@@ -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
- export function clampFocus(scale: number, cx: number, cy: number): { cx: number; cy: number } {
23
- const half = 0.5 / scale;
24
- return { cx: clamp(cx, half, 1 - half), cy: clamp(cy, half, 1 - half) };
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 function buildCamera(regions: ZoomRegion[]): CameraKey[] {
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(rest(Math.max(lastT, start - ZOOM_LEAD_MS)));
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
- if (!next || next.startMs - end > CONNECT_GAP_MS) keys.push(rest(end + ZOOM_LEAD_MS));
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
- /** CSS transform (transform-origin 0 0) that puts the focus point at the centre of a width×height box. */
64
- export function cameraTransform(cam: CameraKey, width: number, height: number): string {
65
- const tx = width / 2 - cam.cx * width * cam.scale;
66
- const ty = height / 2 - cam.cy * height * cam.scale;
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
  }
@@ -0,0 +1,3 @@
1
+ /** Fade lengths shared by the renderer and the timeline (holds must not freeze a fade). */
2
+ export const CAPTION_FADE_MS = 220;
3
+ export const SPOTLIGHT_FADE_MS = 250;