@mineo71/showtime 0.2.1 → 0.3.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/src/cli/main.ts CHANGED
@@ -1,17 +1,38 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { Command } from 'commander';
3
3
  import { workPaths } from '../paths';
4
- import { loadScript } from '../script/load';
4
+ import { loadScript, type LoadedScript } from '../script/load';
5
5
  import { scriptJsonSchema } from '../script/schema';
6
- import { formatCheck } from './format';
6
+ import type { TakeEvents } from '../timeline/types';
7
+ import { formatCheck, formatLint } from './format';
7
8
 
8
- const say = (line: string) => process.stdout.write(`${line}\n`);
9
- const progress = (p: number) => {
10
- if (process.stdout.isTTY) process.stdout.write(`\rRendering ${Math.round(p * 100)}%`);
9
+ const say = (line: string): void => {
10
+ process.stdout.write(`${line}\n`);
11
11
  };
12
+ /** With --json, stdout carries exactly one JSON document. */
13
+ const emit = (data: unknown): void => say(JSON.stringify(data, null, 2));
14
+ const progressFor = (json?: boolean) =>
15
+ json
16
+ ? undefined
17
+ : (p: number) => {
18
+ if (process.stdout.isTTY) process.stdout.write(`\rRendering ${Math.round(p * 100)}%`);
19
+ };
12
20
 
13
21
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
14
22
 
23
+ const takeSummary = (loaded: LoadedScript, ev: TakeEvents) => ({
24
+ take: workPaths(loaded).take,
25
+ durationMs: ev.video.durationMs,
26
+ steps: ev.steps.length,
27
+ });
28
+
29
+ async function render(loaded: LoadedScript, o: { out?: string; json?: boolean }) {
30
+ const { renderDemo } = await import('../pipeline');
31
+ const { probeVideo } = await import('../ffmpeg');
32
+ const video = await renderDemo(loaded, { out: o.out, onProgress: progressFor(o.json) });
33
+ return { video, durationMs: (await probeVideo(video)).durationMs };
34
+ }
35
+
15
36
  const program = new Command('showtime')
16
37
  .description('AI-directed product demo videos: JSON script in, polished MP4 out')
17
38
  .version(version);
@@ -21,23 +42,27 @@ program
21
42
  .description('download the browsers showTime records with and link the skill into Claude Code and Codex')
22
43
  .option('--agent <agent>', 'claude, codex or all', 'all')
23
44
  .option('--no-browsers', 'only link the skill')
24
- .action(async (o: { agent: string; browsers: boolean }) => {
45
+ .option('--json', 'print the result as JSON')
46
+ .action(async (o: { agent: string; browsers: boolean; json?: boolean }) => {
25
47
  const { AGENTS, installBrowsers, linkSkill } = await import('../setup');
26
48
  const agents = o.agent === 'all' ? AGENTS : AGENTS.filter((a) => a === o.agent);
27
49
  if (!agents.length) throw new Error(`--agent must be ${AGENTS.join(', ')} or all`);
28
50
  if (o.browsers) {
29
- say('Downloading Chromium and the Remotion renderer browser…');
30
- await installBrowsers();
51
+ if (!o.json) say('Downloading Chromium and the Remotion renderer browser…');
52
+ await installBrowsers({ quiet: o.json });
31
53
  }
32
- for (const dest of linkSkill(agents)) say(`✓ skill linked: ${dest}`);
54
+ const links = linkSkill(agents);
55
+ if (o.json) return emit({ links, browsers: o.browsers });
56
+ for (const dest of links) say(`✓ skill linked: ${dest}`);
33
57
  say('Ready. Ask your agent for a demo, or run `showtime make <script>`.');
34
58
  });
35
59
 
36
60
  program
37
61
  .command('schema')
38
62
  .description('print the JSON Schema of demo scripts')
63
+ .option('--json', 'accepted for symmetry; the output is always JSON')
39
64
  .action(() => {
40
- say(JSON.stringify(scriptJsonSchema(), null, 2));
65
+ emit(scriptJsonSchema());
41
66
  });
42
67
 
43
68
  program
@@ -49,41 +74,66 @@ program
49
74
  const loaded = await loadScript(file);
50
75
  const { check } = await import('../runner/check');
51
76
  const report = await check(loaded, { headed: o.headed, log: o.json ? undefined : say });
52
- say(o.json ? JSON.stringify(report, null, 2) : formatCheck(report));
77
+ if (o.json) emit(report);
78
+ else say(formatCheck(report));
53
79
  if (!report.ok) process.exitCode = 1;
54
80
  });
55
81
 
82
+ program
83
+ .command('lint <script>')
84
+ .description('flag script problems, and timing problems when a fresh take exists')
85
+ .option('--json', 'print { issues, timing } as JSON')
86
+ .action(async (file: string, o: { json?: boolean }) => {
87
+ const loaded = await loadScript(file);
88
+ const { lintScript, lintTiming } = await import('../lint');
89
+ const issues = lintScript(loaded.script);
90
+ const { loadTake } = await import('../take');
91
+ const events = await loadTake(loaded).catch(() => null);
92
+ if (events) {
93
+ const { buildTimeline } = await import('../timeline/build');
94
+ issues.push(...lintTiming(buildTimeline(loaded.script, events), events.steps));
95
+ }
96
+ if (o.json) emit({ issues, timing: events !== null });
97
+ else say(formatLint(issues, events !== null));
98
+ if (issues.some((i) => i.level === 'warn')) process.exitCode = 1;
99
+ });
100
+
56
101
  program
57
102
  .command('record <script>')
58
103
  .description('record a take (raw video + event log) into .showtime/<name>/take')
59
104
  .option('--headed', 'show the browser')
60
105
  .option('--keep-frames', 'keep the captured JPEG frames')
61
- .action(async (file: string, o: { headed?: boolean; keepFrames?: boolean }) => {
106
+ .option('--json', 'print { take, durationMs, steps } as JSON')
107
+ .action(async (file: string, o: { headed?: boolean; keepFrames?: boolean; json?: boolean }) => {
62
108
  const loaded = await loadScript(file);
63
109
  const { record } = await import('../runner/record');
64
- const ev = await record(loaded, { headed: o.headed, keepFrames: o.keepFrames, log: say });
65
- say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s, ${ev.steps.length} steps → ${workPaths(loaded).take}`);
110
+ const ev = await record(loaded, { headed: o.headed, keepFrames: o.keepFrames, log: o.json ? undefined : say });
111
+ const take = takeSummary(loaded, ev);
112
+ if (o.json) emit(take);
113
+ else say(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s, ${take.steps} steps → ${take.take}`);
66
114
  });
67
115
 
68
116
  program
69
117
  .command('render <script>')
70
118
  .description('render the polished video from the last take (no re-recording)')
71
119
  .option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
72
- .action(async (file: string, o: { out?: string }) => {
73
- const loaded = await loadScript(file);
74
- const { renderDemo } = await import('../pipeline');
75
- const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
76
- say(`\n✓ ${out}`);
120
+ .option('--json', 'print { video, durationMs } as JSON')
121
+ .action(async (file: string, o: { out?: string; json?: boolean }) => {
122
+ const result = await render(await loadScript(file), o);
123
+ if (o.json) emit(result);
124
+ else say(`\n✓ ${result.video}`);
77
125
  });
78
126
 
79
127
  program
80
128
  .command('frames <script>')
81
- .description('render QA stills (intro + one per step) for visual review')
82
- .action(async (file: string) => {
83
- const loaded = await loadScript(file);
129
+ .description('render QA stills (intro, one per step, outro) and a contact sheet of them')
130
+ .option('--json', 'print { sheet, frames } as JSON')
131
+ .action(async (file: string, o: { json?: boolean }) => {
84
132
  const { renderQaFrames } = await import('../pipeline');
85
- const files = await renderQaFrames(loaded);
86
- say(files.join('\n'));
133
+ const { sheet, frames } = await renderQaFrames(await loadScript(file));
134
+ if (o.json) return emit({ sheet, frames });
135
+ say(`sheet: ${sheet}`);
136
+ say(frames.join('\n'));
87
137
  });
88
138
 
89
139
  program
@@ -91,22 +141,25 @@ program
91
141
  .description('check → record → render')
92
142
  .option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
93
143
  .option('--headed', 'show the browser')
94
- .action(async (file: string, o: { out?: string; headed?: boolean }) => {
144
+ .option('--json', 'print { check, take, video } as JSON')
145
+ .action(async (file: string, o: { out?: string; headed?: boolean; json?: boolean }) => {
95
146
  const loaded = await loadScript(file);
147
+ const log = o.json ? undefined : say;
96
148
  const { check } = await import('../runner/check');
97
149
  const report = await check(loaded, { headed: o.headed });
98
150
  if (!report.ok) {
99
- say(formatCheck(report));
151
+ if (o.json) emit({ check: report });
152
+ else say(formatCheck(report));
100
153
  process.exitCode = 1;
101
154
  return;
102
155
  }
103
- say(`✓ check passed (${report.steps.length} steps)`);
156
+ log?.(`✓ check passed (${report.steps.length} steps)`);
104
157
  const { record } = await import('../runner/record');
105
- const ev = await record(loaded, { headed: o.headed, log: say });
106
- say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s`);
107
- const { renderDemo } = await import('../pipeline');
108
- const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
109
- say(`\n✓ ${out}`);
158
+ const take = takeSummary(loaded, await record(loaded, { headed: o.headed, log }));
159
+ log?.(`✓ take: ${(take.durationMs / 1000).toFixed(1)} s`);
160
+ const video = await render(loaded, o);
161
+ if (o.json) emit({ check: report, take, video });
162
+ else say(`\n✓ ${video.video}`);
110
163
  });
111
164
 
112
165
  program.parseAsync().catch((err: Error) => {
package/src/lint.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { Script, Step } from './script/schema';
2
+ import { outputDurationMs, sourceToOutput } from './timeline/timemap';
3
+ import type { StepEvent, Timeline } from './timeline/types';
4
+
5
+ export interface Issue {
6
+ rule: 'caption-length' | 'pause-for-loading' | 'show-ignored' | 'no-intro' | 'caption-too-short' | 'video-length';
7
+ level: 'warn' | 'info';
8
+ /** `scene#step` (1-based), `setup#n`, or `script` */
9
+ where: string;
10
+ message: string;
11
+ }
12
+
13
+ const MAX_CAPTION_WORDS = 6;
14
+ const LOADING_PAUSE_MS = 1500;
15
+ const ACTIONS = new Set<Step['do']>(['click', 'press', 'type', 'select']);
16
+ const TARGETED = new Set<Step['do']>(['click', 'type', 'hover', 'select']);
17
+
18
+ const words = (text: string) => text.trim().split(/\s+/).filter(Boolean).length;
19
+ const hasTarget = (s: Step) => TARGETED.has(s.do) || (s.do === 'scroll' && s.target !== undefined);
20
+ /** Reading time for a caption: half a second to notice it, a quarter per word, never under 1.2 s. */
21
+ export const readingMs = (text: string) => Math.max(1200, 500 + 250 * words(text));
22
+
23
+ /** Checks that need only the script. */
24
+ export function lintScript(script: Script): Issue[] {
25
+ const issues: Issue[] = [];
26
+ if (!script.meta.title) {
27
+ issues.push({ rule: 'no-intro', level: 'info', where: 'script', message: 'No meta.title, so the video starts without an intro card.' });
28
+ }
29
+ script.setup.forEach((step, i) => {
30
+ if (step.show) {
31
+ issues.push({
32
+ rule: 'show-ignored',
33
+ level: 'warn',
34
+ where: `setup#${i + 1}`,
35
+ message: 'Setup steps run before the camera starts, so their `show` does nothing.',
36
+ });
37
+ }
38
+ });
39
+ for (const scene of script.scenes) {
40
+ scene.steps.forEach((step, i) => {
41
+ const where = `${scene.id}#${i + 1}`;
42
+ const caption = step.show?.caption;
43
+ if (caption && words(caption) > MAX_CAPTION_WORDS) {
44
+ issues.push({
45
+ rule: 'caption-length',
46
+ level: 'warn',
47
+ where,
48
+ message: `Caption has ${words(caption)} words; keep it to ${MAX_CAPTION_WORDS} or fewer: "${caption}"`,
49
+ });
50
+ }
51
+ const zooms = typeof step.show?.zoom === 'number';
52
+ if ((zooms || step.show?.spotlight) && !hasTarget(step)) {
53
+ issues.push({
54
+ rule: 'show-ignored',
55
+ level: 'warn',
56
+ where,
57
+ message: `\`${step.do}\` has no target, so ${zooms ? 'zoom' : 'spotlight'} does nothing here.`,
58
+ });
59
+ }
60
+ const prev = scene.steps[i - 1];
61
+ if (step.do === 'pause' && step.ms >= LOADING_PAUSE_MS && prev && ACTIONS.has(prev.do)) {
62
+ issues.push({
63
+ rule: 'pause-for-loading',
64
+ level: 'warn',
65
+ where,
66
+ message: 'A long pause right after an action usually waits for loading. Use { "do": "wait", "for": "<what appears>" } so it is sped up.',
67
+ });
68
+ }
69
+ });
70
+ }
71
+ return issues;
72
+ }
73
+
74
+ /** Checks on the edited result: caption reading time and overall length (output time). */
75
+ export function lintTiming(timeline: Timeline, steps: StepEvent[]): Issue[] {
76
+ const issues: Issue[] = [];
77
+ for (const c of timeline.captions) {
78
+ const shown = sourceToOutput(timeline.segments, c.endMs) - sourceToOutput(timeline.segments, c.startMs);
79
+ const need = readingMs(c.text);
80
+ if (shown < need) {
81
+ const step = steps.find((s) => s.startMs === c.startMs);
82
+ issues.push({
83
+ rule: 'caption-too-short',
84
+ level: 'warn',
85
+ where: step ? `${step.sceneId}#${step.index + 1}` : 'script',
86
+ // Round shown down and needed up, so the numbers never read as equal.
87
+ message: `"${c.text}" is on screen for ${(Math.floor(shown / 100) / 10).toFixed(1)} s but needs about ${(Math.ceil(need / 100) / 10).toFixed(1)} s to read. Add show.hold or slow the step down.`,
88
+ });
89
+ }
90
+ }
91
+ const totalMs = (timeline.intro?.durationMs ?? 0) + outputDurationMs(timeline.segments) + (timeline.outro?.durationMs ?? 0);
92
+ if (totalMs > 60_000) {
93
+ issues.push({
94
+ rule: 'video-length',
95
+ level: totalMs > 120_000 ? 'warn' : 'info',
96
+ where: 'script',
97
+ message: `The video runs ${Math.round(totalMs / 1000)} s; pitch demos land best under a minute.`,
98
+ });
99
+ }
100
+ return issues;
101
+ }
package/src/logo.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const TYPES: Record<string, string> = {
5
+ '.svg': 'image/svg+xml',
6
+ '.png': 'image/png',
7
+ '.jpg': 'image/jpeg',
8
+ '.jpeg': 'image/jpeg',
9
+ '.webp': 'image/webp',
10
+ };
11
+ const MAX_BYTES = 2_000_000;
12
+
13
+ /** The logo file as a data URI, so timeline.json carries it to the renderer without extra files. */
14
+ export async function logoDataUri(file: string): Promise<string> {
15
+ const type = TYPES[path.extname(file).toLowerCase()];
16
+ if (!type) throw new Error(`meta.logo must be an SVG, PNG, JPEG or WebP file: ${file}`);
17
+ const data = await readFile(file);
18
+ if (data.length > MAX_BYTES) throw new Error(`meta.logo is larger than 2 MB: ${file}`);
19
+ return `data:${type};base64,${data.toString('base64')}`;
20
+ }
package/src/paths.ts CHANGED
@@ -5,11 +5,15 @@ export interface WorkPaths {
5
5
  root: string;
6
6
  check: string;
7
7
  take: string;
8
+ /** a take being recorded; swapped in for `take` only when it succeeds */
9
+ takeTmp: string;
8
10
  rawFrames: string;
9
11
  raw: string;
10
12
  events: string;
11
13
  timeline: string;
12
14
  stills: string;
15
+ /** contact sheet of the stills */
16
+ sheet: string;
13
17
  video: string;
14
18
  }
15
19
 
@@ -20,11 +24,13 @@ export function workPaths(loaded: LoadedScript): WorkPaths {
20
24
  root,
21
25
  check: path.join(root, 'check'),
22
26
  take,
27
+ takeTmp: path.join(root, 'take.tmp'),
23
28
  rawFrames: path.join(take, 'frames'),
24
29
  raw: path.join(take, 'raw.mp4'),
25
30
  events: path.join(take, 'events.json'),
26
31
  timeline: path.join(root, 'timeline.json'),
27
32
  stills: path.join(root, 'frames'),
33
+ sheet: path.join(root, 'frames', 'sheet.png'),
28
34
  video: path.join(loaded.dir, `${loaded.name}.mp4`),
29
35
  };
30
36
  }
package/src/pipeline.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { logoDataUri } from './logo';
3
4
  import { workPaths } from './paths';
4
5
  import { renderStills, renderVideo } from './render/render';
6
+ import { contactSheet } from './sheet';
5
7
  import type { LoadedScript } from './script/load';
6
8
  import { loadTake } from './take';
7
9
  import { buildTimeline } from './timeline/build';
@@ -10,7 +12,8 @@ import type { TakeEvents, Timeline } from './timeline/types';
10
12
 
11
13
  export async function timelineFor(loaded: LoadedScript): Promise<{ timeline: Timeline; events: TakeEvents }> {
12
14
  const events = await loadTake(loaded);
13
- const timeline = buildTimeline(loaded.script, events);
15
+ const { logo } = loaded.script.meta;
16
+ const timeline = buildTimeline(loaded.script, events, { logo: logo ? await logoDataUri(logo) : undefined });
14
17
  const paths = workPaths(loaded);
15
18
  await mkdir(paths.root, { recursive: true });
16
19
  await writeFile(paths.timeline, JSON.stringify(timeline, null, 2));
@@ -29,10 +32,16 @@ export async function renderDemo(
29
32
  return out;
30
33
  }
31
34
 
32
- export async function renderQaFrames(loaded: LoadedScript): Promise<string[]> {
35
+ /** QA stills (intro, one per step, outro) and a contact sheet of all of them. */
36
+ export async function renderQaFrames(loaded: LoadedScript): Promise<{ sheet: string; frames: string[] }> {
33
37
  const { timeline, events } = await timelineFor(loaded);
34
38
  const paths = workPaths(loaded);
35
39
  await rm(paths.stills, { recursive: true, force: true });
36
40
  await mkdir(paths.stills, { recursive: true });
37
- return renderStills(timeline, paths.take, paths.stills, qaShots(timeline, events.steps));
41
+ const frames = await renderStills(timeline, paths.take, paths.stills, qaShots(timeline, events.steps));
42
+ const sheet = await contactSheet(
43
+ frames.map((file) => ({ file, label: path.basename(file, '.png') })),
44
+ paths.sheet,
45
+ );
46
+ return { sheet, frames };
38
47
  }
@@ -2,6 +2,7 @@ import type { FC } from 'react';
2
2
  import { AbsoluteFill, Easing, interpolate } from 'remotion';
3
3
  import { introFrames } from '../../timeline/frames';
4
4
  import type { Timeline } from '../../timeline/types';
5
+ import { Logo } from './Logo';
5
6
 
6
7
  export const Intro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t, frame }) => {
7
8
  const total = introFrames(t);
@@ -20,6 +21,7 @@ export const Intro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t,
20
21
  }}
21
22
  >
22
23
  <div style={{ opacity: Math.min(enter, exit), transform: `scale(${0.94 + 0.06 * enter})` }}>
24
+ <Logo src={t.logo} />
23
25
  <div style={{ fontSize: 88, fontWeight: 800, letterSpacing: -1.5 }}>{t.intro?.title}</div>
24
26
  {t.intro?.subtitle ? <div style={{ marginTop: 18, fontSize: 36, opacity: 0.75 }}>{t.intro.subtitle}</div> : null}
25
27
  <div style={{ margin: '28px auto 0', width: 72, height: 6, borderRadius: 3, background: t.style.accent }} />
@@ -0,0 +1,6 @@
1
+ import type { FC } from 'react';
2
+ import { Img } from 'remotion';
3
+
4
+ /** The brand logo on the intro and outro cards; nothing when there is none. */
5
+ export const Logo: FC<{ src: string | null; height?: number }> = ({ src, height = 120 }) =>
6
+ src ? <Img src={src} style={{ height, width: 'auto', display: 'block', margin: '0 auto 36px' }} /> : null;
@@ -0,0 +1,38 @@
1
+ import type { FC } from 'react';
2
+ import { AbsoluteFill, Easing, interpolate } from 'remotion';
3
+ import type { Timeline } from '../../timeline/types';
4
+ import { Logo } from './Logo';
5
+
6
+ /** Closing card: logo, title, subtitle and the URL in an accent pill. `frame` counts from the outro start. */
7
+ export const Outro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t, frame }) => {
8
+ const enter = interpolate(frame, [0, 18], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic) });
9
+ const o = t.outro;
10
+ return (
11
+ <AbsoluteFill
12
+ style={{ background: t.style.background, fontFamily: t.style.font, alignItems: 'center', justifyContent: 'center', color: '#fff', textAlign: 'center' }}
13
+ >
14
+ <div style={{ opacity: enter, transform: `translateY(${(1 - enter) * 24}px)` }}>
15
+ <Logo src={t.logo} />
16
+ <div style={{ fontSize: 80, fontWeight: 800, letterSpacing: -1.5 }}>{o?.title}</div>
17
+ {o?.subtitle ? <div style={{ marginTop: 18, fontSize: 34, opacity: 0.75 }}>{o.subtitle}</div> : null}
18
+ {o?.url ? (
19
+ <div
20
+ style={{
21
+ display: 'inline-block',
22
+ marginTop: 40,
23
+ padding: '14px 34px',
24
+ borderRadius: 999,
25
+ border: `2px solid ${t.style.accent}`,
26
+ background: 'rgba(255, 255, 255, 0.08)',
27
+ fontSize: 30,
28
+ fontWeight: 600,
29
+ letterSpacing: 0.2,
30
+ }}
31
+ >
32
+ {o.url}
33
+ </div>
34
+ ) : null}
35
+ </div>
36
+ </AbsoluteFill>
37
+ );
38
+ };
@@ -55,7 +55,8 @@ export const Ripples: FC<LayerProps> = ({ timeline, srcMs }) => (
55
55
  </>
56
56
  );
57
57
 
58
- export const Captions: FC<LayerProps> = ({ timeline, srcMs }) => {
58
+ /** `top` is where a top caption's box starts (output px); bottom captions sit at a fixed inset. */
59
+ export const Captions: FC<LayerProps & { top: number }> = ({ timeline, srcMs, top }) => {
59
60
  const active = timeline.captions.filter((c) => srcMs >= c.startMs && srcMs <= c.endMs);
60
61
  const c = active[active.length - 1];
61
62
  if (!c) return null;
@@ -66,7 +67,7 @@ export const Captions: FC<LayerProps> = ({ timeline, srcMs }) => {
66
67
  position: 'absolute',
67
68
  left: 0,
68
69
  right: 0,
69
- bottom: Math.round(timeline.output.height * 0.075),
70
+ ...(c.position === 'top' ? { top } : { bottom: Math.round(timeline.output.height * 0.075) }),
70
71
  display: 'flex',
71
72
  justifyContent: 'center',
72
73
  }}
@@ -85,7 +86,7 @@ export const Captions: FC<LayerProps> = ({ timeline, srcMs }) => {
85
86
  textAlign: 'center',
86
87
  boxShadow: '0 12px 40px rgba(0, 0, 0, 0.35)',
87
88
  opacity: o,
88
- transform: `translateY(${(1 - o) * 12}px)`,
89
+ transform: `translateY(${(1 - o) * (c.position === 'top' ? -12 : 12)}px)`,
89
90
  }}
90
91
  >
91
92
  {c.text}
@@ -1,12 +1,13 @@
1
1
  import type { FC } from 'react';
2
2
  import { AbsoluteFill, Easing, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
3
3
  import { cameraAt, cameraTransform } from '../../timeline/camera';
4
- import { introFrames, segmentPlan, sourceMsAtFrame } from '../../timeline/frames';
4
+ import { introFrames, segmentPlan, sourceMsAtFrame, videoEndFrame } from '../../timeline/frames';
5
5
  import { BROWSER_BAR, windowLayout } from '../../timeline/layout';
6
6
  import type { Timeline } from '../../timeline/types';
7
7
  import { BrowserBar } from './BrowserBar';
8
8
  import { Cursor } from './Cursor';
9
9
  import { Intro } from './Intro';
10
+ import { Outro } from './Outro';
10
11
  import { Captions, Ripples, Spotlights } from './Overlays';
11
12
 
12
13
  export type ShowtimeVideoProps = { timeline: Timeline };
@@ -17,6 +18,8 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
17
18
  const frame = useCurrentFrame();
18
19
  const intro = introFrames(t);
19
20
  if (frame < intro) return <Intro timeline={t} frame={frame} />;
21
+ const end = videoEndFrame(t);
22
+ if (t.outro && frame >= end) return <Outro timeline={t} frame={frame - end} />;
20
23
 
21
24
  const src = sourceMsAtFrame(t, frame) ?? 0;
22
25
  const win = windowLayout(t.output, t.stage, t.style.padding, t.chrome ? BROWSER_BAR : 0);
@@ -26,6 +29,8 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
26
29
  const enter = intro
27
30
  ? interpolate(frame - intro, [0, 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic) })
28
31
  : 1;
32
+ // Before an outro card the window and its captions fade out over the last 12 frames.
33
+ const leave = t.outro ? interpolate(frame, [end - 12, end], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) : 1;
29
34
 
30
35
  return (
31
36
  <AbsoluteFill style={{ background: t.style.background, fontFamily: t.style.font }}>
@@ -40,7 +45,7 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
40
45
  overflow: 'hidden',
41
46
  background: '#fff',
42
47
  boxShadow: t.style.shadow ? SHADOW : undefined,
43
- opacity: enter,
48
+ opacity: enter * leave,
44
49
  transform: `translateY(${(1 - enter) * 40}px) scale(${0.96 + 0.04 * enter})`,
45
50
  }}
46
51
  >
@@ -86,7 +91,9 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
86
91
  </div>
87
92
  </div>
88
93
  </div>
89
- <Captions timeline={t} srcMs={src} />
94
+ <div style={{ opacity: leave }}>
95
+ <Captions timeline={t} srcMs={src} top={win.y + Math.round(t.output.height * 0.03)} />
96
+ </div>
90
97
  </AbsoluteFill>
91
98
  );
92
99
  };
