@mineo71/showtime 0.0.0-stage → 0.2.1

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 (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +365 -2
  4. package/bin/showtime.mjs +5 -0
  5. package/docs/script.schema.json +1346 -0
  6. package/package.json +74 -4
  7. package/skill/SKILL.md +112 -0
  8. package/src/cli/format.ts +19 -0
  9. package/src/cli/main.ts +115 -0
  10. package/src/ffmpeg.ts +72 -0
  11. package/src/paths.ts +30 -0
  12. package/src/pipeline.ts +38 -0
  13. package/src/render/remotion/BrowserBar.tsx +61 -0
  14. package/src/render/remotion/Cursor.tsx +40 -0
  15. package/src/render/remotion/Intro.tsx +29 -0
  16. package/src/render/remotion/Overlays.tsx +95 -0
  17. package/src/render/remotion/Root.tsx +21 -0
  18. package/src/render/remotion/ShowtimeVideo.tsx +92 -0
  19. package/src/render/remotion/index.ts +4 -0
  20. package/src/render/render.ts +46 -0
  21. package/src/runner/browser.ts +122 -0
  22. package/src/runner/capture.ts +53 -0
  23. package/src/runner/check.ts +73 -0
  24. package/src/runner/encode.ts +49 -0
  25. package/src/runner/events.ts +15 -0
  26. package/src/runner/record.ts +96 -0
  27. package/src/runner/steps.ts +217 -0
  28. package/src/runner/time.ts +17 -0
  29. package/src/script/hash.ts +29 -0
  30. package/src/script/load.ts +36 -0
  31. package/src/script/schema.ts +144 -0
  32. package/src/setup.ts +41 -0
  33. package/src/take.ts +24 -0
  34. package/src/timeline/build.ts +103 -0
  35. package/src/timeline/camera.ts +69 -0
  36. package/src/timeline/cursor.ts +59 -0
  37. package/src/timeline/frames.ts +60 -0
  38. package/src/timeline/layout.ts +29 -0
  39. package/src/timeline/math.ts +7 -0
  40. package/src/timeline/timemap.ts +52 -0
  41. package/src/timeline/types.ts +111 -0
@@ -0,0 +1,217 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ import type { Step } from '../script/schema';
3
+ import { clamp, easeInOutCubic } from '../timeline/math';
4
+ import type { CursorKind, Pt, Rect } from '../timeline/types';
5
+ import type { NetworkTracker } from './browser';
6
+ import type { EventLog } from './events';
7
+ import { rng, sleep } from './time';
8
+
9
+ export type Mode = 'record' | 'check';
10
+
11
+ export interface StepContext {
12
+ page: Page;
13
+ mode: Mode;
14
+ log: EventLog;
15
+ net: NetworkTracker;
16
+ cursor: Pt;
17
+ }
18
+
19
+ export interface StepWhere {
20
+ sceneId: string;
21
+ index: number;
22
+ }
23
+
24
+ export const STEP_TIMEOUT_MS = 10_000;
25
+ export const WAIT_TIMEOUT_MS = 15_000;
26
+ export const BEAT_MS = 450;
27
+ const NET_QUIET_MS = 300;
28
+ const NET_CAP_MS = 3000;
29
+ const IDLE_GRACE_MS = 300;
30
+
31
+ export class StepError extends Error {
32
+ constructor(
33
+ message: string,
34
+ readonly where: StepWhere,
35
+ readonly step: Step,
36
+ ) {
37
+ super(message);
38
+ this.name = 'StepError';
39
+ }
40
+ }
41
+
42
+ export function describeStep(step: Step): string {
43
+ if ('target' in step && step.target) return `${step.do} ${step.target}`;
44
+ if (step.do === 'wait') return step.for ? `wait ${step.for}` : `wait ${step.ms} ms`;
45
+ if (step.do === 'goto') return `goto ${step.url}`;
46
+ if (step.do === 'press') return `press ${step.key}`;
47
+ if (step.do === 'pause') return `pause ${step.ms} ms`;
48
+ if (step.do === 'scroll') return `scroll ${step.by}px`;
49
+ return step.do;
50
+ }
51
+
52
+ export async function runStep(ctx: StepContext, step: Step, where: StepWhere): Promise<void> {
53
+ try {
54
+ await perform(ctx, step, where);
55
+ } catch (err) {
56
+ if (err instanceof StepError) throw err;
57
+ const message = (err as Error).message.split('\n').filter(Boolean).slice(0, 4).join('\n');
58
+ throw new StepError(`Scene "${where.sceneId}" step ${where.index + 1} (${describeStep(step)}): ${message}`, where, step);
59
+ }
60
+ }
61
+
62
+ const centre = (r: Rect): Pt => ({ x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
63
+
64
+ async function perform(ctx: StepContext, step: Step, where: StepWhere): Promise<void> {
65
+ const { page, log } = ctx;
66
+ const startMs = log.now();
67
+ let actionMs = startMs;
68
+ let target: Rect | undefined;
69
+
70
+ switch (step.do) {
71
+ case 'goto':
72
+ await page.goto(step.url, { waitUntil: 'load', timeout: WAIT_TIMEOUT_MS });
73
+ break;
74
+ case 'click':
75
+ case 'hover':
76
+ case 'select':
77
+ case 'type': {
78
+ const r = await resolve(ctx, step.target, false);
79
+ target = r.box;
80
+ let point = centre(r.box);
81
+ await travel(ctx, point, r.cursor);
82
+ // The page may have shifted during travel (late layout, animation): follow the element.
83
+ const now = await r.loc.boundingBox();
84
+ if (now && (Math.abs(now.x - target.x) > 2 || Math.abs(now.y - target.y) > 2)) {
85
+ target = now;
86
+ point = centre(now);
87
+ await travel(ctx, point, r.cursor);
88
+ }
89
+ actionMs = log.now();
90
+ if (step.do !== 'hover') log.clicks.push({ tMs: actionMs, ...point });
91
+ if (step.do === 'click') await r.loc.click({ timeout: STEP_TIMEOUT_MS });
92
+ if (step.do === 'select') await r.loc.selectOption(step.value, { timeout: STEP_TIMEOUT_MS });
93
+ if (step.do === 'type') {
94
+ await r.loc.click({ timeout: STEP_TIMEOUT_MS });
95
+ if (step.clear) await r.loc.fill('');
96
+ await typeText(ctx, step.text, step.pace);
97
+ }
98
+ break;
99
+ }
100
+ case 'press':
101
+ await page.keyboard.press(step.key);
102
+ break;
103
+ case 'scroll':
104
+ if (step.target) target = (await resolve(ctx, step.target, true)).box;
105
+ else await wheel(ctx, step.by ?? 0);
106
+ break;
107
+ case 'wait':
108
+ if (step.for) await page.locator(step.for).waitFor({ state: 'visible', timeout: WAIT_TIMEOUT_MS });
109
+ else await sleep(step.ms ?? 0);
110
+ break;
111
+ case 'pause':
112
+ if (ctx.mode === 'record') await sleep(step.ms);
113
+ break;
114
+ }
115
+
116
+ const endMs = log.now();
117
+ if (step.do === 'wait' || step.do === 'goto') log.idle.push({ startMs, endMs });
118
+ await settle(ctx, step);
119
+ log.steps.push({ ...where, do: step.do, startMs, actionMs, endMs, settledMs: log.now(), target });
120
+ }
121
+
122
+ async function resolve(ctx: StepContext, selector: string, forceScroll: boolean) {
123
+ const loc = ctx.page.locator(selector);
124
+ await loc.waitFor({ state: 'visible', timeout: STEP_TIMEOUT_MS });
125
+ // Visible fraction within the viewport, clipped by scrollable ancestors (a list or modal body too).
126
+ const visible = await loc.evaluate(
127
+ (el) =>
128
+ new Promise<number>((done) => {
129
+ const io = new IntersectionObserver((entries) => {
130
+ io.disconnect();
131
+ done(entries[0].intersectionRatio);
132
+ });
133
+ io.observe(el);
134
+ }),
135
+ );
136
+ if (forceScroll || visible < 0.99) {
137
+ const smooth = ctx.mode === 'record';
138
+ await loc.evaluate(
139
+ (el, s) => el.scrollIntoView({ behavior: (s ? 'smooth' : 'instant') as ScrollBehavior, block: 'center', inline: 'nearest' }),
140
+ smooth,
141
+ );
142
+ }
143
+ const box = await stableBox(loc);
144
+ const css = await loc.evaluate((el) => getComputedStyle(el).cursor);
145
+ const cursor: CursorKind = css === 'text' ? 'text' : css === 'pointer' ? 'pointer' : 'arrow';
146
+ return { loc, box, cursor };
147
+ }
148
+
149
+ /** Wait (max ~2 s) until the element stops moving, e.g. after smooth scrolling or an opening animation. */
150
+ async function stableBox(loc: Locator): Promise<Rect> {
151
+ let prev = await loc.boundingBox();
152
+ for (let i = 0; i < 40; i++) {
153
+ await sleep(50);
154
+ const next = await loc.boundingBox();
155
+ if (prev && next && Math.abs(prev.x - next.x) < 0.5 && Math.abs(prev.y - next.y) < 0.5 && Math.abs(prev.width - next.width) < 0.5) {
156
+ return next;
157
+ }
158
+ prev = next;
159
+ }
160
+ if (!prev) throw new Error('element has no bounding box (hidden or detached)');
161
+ return prev;
162
+ }
163
+
164
+ async function travel(ctx: StepContext, to: Pt, cursor: CursorKind): Promise<void> {
165
+ const from = ctx.cursor;
166
+ if (ctx.mode === 'check') {
167
+ await ctx.page.mouse.move(to.x, to.y);
168
+ ctx.cursor = to;
169
+ return;
170
+ }
171
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
172
+ const duration = clamp(350 + dist * 0.35, 350, 900);
173
+ const startMs = ctx.log.now();
174
+ const t0 = Date.now();
175
+ const n = Math.max(2, Math.round(duration / 16));
176
+ for (let i = 1; i <= n; i++) {
177
+ const e = easeInOutCubic(i / n);
178
+ await ctx.page.mouse.move(from.x + (to.x - from.x) * e, from.y + (to.y - from.y) * e);
179
+ const wait = t0 + (duration * i) / n - Date.now();
180
+ if (wait > 0) await sleep(wait);
181
+ }
182
+ ctx.log.moves.push({ startMs, endMs: ctx.log.now(), from, to, cursor });
183
+ ctx.cursor = to;
184
+ }
185
+
186
+ async function typeText(ctx: StepContext, text: string, pace: 'human' | 'fast' | number): Promise<void> {
187
+ if (ctx.mode === 'check' || pace === 'fast') {
188
+ await ctx.page.keyboard.type(text);
189
+ return;
190
+ }
191
+ const rand = rng(text);
192
+ for (const ch of text) {
193
+ await ctx.page.keyboard.type(ch);
194
+ await sleep(typeof pace === 'number' ? pace : 45 + rand() * 65);
195
+ }
196
+ }
197
+
198
+ async function wheel(ctx: StepContext, by: number): Promise<void> {
199
+ if (ctx.mode === 'check') {
200
+ await ctx.page.mouse.wheel(0, by);
201
+ await sleep(100);
202
+ return;
203
+ }
204
+ const n = 14;
205
+ for (let i = 0; i < n; i++) {
206
+ await ctx.page.mouse.wheel(0, by / n);
207
+ await sleep(40);
208
+ }
209
+ }
210
+
211
+ async function settle(ctx: StepContext, step: Step): Promise<void> {
212
+ const s = ctx.log.now();
213
+ await ctx.net.idle(NET_QUIET_MS, NET_CAP_MS);
214
+ const waited = ctx.log.now() - s;
215
+ if (waited > IDLE_GRACE_MS) ctx.log.idle.push({ startMs: s + IDLE_GRACE_MS, endMs: s + waited });
216
+ if (ctx.mode === 'record') await sleep(BEAT_MS + (step.show?.hold ?? 0));
217
+ }
@@ -0,0 +1,17 @@
1
+ export const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
2
+
3
+ /** Deterministic PRNG (mulberry32) seeded from a string, so re-takes type with the same rhythm. */
4
+ export function rng(seed: string): () => number {
5
+ let h = 1779033703 ^ seed.length;
6
+ for (let i = 0; i < seed.length; i++) {
7
+ h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
8
+ h = (h << 13) | (h >>> 19);
9
+ }
10
+ let a = h >>> 0;
11
+ return () => {
12
+ a = (a + 0x6d2b79f5) | 0;
13
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
14
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Script, Step } from './schema';
3
+
4
+ /** Everything that changes what gets recorded: the step minus `show`, plus `show.hold`. */
5
+ function actionsOf(step: Step): Record<string, unknown> {
6
+ const { show, ...rest } = step;
7
+ return show?.hold ? { ...rest, hold: show.hold } : rest;
8
+ }
9
+
10
+ function canonical(value: unknown): string {
11
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
12
+ if (value && typeof value === 'object') {
13
+ const obj = value as Record<string, unknown>;
14
+ const keys = Object.keys(obj)
15
+ .filter((k) => obj[k] !== undefined)
16
+ .sort();
17
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
18
+ }
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ export function actionHash(script: Script): string {
23
+ const payload = {
24
+ app: script.app,
25
+ setup: script.setup.map(actionsOf),
26
+ scenes: script.scenes.map((s) => ({ id: s.id, steps: s.steps.map(actionsOf) })),
27
+ };
28
+ return createHash('sha256').update(canonical(payload)).digest('hex');
29
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parseScript, ScriptError, type Script } from './schema';
5
+
6
+ export interface LoadedScript {
7
+ script: Script;
8
+ scriptPath: string;
9
+ dir: string;
10
+ name: string;
11
+ }
12
+
13
+ export async function loadScript(file: string): Promise<LoadedScript> {
14
+ const scriptPath = path.resolve(file);
15
+ let text: string;
16
+ try {
17
+ text = await readFile(scriptPath, 'utf8');
18
+ } catch {
19
+ throw new ScriptError(`Cannot read script: ${scriptPath}`);
20
+ }
21
+ let raw: unknown;
22
+ try {
23
+ raw = JSON.parse(text);
24
+ } catch (err) {
25
+ throw new ScriptError(`Invalid JSON in ${scriptPath}: ${(err as Error).message}`);
26
+ }
27
+ const script = parseScript(raw, scriptPath);
28
+ const dir = path.dirname(scriptPath);
29
+ if (script.app.storageState) {
30
+ script.app.storageState = path.resolve(dir, script.app.storageState);
31
+ if (!existsSync(script.app.storageState)) {
32
+ throw new ScriptError(`app.storageState not found: ${script.app.storageState}`);
33
+ }
34
+ }
35
+ return { script, scriptPath, dir, name: path.basename(scriptPath).replace(/\.json$/i, '') };
36
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod';
2
+
3
+ const target = z
4
+ .string()
5
+ .min(1)
6
+ .describe("Playwright selector, e.g. role=button[name='Save'], text=Save, #id, [data-testid=x], CSS");
7
+
8
+ export const ShowSchema = z
9
+ .object({
10
+ zoom: z
11
+ .union([z.number().min(1).max(4), z.literal(false)])
12
+ .optional()
13
+ .describe('Zoom depth for this step, or false to disable auto-zoom'),
14
+ caption: z.string().min(1).optional().describe('Caption shown while this step plays'),
15
+ spotlight: z.boolean().optional().describe('Dim everything except the target'),
16
+ hold: z.number().int().min(0).max(30_000).optional().describe('Extra real-time ms after the step'),
17
+ speed: z.number().min(0.25).max(16).optional().describe('Playback speed for this step'),
18
+ })
19
+ .strict();
20
+
21
+ const show = ShowSchema.optional();
22
+
23
+ const exactlyOne = (a: string, b: string) => (s: Record<string, unknown>) =>
24
+ (s[a] === undefined) !== (s[b] === undefined);
25
+
26
+ export const StepSchema = z.discriminatedUnion('do', [
27
+ z.object({ do: z.literal('goto'), url: z.string().min(1), show }).strict(),
28
+ z.object({ do: z.literal('click'), target, show }).strict(),
29
+ z
30
+ .object({
31
+ do: z.literal('type'),
32
+ target,
33
+ text: z.string(),
34
+ pace: z.union([z.enum(['human', 'fast']), z.number().int().min(0).max(1000)]).default('human'),
35
+ clear: z.boolean().default(false),
36
+ show,
37
+ })
38
+ .strict(),
39
+ z.object({ do: z.literal('press'), key: z.string().min(1), show }).strict(),
40
+ z.object({ do: z.literal('hover'), target, show }).strict(),
41
+ z.object({ do: z.literal('select'), target, value: z.string(), show }).strict(),
42
+ z
43
+ .object({ do: z.literal('scroll'), target: target.optional(), by: z.number().int().optional(), show })
44
+ .strict()
45
+ .refine(exactlyOne('target', 'by'), { message: 'scroll needs exactly one of "target" or "by"' }),
46
+ z
47
+ .object({ do: z.literal('wait'), for: target.optional(), ms: z.number().int().min(0).max(60_000).optional(), show })
48
+ .strict()
49
+ .refine(exactlyOne('for', 'ms'), { message: 'wait needs exactly one of "for" or "ms"' }),
50
+ z.object({ do: z.literal('pause'), ms: z.number().int().min(0).max(60_000), show }).strict(),
51
+ ]);
52
+
53
+ export const StyleSchema = z
54
+ .object({
55
+ background: z.string().default('linear-gradient(135deg, #0f172a 0%, #312e81 100%)'),
56
+ padding: z.number().min(0).max(0.3).default(0.06),
57
+ radius: z.number().min(0).max(64).default(14),
58
+ shadow: z.boolean().default(true),
59
+ font: z.string().default('Inter, -apple-system, "Segoe UI", system-ui, sans-serif'),
60
+ accent: z.string().default('#6366f1'),
61
+ autoZoom: z.boolean().default(true),
62
+ autoSpeed: z.boolean().default(true),
63
+ maxZoom: z.number().min(1).max(4).default(1.8),
64
+ chrome: z
65
+ .enum(['browser', 'none'])
66
+ .default('browser')
67
+ .describe('Frame the recording in a browser window with an address bar, or show the bare viewport'),
68
+ address: z.string().optional().describe('Address-bar text; defaults to the app URL without its scheme'),
69
+ })
70
+ .strict();
71
+
72
+ export const AppSchema = z
73
+ .object({
74
+ url: z.string().min(1),
75
+ viewport: z
76
+ .object({ width: z.number().int().min(320).max(3840), height: z.number().int().min(240).max(2160) })
77
+ .strict()
78
+ .default({ width: 1440, height: 900 }),
79
+ deviceScaleFactor: z.number().min(1).max(3).default(2),
80
+ storageState: z.string().optional(),
81
+ colorScheme: z.enum(['light', 'dark']).optional(),
82
+ locale: z.string().optional(),
83
+ timezoneId: z.string().optional(),
84
+ mask: z
85
+ .array(z.string().min(1))
86
+ .default([])
87
+ .describe('CSS selectors (not Playwright selectors) of elements to blur in the recording'),
88
+ })
89
+ .strict();
90
+
91
+ export const SceneSchema = z
92
+ .object({
93
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'scene id may contain letters, digits, "-" and "_"'),
94
+ steps: z.array(StepSchema).min(1),
95
+ })
96
+ .strict();
97
+
98
+ export const ScriptSchema = z
99
+ .object({
100
+ $schema: z.string().optional(),
101
+ version: z.literal(1),
102
+ meta: z.object({ title: z.string().optional(), subtitle: z.string().optional() }).strict().default({}),
103
+ app: AppSchema,
104
+ setup: z.array(StepSchema).default([]),
105
+ style: StyleSchema.prefault({}),
106
+ scenes: z.array(SceneSchema).min(1),
107
+ })
108
+ .strict()
109
+ .superRefine((s, ctx) => {
110
+ const seen = new Set<string>();
111
+ s.scenes.forEach((scene, i) => {
112
+ if (seen.has(scene.id)) {
113
+ ctx.addIssue({ code: 'custom', path: ['scenes', i, 'id'], message: `duplicate scene id "${scene.id}"` });
114
+ }
115
+ seen.add(scene.id);
116
+ });
117
+ });
118
+
119
+ export type Script = z.output<typeof ScriptSchema>;
120
+ export type ScriptInput = z.input<typeof ScriptSchema>;
121
+ export type Step = z.output<typeof StepSchema>;
122
+ export type Show = z.output<typeof ShowSchema>;
123
+ export type Style = z.output<typeof StyleSchema>;
124
+
125
+ export class ScriptError extends Error {
126
+ constructor(message: string) {
127
+ super(message);
128
+ this.name = 'ScriptError';
129
+ }
130
+ }
131
+
132
+ export function formatIssues(issues: ReadonlyArray<{ path: PropertyKey[]; message: string }>): string {
133
+ return issues.map((i) => ` - ${i.path.length ? i.path.map(String).join('.') : '(root)'}: ${i.message}`).join('\n');
134
+ }
135
+
136
+ export function parseScript(raw: unknown, source = 'script'): Script {
137
+ const result = ScriptSchema.safeParse(raw);
138
+ if (!result.success) throw new ScriptError(`Invalid ${source}:\n${formatIssues(result.error.issues)}`);
139
+ return result.data;
140
+ }
141
+
142
+ export function scriptJsonSchema(): Record<string, unknown> {
143
+ return z.toJSONSchema(ScriptSchema, { io: 'input', unrepresentable: 'any' }) as Record<string, unknown>;
144
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lstatSync, mkdirSync, symlinkSync, unlinkSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export const SKILL_DIR = fileURLToPath(new URL('../skill', import.meta.url));
9
+
10
+ /** Where each agent looks for user skills (relative to the home folder). Codex reads the shared ~/.agents/skills. */
11
+ export const SKILL_HOMES = { claude: '.claude/skills', codex: '.agents/skills' } as const;
12
+ export type Agent = keyof typeof SKILL_HOMES;
13
+ export const AGENTS = Object.keys(SKILL_HOMES) as Agent[];
14
+
15
+ /** Symlink the skill into each agent's skills folder; returns the links. Re-linking our own link is fine. */
16
+ export function linkSkill(agents: Agent[], home = homedir(), source = SKILL_DIR): string[] {
17
+ return agents.map((agent) => {
18
+ const dir = path.join(home, SKILL_HOMES[agent]);
19
+ const dest = path.join(dir, 'showtime');
20
+ mkdirSync(dir, { recursive: true });
21
+ const existing = lstatSync(dest, { throwIfNoEntry: false });
22
+ if (existing && !existing.isSymbolicLink()) throw new Error(`${dest} exists and is not a symlink — move it away first.`);
23
+ if (existing) unlinkSync(dest);
24
+ symlinkSync(source, dest, 'junction');
25
+ return dest;
26
+ });
27
+ }
28
+
29
+ /** Download the Chromium build this copy of Playwright drives, and Remotion's headless shell. */
30
+ export async function installBrowsers(): Promise<void> {
31
+ // Our own Playwright CLI: a bare `npx playwright` may resolve another version with another Chromium.
32
+ const require = createRequire(import.meta.url);
33
+ const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
34
+ await new Promise<void>((resolve, reject) => {
35
+ const child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' });
36
+ child.on('error', reject);
37
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`playwright install chromium exited with code ${code}`))));
38
+ });
39
+ const { ensureBrowser } = await import('@remotion/renderer');
40
+ await ensureBrowser();
41
+ }
package/src/take.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { workPaths } from './paths';
5
+ import { actionHash } from './script/hash';
6
+ import type { LoadedScript } from './script/load';
7
+ import type { TakeEvents } from './timeline/types';
8
+
9
+ export async function loadTake(loaded: LoadedScript): Promise<TakeEvents> {
10
+ const paths = workPaths(loaded);
11
+ let text: string;
12
+ try {
13
+ text = await readFile(paths.events, 'utf8');
14
+ } catch {
15
+ throw new Error(`No take found for "${loaded.name}". Run: showtime record ${loaded.scriptPath}`);
16
+ }
17
+ const events = JSON.parse(text) as TakeEvents;
18
+ if (events.actionHash !== actionHash(loaded.script)) {
19
+ throw new Error(`Actions changed since the take (presentation-only edits are fine). Run: showtime record ${loaded.scriptPath}`);
20
+ }
21
+ const video = path.join(paths.take, events.video.file);
22
+ if (!existsSync(video)) throw new Error(`${video} is missing. Run: showtime record ${loaded.scriptPath}`);
23
+ return events;
24
+ }
@@ -0,0 +1,103 @@
1
+ import type { Script, Step } from '../script/schema';
2
+ import { buildCamera, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
3
+ import { clamp } from './math';
4
+ import { buildSegments, type SpeedSpan } from './timemap';
5
+ import type { Caption, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
6
+
7
+ export const AUTO_SPEED_MIN_IDLE_MS = 800;
8
+ export const AUTO_SPEED_TARGET_MS = 600;
9
+ export const INTRO_MS = 2200;
10
+ export const CAPTION_MIN_MS = 1500;
11
+ export const SPOTLIGHT_PAD = 8;
12
+
13
+ const ZOOMABLE = new Set<Step['do']>(['click', 'type', 'select', 'hover']);
14
+
15
+ export interface BuildOptions {
16
+ fps?: number;
17
+ output?: { width: number; height: number };
18
+ }
19
+
20
+ function stepOf(script: Script, e: StepEvent): Step {
21
+ const step = script.scenes.find((s) => s.id === e.sceneId)?.steps[e.index];
22
+ if (!step) throw new Error(`Take does not match the script: no step ${e.index + 1} in scene "${e.sceneId}"`);
23
+ return step;
24
+ }
25
+
26
+ /** "https://app.acme.com/reports/" → "app.acme.com/reports". */
27
+ function displayAddress(url: string): string {
28
+ try {
29
+ const u = new URL(url);
30
+ return (u.host + u.pathname).replace(/\/$/, '');
31
+ } catch {
32
+ return url;
33
+ }
34
+ }
35
+
36
+ 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
+
38
+ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions = {}): Timeline {
39
+ const { style } = script;
40
+ const stage = ev.viewport;
41
+ const pairs = ev.steps.map((e) => ({ e, step: stepOf(script, e) }));
42
+
43
+ // Speed: auto idle first, explicit `show.speed` next, pauses last (later spans win).
44
+ const spans: SpeedSpan[] = [];
45
+ if (style.autoSpeed) {
46
+ for (const i of ev.idle) {
47
+ const len = i.endMs - i.startMs;
48
+ if (len > AUTO_SPEED_MIN_IDLE_MS) spans.push({ ...i, speed: clamp(len / AUTO_SPEED_TARGET_MS, 2, 8) });
49
+ }
50
+ }
51
+ for (const { e, step } of pairs) {
52
+ if (step.show?.speed !== undefined) spans.push({ startMs: e.startMs, endMs: e.settledMs, speed: step.show.speed });
53
+ }
54
+ for (const { e, step } of pairs) {
55
+ if (step.do === 'pause') spans.push({ startMs: e.startMs, endMs: e.endMs, speed: 1 });
56
+ }
57
+
58
+ const regions: ZoomRegion[] = [];
59
+ const captions: Caption[] = [];
60
+ const spotlights: Spotlight[] = [];
61
+ for (const { e, step } of pairs) {
62
+ const zoom = step.show?.zoom;
63
+ 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) {
67
+ regions.push({
68
+ startMs: e.actionMs,
69
+ endMs: e.settledMs,
70
+ scale,
71
+ cx: (e.target.x + e.target.width / 2) / stage.width,
72
+ cy: (e.target.y + e.target.height / 2) / stage.height,
73
+ });
74
+ }
75
+ }
76
+ if (step.show?.caption) {
77
+ captions.push({ startMs: e.startMs, endMs: Math.max(e.settledMs, e.startMs + CAPTION_MIN_MS), text: step.show.caption });
78
+ }
79
+ if (step.show?.spotlight && e.target) {
80
+ spotlights.push({ startMs: e.actionMs, endMs: e.settledMs, rect: pad(e.target, SPOTLIGHT_PAD) });
81
+ }
82
+ }
83
+
84
+ return {
85
+ version: 1,
86
+ fps: opts.fps ?? 30,
87
+ output: opts.output ?? { width: 1920, height: 1080 },
88
+ stage,
89
+ video: { src: ev.video.file, durationMs: ev.video.durationMs },
90
+ intro: script.meta.title ? { title: script.meta.title, subtitle: script.meta.subtitle, durationMs: INTRO_MS } : null,
91
+ chrome:
92
+ style.chrome === 'browser'
93
+ ? { address: style.address ?? displayAddress(script.app.url), dark: script.app.colorScheme === 'dark' }
94
+ : null,
95
+ segments: buildSegments(ev.video.durationMs, spans),
96
+ camera: buildCamera(regions),
97
+ moves: ev.moves,
98
+ clicks: ev.clicks,
99
+ captions,
100
+ spotlights,
101
+ style,
102
+ };
103
+ }
@@ -0,0 +1,69 @@
1
+ import { clamp, easeInOutCubic, lerp } from './math';
2
+ import type { CameraKey, Rect } from './types';
3
+
4
+ export const ZOOM_LEAD_MS = 700;
5
+ export const CONNECT_GAP_MS = 1500;
6
+ export const MIN_ZOOM = 1.15;
7
+ const ZOOM_MARGIN_PX = 160;
8
+
9
+ export interface ZoomRegion {
10
+ startMs: number;
11
+ endMs: number;
12
+ scale: number;
13
+ cx: number;
14
+ cy: number;
15
+ }
16
+
17
+ export function zoomDepth(target: Rect, stage: { width: number; height: number }, maxZoom: number): number {
18
+ const fit = Math.min(stage.width / (target.width + ZOOM_MARGIN_PX), stage.height / (target.height + ZOOM_MARGIN_PX));
19
+ return clamp(fit, 1, maxZoom);
20
+ }
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) };
25
+ }
26
+
27
+ const rest = (tMs: number): CameraKey => ({ tMs, scale: 1, cx: 0.5, cy: 0.5 });
28
+
29
+ export function buildCamera(regions: ZoomRegion[]): CameraKey[] {
30
+ const rs = [...regions].sort((a, b) => a.startMs - b.startMs);
31
+ const keys: CameraKey[] = [];
32
+ rs.forEach((r, i) => {
33
+ const prev = rs[i - 1];
34
+ const next = rs[i + 1];
35
+ const start = prev ? Math.max(r.startMs, prev.endMs) : r.startMs;
36
+ const end = Math.max(start, r.endMs);
37
+ const focus = clampFocus(r.scale, r.cx, r.cy);
38
+ if (!prev || start - prev.endMs > CONNECT_GAP_MS) {
39
+ const lastT = keys.length ? keys[keys.length - 1].tMs : 0;
40
+ keys.push(rest(Math.max(lastT, start - ZOOM_LEAD_MS)));
41
+ }
42
+ keys.push({ tMs: start, scale: r.scale, ...focus });
43
+ keys.push({ tMs: end, scale: r.scale, ...focus });
44
+ if (!next || next.startMs - end > CONNECT_GAP_MS) keys.push(rest(end + ZOOM_LEAD_MS));
45
+ });
46
+ return keys;
47
+ }
48
+
49
+ export function cameraAt(keys: CameraKey[], tMs: number): CameraKey {
50
+ if (!keys.length) return { ...rest(tMs) };
51
+ if (tMs <= keys[0].tMs) return { ...keys[0], tMs };
52
+ for (let i = 1; i < keys.length; i++) {
53
+ const b = keys[i];
54
+ if (tMs > b.tMs) continue;
55
+ const a = keys[i - 1];
56
+ if (b.tMs === a.tMs) return { ...b, tMs };
57
+ const e = easeInOutCubic((tMs - a.tMs) / (b.tMs - a.tMs));
58
+ return { tMs, scale: lerp(a.scale, b.scale, e), cx: lerp(a.cx, b.cx, e), cy: lerp(a.cy, b.cy, e) };
59
+ }
60
+ return { ...keys[keys.length - 1], tMs };
61
+ }
62
+
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;
67
+ const r = (v: number) => Math.round(v * 100) / 100 + 0;
68
+ return `translate(${r(tx)}px, ${r(ty)}px) scale(${r(cam.scale * 1000) / 1000})`;
69
+ }