@mineo71/showtime 0.2.0 → 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/CHANGELOG.md +23 -0
- package/README.md +82 -10
- package/docs/script.schema.json +217 -0
- package/package.json +5 -2
- package/skill/SKILL.md +34 -10
- package/src/cli/format.ts +10 -0
- package/src/cli/main.ts +85 -32
- package/src/lint.ts +101 -0
- package/src/logo.ts +20 -0
- package/src/paths.ts +6 -0
- package/src/pipeline.ts +12 -3
- package/src/render/remotion/Intro.tsx +2 -0
- package/src/render/remotion/Logo.tsx +6 -0
- package/src/render/remotion/Outro.tsx +38 -0
- package/src/render/remotion/Overlays.tsx +4 -3
- package/src/render/remotion/ShowtimeVideo.tsx +10 -3
- package/src/runner/browser.ts +47 -6
- package/src/runner/check.ts +10 -1
- package/src/runner/hooks.ts +111 -0
- package/src/runner/record.ts +52 -34
- package/src/runner/steps.ts +3 -1
- package/src/script/config.ts +58 -0
- package/src/script/hash.ts +7 -1
- package/src/script/load.ts +12 -2
- package/src/script/schema.ts +62 -1
- package/src/setup.ts +13 -4
- package/src/sheet.ts +47 -0
- package/src/timeline/build.ts +46 -6
- package/src/timeline/frames.ts +12 -2
- package/src/timeline/layout.ts +21 -0
- package/src/timeline/types.ts +6 -0
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 {
|
|
6
|
+
import type { TakeEvents } from '../timeline/types';
|
|
7
|
+
import { formatCheck, formatLint } from './format';
|
|
7
8
|
|
|
8
|
-
const say = (line: string) =>
|
|
9
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
say(`\n✓ ${
|
|
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
|
|
82
|
-
.
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
151
|
+
if (o.json) emit({ check: report });
|
|
152
|
+
else say(formatCheck(report));
|
|
100
153
|
process.exitCode = 1;
|
|
101
154
|
return;
|
|
102
155
|
}
|
|
103
|
-
|
|
156
|
+
log?.(`✓ check passed (${report.steps.length} steps)`);
|
|
104
157
|
const { record } = await import('../runner/record');
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
say(`\n✓ ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
};
|
package/src/runner/browser.ts
CHANGED
|
@@ -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',
|
|
33
|
-
|
|
34
|
-
|
|
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;
|
package/src/runner/check.ts
CHANGED
|
@@ -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()) {
|