@@ -1,5 +1,5 @@
1
- import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
2
- import { ScriptError, type Script } from '../script/schema';
1
+ import { chromium, type Browser, type BrowserContext, type Page, type Request, type Route } from 'playwright';
2
+ import { ScriptError, type Mock, type Script } from '../script/schema';
3
3
  import type { Pt } from '../timeline/types';
4
4
  import { EventLog } from './events';
5
5
  import { runStep, STEP_TIMEOUT_MS, type StepContext } from './steps';
@@ -18,8 +18,12 @@ export interface Session {
18
18
  close(): Promise<void>;
19
19
  }
20
20
 
21
+ /** Event streams never finish; counting them would hold every step for the full settle cap. */
22
+ const isStream = (r: Request) => r.resourceType() === 'eventsource' || (r.headers().accept ?? '').includes('text/event-stream');
23
+
21
24
  export function trackNetwork(page: Page): NetworkTracker {
22
25
  let inflight = 0;
26
+ const counted = new Set<Request>();
23
27
  let lastChange = Date.now();
24
28
  const up = () => {
25
29
  inflight++;
@@ -29,9 +33,16 @@ export function trackNetwork(page: Page): NetworkTracker {
29
33
  inflight = Math.max(0, inflight - 1);
30
34
  lastChange = Date.now();
31
35
  };
32
- page.on('request', up);
33
- page.on('requestfinished', down);
34
- page.on('requestfailed', down);
36
+ page.on('request', (r) => {
37
+ if (isStream(r)) return;
38
+ counted.add(r);
39
+ up();
40
+ });
41
+ const done = (r: Request) => {
42
+ if (counted.delete(r)) down();
43
+ };
44
+ page.on('requestfinished', done);
45
+ page.on('requestfailed', done);
35
46
  return {
36
47
  async idle(quietMs, capMs) {
37
48
  const start = Date.now();
@@ -79,8 +90,15 @@ export async function openSession(script: Script, opts: { headed?: boolean } = {
79
90
  timezoneId: app.timezoneId,
80
91
  });
81
92
  if (app.mask.length) await context.addInitScript({ content: maskScript(app.mask) });
93
+ // Starts the page clock at app.clock and lets it run: frozen time would stall Date-based debounce.
94
+ if (app.clock) await context.clock.install({ time: app.clock });
95
+ // Playwright tries the most recently added route first; reversed, the first matching mock wins.
96
+ for (const mock of [...app.mock].reverse()) await context.route(mock.url, (route) => answer(route, mock));
82
97
  const page = await context.newPage();
83
98
  page.setDefaultTimeout(STEP_TIMEOUT_MS);
99
+ page.on('dialog', (dialog) => {
100
+ (app.dialogs === 'accept' ? dialog.accept() : dialog.dismiss()).catch(() => undefined);
101
+ });
84
102
  const invalid = await page.evaluate(
85
103
  (selectors) =>
86
104
  selectors.map((s) => {
@@ -106,6 +124,29 @@ export async function openSession(script: Script, opts: { headed?: boolean } = {
106
124
  }
107
125
  }
108
126
 
127
+ /**
128
+ * Fulfil a request from an `app.mock` entry. Cross-origin answers echo the page's origin and allow
129
+ * credentials (a wildcard is refused for cookie-authenticated calls); Playwright answers preflights.
130
+ */
131
+ async function answer(route: Route, mock: Mock): Promise<void> {
132
+ const request = route.request();
133
+ if (mock.method && request.method().toUpperCase() !== mock.method.toUpperCase()) return route.fallback();
134
+ const origin = request.headers().origin;
135
+ const cors: Record<string, string> = origin
136
+ ? { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', vary: 'Origin' }
137
+ : {};
138
+ if (mock.delayMs) await sleep(mock.delayMs);
139
+ const json = mock.json !== undefined;
140
+ // The page may have navigated away while we waited: a closed route is not an error.
141
+ await route
142
+ .fulfill({
143
+ status: mock.status,
144
+ headers: { ...cors, ...(json ? { 'content-type': 'application/json' } : {}), ...mock.headers },
145
+ body: json ? JSON.stringify(mock.json) : (mock.body ?? ''),
146
+ })
147
+ .catch(() => undefined);
148
+ }
149
+
109
150
  /** Open app.url, run setup steps (fast, unlogged), park the mouse at the centre. */
110
151
  export async function prepare(session: Session, script: Script): Promise<Pt> {
111
152
  const { page } = session;
@@ -115,7 +156,7 @@ export async function prepare(session: Session, script: Script): Promise<Pt> {
115
156
  throw new Error(`Cannot open app.url ${script.app.url}: ${(err as Error).message.split('\n')[0]}`);
116
157
  }
117
158
  const centre = { x: Math.round(script.app.viewport.width / 2), y: Math.round(script.app.viewport.height / 2) };
118
- const ctx: StepContext = { page, mode: 'check', log: new EventLog(), net: session.net, cursor: centre };
159
+ const ctx: StepContext = { page, mode: 'check', log: new EventLog(), net: session.net, cursor: centre, baseUrl: script.app.url };
119
160
  for (const [index, step] of script.setup.entries()) await runStep(ctx, step, { sceneId: 'setup', index });
120
161
  await page.mouse.move(centre.x, centre.y);
121
162
  return centre;
@@ -4,11 +4,14 @@ import { workPaths } from '../paths';
4
4
  import type { LoadedScript } from '../script/load';
5
5
  import { openSession, prepare } from './browser';
6
6
  import { EventLog } from './events';
7
+ import { withHooks } from './hooks';
7
8
  import { describeStep, runStep, StepError, type StepContext } from './steps';
8
9
 
9
10
  export interface RunOptions {
10
11
  headed?: boolean;
11
12
  log?: (line: string) => void;
13
+ /** Non-fatal problems, such as a failing after hook (default: stderr). */
14
+ warn?: (line: string) => void;
12
15
  }
13
16
 
14
17
  export interface CheckStep {
@@ -42,11 +45,17 @@ export async function check(loaded: LoadedScript, opts: RunOptions = {}): Promis
42
45
  await rm(paths.check, { recursive: true, force: true });
43
46
  await mkdir(paths.check, { recursive: true });
44
47
 
48
+ return withHooks(script.hooks, loaded.dir, () => rehearse(loaded, opts), opts.warn);
49
+ }
50
+
51
+ async function rehearse(loaded: LoadedScript, opts: RunOptions): Promise<CheckReport> {
52
+ const { script } = loaded;
53
+ const paths = workPaths(loaded);
45
54
  const report: CheckReport = { ok: true, steps: [], failure: null };
46
55
  const session = await openSession(script, opts);
47
56
  try {
48
57
  const cursor = await prepare(session, script);
49
- const ctx: StepContext = { page: session.page, mode: 'check', log: new EventLog(), net: session.net, cursor };
58
+ const ctx: StepContext = { page: session.page, mode: 'check', log: new EventLog(), net: session.net, cursor, baseUrl: script.app.url };
50
59
  let n = 0;
51
60
  for (const scene of script.scenes) {
52
61
  for (const [index, step] of scene.steps.entries()) {