@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/CHANGELOG.md +17 -0
- package/README.md +82 -10
- package/docs/script.schema.json +217 -0
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { Hook } from '../script/schema';
|
|
3
|
+
|
|
4
|
+
export const RUN_TIMEOUT_MS = 120_000;
|
|
5
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
6
|
+
const TAIL_LINES = 20;
|
|
7
|
+
const KEEP_CHARS = 8000;
|
|
8
|
+
|
|
9
|
+
export interface HookOptions {
|
|
10
|
+
/** Folder shell hooks run in (the script's folder). */
|
|
11
|
+
cwd: string;
|
|
12
|
+
/** Prefix for errors, e.g. "hooks.before". */
|
|
13
|
+
label: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const describeHook = (h: Hook) => ('run' in h ? h.run : `${h.request.method} ${h.request.url}`);
|
|
18
|
+
|
|
19
|
+
/** The last lines of a command's output, on their own lines after the message. */
|
|
20
|
+
const tail = (out: string) => {
|
|
21
|
+
const lines = out.trim().split('\n').slice(-TAIL_LINES).join('\n');
|
|
22
|
+
return lines ? `\n${lines}` : '';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runCommand(command: string, cwd: string, timeoutMs: number): Promise<void> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
// Own process group, so a timeout kills whatever the shell started too.
|
|
28
|
+
const child = spawn(command, { cwd, shell: true, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
29
|
+
let output = '';
|
|
30
|
+
let settled = false;
|
|
31
|
+
const keep = (chunk: Buffer) => {
|
|
32
|
+
output = (output + chunk.toString()).slice(-KEEP_CHARS);
|
|
33
|
+
};
|
|
34
|
+
child.stdout.on('data', keep);
|
|
35
|
+
child.stderr.on('data', keep);
|
|
36
|
+
const finish = (err?: Error) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
// A background process the hook started (a dev server, say) may hold the pipes open: let go.
|
|
41
|
+
child.stdout.destroy();
|
|
42
|
+
child.stderr.destroy();
|
|
43
|
+
child.unref();
|
|
44
|
+
if (err) reject(err);
|
|
45
|
+
else resolve();
|
|
46
|
+
};
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-child.pid!, 'SIGKILL');
|
|
50
|
+
} catch {
|
|
51
|
+
child.kill('SIGKILL');
|
|
52
|
+
}
|
|
53
|
+
finish(new Error(`timed out after ${timeoutMs / 1000} s${tail(output)}`));
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
child.on('error', (err) => finish(err));
|
|
56
|
+
// Done when the shell exits, not when every process holding its output does.
|
|
57
|
+
child.on('exit', (code) => {
|
|
58
|
+
if (code === 0) return finish();
|
|
59
|
+
const fail = () => finish(new Error(`exit code ${code}${tail(output)}`));
|
|
60
|
+
child.on('close', fail); // the last output usually arrives right after exit
|
|
61
|
+
setTimeout(fail, 500);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runRequest(r: Extract<Hook, { request: unknown }>['request'], timeoutMs: number): Promise<void> {
|
|
67
|
+
const hasJson = r.json !== undefined;
|
|
68
|
+
const res = await fetch(r.url, {
|
|
69
|
+
method: r.method,
|
|
70
|
+
headers: { ...(hasJson ? { 'content-type': 'application/json' } : {}), ...r.headers },
|
|
71
|
+
body: hasJson ? JSON.stringify(r.json) : undefined,
|
|
72
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}${tail(await res.text().catch(() => ''))}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run hooks in order; the first failure stops the run and names the hook. */
|
|
78
|
+
export async function runHooks(hooks: Hook[], opts: HookOptions): Promise<void> {
|
|
79
|
+
for (const [i, hook] of hooks.entries()) {
|
|
80
|
+
try {
|
|
81
|
+
if ('run' in hook) await runCommand(hook.run, opts.cwd, opts.timeoutMs ?? RUN_TIMEOUT_MS);
|
|
82
|
+
else await runRequest(hook.request, opts.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(`${opts.label}[${i}] (${describeHook(hook)}) failed: ${(err as Error).message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toStderr = (line: string) => {
|
|
90
|
+
process.stderr.write(`${line}\n`);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `before` hooks, then the work, then `after` hooks, which run even when the work fails. A failing
|
|
95
|
+
* `before` hook aborts; a failing `after` hook is only a warning, so it never undoes the work's
|
|
96
|
+
* result (a saved take, a check report).
|
|
97
|
+
*/
|
|
98
|
+
export async function withHooks<T>(
|
|
99
|
+
hooks: { before: Hook[]; after: Hook[] },
|
|
100
|
+
cwd: string,
|
|
101
|
+
work: () => Promise<T>,
|
|
102
|
+
warn: (line: string) => void = toStderr,
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
await runHooks(hooks.before, { cwd, label: 'hooks.before' });
|
|
105
|
+
const after = () => runHooks(hooks.after, { cwd, label: 'hooks.after' }).catch((err: Error) => warn(`⚠ ${err.message}`));
|
|
106
|
+
try {
|
|
107
|
+
return await work();
|
|
108
|
+
} finally {
|
|
109
|
+
await after();
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/runner/record.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { assertFfmpeg, probeVideo } from '../ffmpeg';
|
|
4
4
|
import { workPaths } from '../paths';
|
|
@@ -10,6 +10,7 @@ import { startCapture, type Frame } from './capture';
|
|
|
10
10
|
import type { RunOptions } from './check';
|
|
11
11
|
import { encodeFrames } from './encode';
|
|
12
12
|
import { EventLog } from './events';
|
|
13
|
+
import { withHooks } from './hooks';
|
|
13
14
|
import { describeStep, runStep, StepError, type StepContext } from './steps';
|
|
14
15
|
import { sleep } from './time';
|
|
15
16
|
|
|
@@ -36,7 +37,7 @@ async function shoot(session: Session, loaded: LoadedScript, framesDir: string,
|
|
|
36
37
|
height: viewport.height * deviceScaleFactor,
|
|
37
38
|
});
|
|
38
39
|
const log = new EventLog(capture.t0);
|
|
39
|
-
const ctx: StepContext = { page: session.page, mode: 'record', log, net: session.net, cursor };
|
|
40
|
+
const ctx: StepContext = { page: session.page, mode: 'record', log, net: session.net, cursor, baseUrl: script.app.url };
|
|
40
41
|
try {
|
|
41
42
|
await sleep(ESTABLISH_MS);
|
|
42
43
|
for (const scene of script.scenes) {
|
|
@@ -59,38 +60,55 @@ export async function record(loaded: LoadedScript, opts: RecordOptions = {}): Pr
|
|
|
59
60
|
const { script } = loaded;
|
|
60
61
|
await assertFfmpeg();
|
|
61
62
|
const paths = workPaths(loaded);
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// Record into take.tmp; the previous take is replaced only once this one is complete.
|
|
64
|
+
const tmp = paths.takeTmp;
|
|
65
|
+
const frames = path.join(tmp, 'frames');
|
|
66
|
+
await rm(tmp, { recursive: true, force: true });
|
|
67
|
+
await mkdir(frames, { recursive: true });
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
// After hooks run once the take is saved, so a failing one cannot cost the take.
|
|
70
|
+
return withHooks(
|
|
71
|
+
script.hooks,
|
|
72
|
+
loaded.dir,
|
|
73
|
+
async () => {
|
|
74
|
+
const session = await openSession(script, opts);
|
|
75
|
+
let shot: Shot;
|
|
76
|
+
try {
|
|
77
|
+
shot = await shoot(session, loaded, frames, opts);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof StepError) {
|
|
80
|
+
const failure = path.join(tmp, 'failure.png');
|
|
81
|
+
await session.page.screenshot({ path: failure, scale: 'css' }).catch(() => undefined);
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${err.message}\nRecording aborted; the previous take is untouched (failure screenshot: ${failure}). Run \`showtime check ${loaded.scriptPath}\` to debug.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
} finally {
|
|
88
|
+
await session.close();
|
|
89
|
+
}
|
|
78
90
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
opts.log?.('Encoding raw.mp4…');
|
|
92
|
+
const raw = path.join(tmp, 'raw.mp4');
|
|
93
|
+
await encodeFrames(shot.frames, raw, { fps: TAKE_FPS, durationMs: shot.durationMs });
|
|
94
|
+
const probe = await probeVideo(raw);
|
|
95
|
+
const events: TakeEvents = {
|
|
96
|
+
version: 1,
|
|
97
|
+
actionHash: actionHash(script),
|
|
98
|
+
viewport: script.app.viewport,
|
|
99
|
+
deviceScaleFactor: script.app.deviceScaleFactor,
|
|
100
|
+
video: { file: 'raw.mp4', width: probe.width, height: probe.height, fps: TAKE_FPS, durationMs: shot.durationMs },
|
|
101
|
+
steps: shot.log.steps,
|
|
102
|
+
moves: shot.log.moves,
|
|
103
|
+
clicks: shot.log.clicks,
|
|
104
|
+
idle: shot.log.idle,
|
|
105
|
+
};
|
|
106
|
+
await writeFile(path.join(tmp, 'events.json'), JSON.stringify(events, null, 2));
|
|
107
|
+
if (!opts.keepFrames) await rm(frames, { recursive: true, force: true });
|
|
108
|
+
await rm(paths.take, { recursive: true, force: true });
|
|
109
|
+
await rename(tmp, paths.take);
|
|
110
|
+
return events;
|
|
111
|
+
},
|
|
112
|
+
opts.warn,
|
|
113
|
+
);
|
|
96
114
|
}
|
package/src/runner/steps.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StepContext {
|
|
|
14
14
|
log: EventLog;
|
|
15
15
|
net: NetworkTracker;
|
|
16
16
|
cursor: Pt;
|
|
17
|
+
/** Relative `goto` URLs resolve against this (app.url); the current page when absent. */
|
|
18
|
+
baseUrl?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface StepWhere {
|
|
@@ -69,7 +71,7 @@ async function perform(ctx: StepContext, step: Step, where: StepWhere): Promise<
|
|
|
69
71
|
|
|
70
72
|
switch (step.do) {
|
|
71
73
|
case 'goto':
|
|
72
|
-
await page.goto(step.url, { waitUntil: 'load', timeout: WAIT_TIMEOUT_MS });
|
|
74
|
+
await page.goto(new URL(step.url, ctx.baseUrl ?? page.url()).href, { waitUntil: 'load', timeout: WAIT_TIMEOUT_MS });
|
|
73
75
|
break;
|
|
74
76
|
case 'click':
|
|
75
77
|
case 'hover':
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { formatIssues, MetaSchema, ScriptError, StyleSchema } from './schema';
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILE = 'showtime.config.json';
|
|
8
|
+
|
|
9
|
+
type Json = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface BrandConfig {
|
|
12
|
+
style?: Json;
|
|
13
|
+
meta?: Json;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Validated on its own so mistakes are blamed on this file; the raw values are what get merged.
|
|
17
|
+
const ConfigSchema = z
|
|
18
|
+
.object({ $schema: z.string().optional(), style: StyleSchema.partial().optional(), meta: MetaSchema.partial().optional() })
|
|
19
|
+
.strict();
|
|
20
|
+
|
|
21
|
+
/** The nearest showtime.config.json from `dir` upward, or null. */
|
|
22
|
+
export function findConfig(dir: string): string | null {
|
|
23
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
24
|
+
const file = path.join(d, CONFIG_FILE);
|
|
25
|
+
if (existsSync(file)) return file;
|
|
26
|
+
if (path.dirname(d) === d) return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read a brand file; paths inside it (meta.logo) resolve against its folder. */
|
|
31
|
+
export async function readConfig(file: string): Promise<BrandConfig> {
|
|
32
|
+
let raw: unknown;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(await readFile(file, 'utf8'));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new ScriptError(`Invalid JSON in ${file}: ${(err as Error).message}`);
|
|
37
|
+
}
|
|
38
|
+
const parsed = ConfigSchema.safeParse(raw);
|
|
39
|
+
if (!parsed.success) throw new ScriptError(`Invalid ${file}:\n${formatIssues(parsed.error.issues)}`);
|
|
40
|
+
const { style, meta } = raw as BrandConfig;
|
|
41
|
+
return { style, meta: withLogoFrom(meta, path.dirname(file)) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** meta with `logo` made absolute against `dir` (when it is a string). */
|
|
45
|
+
export function withLogoFrom(meta: Json | undefined, dir: string): Json | undefined {
|
|
46
|
+
if (!meta || typeof meta.logo !== 'string') return meta;
|
|
47
|
+
return { ...meta, logo: path.resolve(dir, meta.logo) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
51
|
+
|
|
52
|
+
/** Brand defaults under the raw script: the script wins key by key in `style` and `meta`. */
|
|
53
|
+
export function applyConfig(config: BrandConfig, raw: Json): Json {
|
|
54
|
+
const merged: Json = { ...raw };
|
|
55
|
+
if (config.style) merged.style = isObject(raw.style) ? { ...config.style, ...raw.style } : (raw.style ?? config.style);
|
|
56
|
+
if (config.meta) merged.meta = isObject(raw.meta) ? { ...config.meta, ...raw.meta } : (raw.meta ?? config.meta);
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
package/src/script/hash.ts
CHANGED
|
@@ -19,9 +19,15 @@ function canonical(value: unknown): string {
|
|
|
19
19
|
return JSON.stringify(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** `app` without fields that sit at their defaults, so adding a field never invalidates older takes. */
|
|
23
|
+
function appOf(app: Script['app']): Record<string, unknown> {
|
|
24
|
+
const { mock, dialogs, ...rest } = app;
|
|
25
|
+
return { ...rest, ...(mock.length ? { mock } : {}), ...(dialogs !== 'accept' ? { dialogs } : {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function actionHash(script: Script): string {
|
|
23
29
|
const payload = {
|
|
24
|
-
app: script.app,
|
|
30
|
+
app: appOf(script.app),
|
|
25
31
|
setup: script.setup.map(actionsOf),
|
|
26
32
|
scenes: script.scenes.map((s) => ({ id: s.id, steps: s.steps.map(actionsOf) })),
|
|
27
33
|
};
|
package/src/script/load.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { applyConfig, findConfig, readConfig, withLogoFrom } from './config';
|
|
4
5
|
import { parseScript, ScriptError, type Script } from './schema';
|
|
5
6
|
|
|
6
7
|
export interface LoadedScript {
|
|
@@ -8,6 +9,8 @@ export interface LoadedScript {
|
|
|
8
9
|
scriptPath: string;
|
|
9
10
|
dir: string;
|
|
10
11
|
name: string;
|
|
12
|
+
/** The showtime.config.json whose defaults were applied, if any. */
|
|
13
|
+
config: string | null;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export async function loadScript(file: string): Promise<LoadedScript> {
|
|
@@ -24,13 +27,20 @@ export async function loadScript(file: string): Promise<LoadedScript> {
|
|
|
24
27
|
} catch (err) {
|
|
25
28
|
throw new ScriptError(`Invalid JSON in ${scriptPath}: ${(err as Error).message}`);
|
|
26
29
|
}
|
|
27
|
-
const script = parseScript(raw, scriptPath);
|
|
28
30
|
const dir = path.dirname(scriptPath);
|
|
31
|
+
const config = findConfig(dir);
|
|
32
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
33
|
+
const own = raw as Record<string, unknown>;
|
|
34
|
+
if (own.meta && typeof own.meta === 'object') own.meta = withLogoFrom(own.meta as Record<string, unknown>, dir);
|
|
35
|
+
if (config) raw = applyConfig(await readConfig(config), own);
|
|
36
|
+
}
|
|
37
|
+
const script = parseScript(raw, scriptPath);
|
|
38
|
+
if (script.meta.logo && !existsSync(script.meta.logo)) throw new ScriptError(`meta.logo not found: ${script.meta.logo}`);
|
|
29
39
|
if (script.app.storageState) {
|
|
30
40
|
script.app.storageState = path.resolve(dir, script.app.storageState);
|
|
31
41
|
if (!existsSync(script.app.storageState)) {
|
|
32
42
|
throw new ScriptError(`app.storageState not found: ${script.app.storageState}`);
|
|
33
43
|
}
|
|
34
44
|
}
|
|
35
|
-
return { script, scriptPath, dir, name: path.basename(scriptPath).replace(/\.json$/i, '') };
|
|
45
|
+
return { script, scriptPath, dir, name: path.basename(scriptPath).replace(/\.json$/i, ''), config };
|
|
36
46
|
}
|
package/src/script/schema.ts
CHANGED
|
@@ -69,6 +69,21 @@ export const StyleSchema = z
|
|
|
69
69
|
})
|
|
70
70
|
.strict();
|
|
71
71
|
|
|
72
|
+
const headers = z.record(z.string(), z.string());
|
|
73
|
+
|
|
74
|
+
export const MockSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
url: z.string().min(1).describe('Playwright URL glob of the requests to answer, e.g. **/api/reports*'),
|
|
77
|
+
method: z.string().min(1).optional().describe('Only answer this HTTP method; others go to the network'),
|
|
78
|
+
status: z.number().int().min(100).max(599).default(200),
|
|
79
|
+
json: z.unknown().optional().describe('Response body, sent as application/json'),
|
|
80
|
+
body: z.string().optional().describe('Raw response body'),
|
|
81
|
+
headers: headers.optional(),
|
|
82
|
+
delayMs: z.number().int().min(0).max(30_000).optional().describe('Wait this long before answering'),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
.refine((m) => m.json === undefined || m.body === undefined, { message: 'a mock takes "json" or "body", not both' });
|
|
86
|
+
|
|
72
87
|
export const AppSchema = z
|
|
73
88
|
.object({
|
|
74
89
|
url: z.string().min(1),
|
|
@@ -85,6 +100,49 @@ export const AppSchema = z
|
|
|
85
100
|
.array(z.string().min(1))
|
|
86
101
|
.default([])
|
|
87
102
|
.describe('CSS selectors (not Playwright selectors) of elements to blur in the recording'),
|
|
103
|
+
mock: z.array(MockSchema).default([]).describe('Answer matching network requests with canned responses; the first match wins'),
|
|
104
|
+
clock: z
|
|
105
|
+
.string()
|
|
106
|
+
.refine((v) => !Number.isNaN(Date.parse(v)), { message: 'must be an ISO date-time, e.g. 2026-10-13T09:30:00Z' })
|
|
107
|
+
.optional()
|
|
108
|
+
.describe('Start the page clock at this ISO date-time; it then runs normally'),
|
|
109
|
+
dialogs: z.enum(['accept', 'dismiss']).default('accept').describe('How to answer alert/confirm/prompt dialogs'),
|
|
110
|
+
})
|
|
111
|
+
.strict();
|
|
112
|
+
|
|
113
|
+
export const HookSchema = z.union([
|
|
114
|
+
z.object({ run: z.string().min(1).describe('Shell command, run in the script folder') }).strict(),
|
|
115
|
+
z
|
|
116
|
+
.object({
|
|
117
|
+
request: z
|
|
118
|
+
.object({
|
|
119
|
+
url: z.string().min(1),
|
|
120
|
+
method: z.string().min(1).default('POST'),
|
|
121
|
+
json: z.unknown().optional(),
|
|
122
|
+
headers: headers.optional(),
|
|
123
|
+
})
|
|
124
|
+
.strict(),
|
|
125
|
+
})
|
|
126
|
+
.strict(),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
export const HooksSchema = z
|
|
130
|
+
.object({
|
|
131
|
+
before: z.array(HookSchema).default([]).describe('Run before every check and record, e.g. to reset demo data'),
|
|
132
|
+
after: z.array(HookSchema).default([]).describe('Run after every check and record, even when it failed'),
|
|
133
|
+
})
|
|
134
|
+
.strict();
|
|
135
|
+
|
|
136
|
+
export const MetaSchema = z
|
|
137
|
+
.object({
|
|
138
|
+
title: z.string().optional(),
|
|
139
|
+
subtitle: z.string().optional(),
|
|
140
|
+
logo: z.string().min(1).optional().describe('SVG/PNG/JPEG/WebP shown on the intro and outro cards'),
|
|
141
|
+
outro: z
|
|
142
|
+
.object({ title: z.string().min(1), subtitle: z.string().optional(), url: z.string().optional() })
|
|
143
|
+
.strict()
|
|
144
|
+
.optional()
|
|
145
|
+
.describe('Closing card after the video'),
|
|
88
146
|
})
|
|
89
147
|
.strict();
|
|
90
148
|
|
|
@@ -99,8 +157,9 @@ export const ScriptSchema = z
|
|
|
99
157
|
.object({
|
|
100
158
|
$schema: z.string().optional(),
|
|
101
159
|
version: z.literal(1),
|
|
102
|
-
meta:
|
|
160
|
+
meta: MetaSchema.default({}),
|
|
103
161
|
app: AppSchema,
|
|
162
|
+
hooks: HooksSchema.prefault({}),
|
|
104
163
|
setup: z.array(StepSchema).default([]),
|
|
105
164
|
style: StyleSchema.prefault({}),
|
|
106
165
|
scenes: z.array(SceneSchema).min(1),
|
|
@@ -121,6 +180,8 @@ export type ScriptInput = z.input<typeof ScriptSchema>;
|
|
|
121
180
|
export type Step = z.output<typeof StepSchema>;
|
|
122
181
|
export type Show = z.output<typeof ShowSchema>;
|
|
123
182
|
export type Style = z.output<typeof StyleSchema>;
|
|
183
|
+
export type Hook = z.output<typeof HookSchema>;
|
|
184
|
+
export type Mock = z.output<typeof MockSchema>;
|
|
124
185
|
|
|
125
186
|
export class ScriptError extends Error {
|
|
126
187
|
constructor(message: string) {
|
package/src/setup.ts
CHANGED
|
@@ -26,16 +26,25 @@ export function linkSkill(agents: Agent[], home = homedir(), source = SKILL_DIR)
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Download the Chromium build this copy of Playwright drives, and Remotion's headless shell.
|
|
31
|
+
* `quiet` sends their progress output to stderr, keeping stdout clean for --json.
|
|
32
|
+
*/
|
|
33
|
+
export async function installBrowsers(opts: { quiet?: boolean } = {}): Promise<void> {
|
|
31
34
|
// Our own Playwright CLI: a bare `npx playwright` may resolve another version with another Chromium.
|
|
32
35
|
const require = createRequire(import.meta.url);
|
|
33
36
|
const cli = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
34
37
|
await new Promise<void>((resolve, reject) => {
|
|
35
|
-
const child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' });
|
|
38
|
+
const child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: ['ignore', opts.quiet ? 2 : 'inherit', 'inherit'] });
|
|
36
39
|
child.on('error', reject);
|
|
37
40
|
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`playwright install chromium exited with code ${code}`))));
|
|
38
41
|
});
|
|
39
42
|
const { ensureBrowser } = await import('@remotion/renderer');
|
|
40
|
-
|
|
43
|
+
const log = console.log;
|
|
44
|
+
if (opts.quiet) console.log = console.error;
|
|
45
|
+
try {
|
|
46
|
+
await ensureBrowser();
|
|
47
|
+
} finally {
|
|
48
|
+
console.log = log;
|
|
49
|
+
}
|
|
41
50
|
}
|
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
|
|
6
|
+
export const SHEET_COLUMNS = 4;
|
|
7
|
+
export const SHEET_WIDTH = 1920;
|
|
8
|
+
|
|
9
|
+
export interface SheetItem {
|
|
10
|
+
file: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
15
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ENTITIES[c]);
|
|
16
|
+
|
|
17
|
+
/** A page laying the stills out in a labelled grid; images load by file name from the page's own folder. */
|
|
18
|
+
export function sheetHtml(items: SheetItem[]): string {
|
|
19
|
+
const figures = items
|
|
20
|
+
.map((i) => `<figure><img src="${encodeURIComponent(path.basename(i.file))}"><figcaption>${esc(i.label)}</figcaption></figure>`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
return `<!doctype html>
|
|
23
|
+
<html><head><meta charset="utf-8"><style>
|
|
24
|
+
body { margin: 0; background: #0b0f17; color: #e2e8f0; font: 600 18px/1.3 -apple-system, "Segoe UI", system-ui, sans-serif; }
|
|
25
|
+
.grid { display: grid; grid-template-columns: repeat(${SHEET_COLUMNS}, 1fr); gap: 16px; padding: 24px; width: ${SHEET_WIDTH}px; box-sizing: border-box; }
|
|
26
|
+
figure { margin: 0; }
|
|
27
|
+
img { display: block; width: 100%; border-radius: 6px; }
|
|
28
|
+
figcaption { margin-top: 8px; }
|
|
29
|
+
</style></head><body><div class="grid">
|
|
30
|
+
${figures}
|
|
31
|
+
</div></body></html>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render the stills (all in one folder) into one labelled PNG at `out`, next to them. */
|
|
35
|
+
export async function contactSheet(items: SheetItem[], out: string): Promise<string> {
|
|
36
|
+
const html = path.join(path.dirname(out), 'sheet.html');
|
|
37
|
+
await writeFile(html, sheetHtml(items));
|
|
38
|
+
const browser = await chromium.launch();
|
|
39
|
+
try {
|
|
40
|
+
const page = await browser.newPage({ viewport: { width: SHEET_WIDTH, height: 600 } });
|
|
41
|
+
await page.goto(pathToFileURL(html).href, { waitUntil: 'load' });
|
|
42
|
+
await page.screenshot({ path: out, fullPage: true });
|
|
43
|
+
} finally {
|
|
44
|
+
await browser.close();
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/src/timeline/build.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import type { Script, Step } from '../script/schema';
|
|
2
|
-
import { buildCamera, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
|
|
2
|
+
import { buildCamera, cameraAt, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
|
|
3
|
+
import { BROWSER_BAR, projectRect, windowLayout, type Box } from './layout';
|
|
3
4
|
import { clamp } from './math';
|
|
4
5
|
import { buildSegments, type SpeedSpan } from './timemap';
|
|
5
|
-
import type { Caption, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
|
|
6
|
+
import type { CameraKey, Caption, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
|
|
6
7
|
|
|
7
8
|
export const AUTO_SPEED_MIN_IDLE_MS = 800;
|
|
8
9
|
export const AUTO_SPEED_TARGET_MS = 600;
|
|
9
10
|
export const INTRO_MS = 2200;
|
|
10
11
|
export const CAPTION_MIN_MS = 1500;
|
|
11
12
|
export const SPOTLIGHT_PAD = 8;
|
|
13
|
+
export const OUTRO_MS = 2600;
|
|
14
|
+
/** Bottom captions own the output below this fraction of its height. */
|
|
15
|
+
export const CAPTION_BAND = 0.78;
|
|
16
|
+
const DEFAULT_OUTPUT = { width: 1920, height: 1080 };
|
|
12
17
|
|
|
13
18
|
const ZOOMABLE = new Set<Step['do']>(['click', 'type', 'select', 'hover']);
|
|
14
19
|
|
|
15
20
|
export interface BuildOptions {
|
|
16
21
|
fps?: number;
|
|
17
22
|
output?: { width: number; height: number };
|
|
23
|
+
/** Logo as a data URI. */
|
|
24
|
+
logo?: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function stepOf(script: Script, e: StepEvent): Step {
|
|
@@ -33,11 +40,30 @@ function displayAddress(url: string): string {
|
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
/** Top when the target, at any of the caption's start/middle/end, reaches into the bottom caption band. */
|
|
44
|
+
function captionPosition(
|
|
45
|
+
target: Rect | undefined,
|
|
46
|
+
c: { startMs: number; endMs: number },
|
|
47
|
+
camera: CameraKey[],
|
|
48
|
+
stage: { width: number; height: number },
|
|
49
|
+
win: Box,
|
|
50
|
+
output: { width: number; height: number },
|
|
51
|
+
): Caption['position'] {
|
|
52
|
+
if (!target) return 'bottom';
|
|
53
|
+
const band = output.height * CAPTION_BAND;
|
|
54
|
+
const low = [c.startMs, (c.startMs + c.endMs) / 2, c.endMs].some((tMs) => {
|
|
55
|
+
const r = projectRect(target, cameraAt(camera, tMs), stage, win);
|
|
56
|
+
return r.y + r.height > band;
|
|
57
|
+
});
|
|
58
|
+
return low ? 'top' : 'bottom';
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
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
62
|
|
|
38
63
|
export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions = {}): Timeline {
|
|
39
64
|
const { style } = script;
|
|
40
65
|
const stage = ev.viewport;
|
|
66
|
+
const output = opts.output ?? DEFAULT_OUTPUT;
|
|
41
67
|
const pairs = ev.steps.map((e) => ({ e, step: stepOf(script, e) }));
|
|
42
68
|
|
|
43
69
|
// Speed: auto idle first, explicit `show.speed` next, pauses last (later spans win).
|
|
@@ -56,7 +82,7 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
const regions: ZoomRegion[] = [];
|
|
59
|
-
const
|
|
85
|
+
const drafts: { startMs: number; endMs: number; text: string; target?: Rect }[] = [];
|
|
60
86
|
const spotlights: Spotlight[] = [];
|
|
61
87
|
for (const { e, step } of pairs) {
|
|
62
88
|
const zoom = step.show?.zoom;
|
|
@@ -74,17 +100,29 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
74
100
|
}
|
|
75
101
|
}
|
|
76
102
|
if (step.show?.caption) {
|
|
77
|
-
|
|
103
|
+
drafts.push({ startMs: e.startMs, endMs: Math.max(e.settledMs, e.startMs + CAPTION_MIN_MS), text: step.show.caption, target: e.target });
|
|
78
104
|
}
|
|
79
105
|
if (step.show?.spotlight && e.target) {
|
|
80
106
|
spotlights.push({ startMs: e.actionMs, endMs: e.settledMs, rect: pad(e.target, SPOTLIGHT_PAD) });
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
|
|
110
|
+
const camera = buildCamera(regions);
|
|
111
|
+
const win = windowLayout(output, stage, style.padding, style.chrome === 'browser' ? BROWSER_BAR : 0);
|
|
112
|
+
// A caption ends when the next one starts: no overlap, so captions dip instead of colliding.
|
|
113
|
+
drafts.sort((a, b) => a.startMs - b.startMs);
|
|
114
|
+
drafts.forEach((d, i) => {
|
|
115
|
+
const next = drafts[i + 1];
|
|
116
|
+
if (next) d.endMs = Math.min(d.endMs, next.startMs);
|
|
117
|
+
});
|
|
118
|
+
const captions: Caption[] = drafts
|
|
119
|
+
.filter((d) => d.endMs > d.startMs)
|
|
120
|
+
.map((d) => ({ startMs: d.startMs, endMs: d.endMs, text: d.text, position: captionPosition(d.target, d, camera, stage, win, output) }));
|
|
121
|
+
|
|
84
122
|
return {
|
|
85
123
|
version: 1,
|
|
86
124
|
fps: opts.fps ?? 30,
|
|
87
|
-
output
|
|
125
|
+
output,
|
|
88
126
|
stage,
|
|
89
127
|
video: { src: ev.video.file, durationMs: ev.video.durationMs },
|
|
90
128
|
intro: script.meta.title ? { title: script.meta.title, subtitle: script.meta.subtitle, durationMs: INTRO_MS } : null,
|
|
@@ -92,8 +130,10 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
92
130
|
style.chrome === 'browser'
|
|
93
131
|
? { address: style.address ?? displayAddress(script.app.url), dark: script.app.colorScheme === 'dark' }
|
|
94
132
|
: null,
|
|
133
|
+
outro: script.meta.outro ? { ...script.meta.outro, durationMs: OUTRO_MS } : null,
|
|
134
|
+
logo: opts.logo ?? null,
|
|
95
135
|
segments: buildSegments(ev.video.durationMs, spans),
|
|
96
|
-
camera
|
|
136
|
+
camera,
|
|
97
137
|
moves: ev.moves,
|
|
98
138
|
clicks: ev.clicks,
|
|
99
139
|
captions,
|