@mineo71/showtime 0.0.0-stage → 0.2.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +365 -2
- package/bin/showtime.mjs +5 -0
- package/docs/script.schema.json +1346 -0
- package/package.json +71 -4
- package/skill/SKILL.md +112 -0
- package/src/cli/format.ts +19 -0
- package/src/cli/main.ts +115 -0
- package/src/ffmpeg.ts +72 -0
- package/src/paths.ts +30 -0
- package/src/pipeline.ts +38 -0
- package/src/render/remotion/BrowserBar.tsx +61 -0
- package/src/render/remotion/Cursor.tsx +40 -0
- package/src/render/remotion/Intro.tsx +29 -0
- package/src/render/remotion/Overlays.tsx +95 -0
- package/src/render/remotion/Root.tsx +21 -0
- package/src/render/remotion/ShowtimeVideo.tsx +92 -0
- package/src/render/remotion/index.ts +4 -0
- package/src/render/render.ts +46 -0
- package/src/runner/browser.ts +122 -0
- package/src/runner/capture.ts +53 -0
- package/src/runner/check.ts +73 -0
- package/src/runner/encode.ts +49 -0
- package/src/runner/events.ts +15 -0
- package/src/runner/record.ts +96 -0
- package/src/runner/steps.ts +217 -0
- package/src/runner/time.ts +17 -0
- package/src/script/hash.ts +29 -0
- package/src/script/load.ts +36 -0
- package/src/script/schema.ts +144 -0
- package/src/setup.ts +41 -0
- package/src/take.ts +24 -0
- package/src/timeline/build.ts +103 -0
- package/src/timeline/camera.ts +69 -0
- package/src/timeline/cursor.ts +59 -0
- package/src/timeline/frames.ts +60 -0
- package/src/timeline/layout.ts +29 -0
- package/src/timeline/math.ts +7 -0
- package/src/timeline/timemap.ts +52 -0
- package/src/timeline/types.ts +111 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import { ripplesAt } from '../../timeline/cursor';
|
|
3
|
+
import { clamp, easeOutCubic } from '../../timeline/math';
|
|
4
|
+
import type { Timeline } from '../../timeline/types';
|
|
5
|
+
|
|
6
|
+
type LayerProps = { timeline: Timeline; srcMs: number };
|
|
7
|
+
|
|
8
|
+
/** 0 → 1 → 0 over [start, end] with `ms` ramps. */
|
|
9
|
+
const envelope = (t: number, start: number, end: number, ms: number) => clamp(Math.min((t - start) / ms, (end - t) / ms), 0, 1);
|
|
10
|
+
|
|
11
|
+
export const Spotlights: FC<LayerProps> = ({ timeline, srcMs }) => (
|
|
12
|
+
<>
|
|
13
|
+
{timeline.spotlights
|
|
14
|
+
.filter((s) => srcMs >= s.startMs && srcMs <= s.endMs)
|
|
15
|
+
.map((s) => (
|
|
16
|
+
<div
|
|
17
|
+
key={s.startMs}
|
|
18
|
+
style={{
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
left: s.rect.x,
|
|
21
|
+
top: s.rect.y,
|
|
22
|
+
width: s.rect.width,
|
|
23
|
+
height: s.rect.height,
|
|
24
|
+
borderRadius: 12,
|
|
25
|
+
boxShadow: '0 0 0 9999px rgba(2, 6, 23, 0.55)',
|
|
26
|
+
outline: `3px solid ${timeline.style.accent}`,
|
|
27
|
+
outlineOffset: 2,
|
|
28
|
+
opacity: envelope(srcMs, s.startMs, s.endMs, 250),
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const Ripples: FC<LayerProps> = ({ timeline, srcMs }) => (
|
|
36
|
+
<>
|
|
37
|
+
{ripplesAt(timeline.clicks, srcMs).map((r) => {
|
|
38
|
+
const radius = 6 + 42 * easeOutCubic(r.progress);
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
key={r.tMs}
|
|
42
|
+
style={{
|
|
43
|
+
position: 'absolute',
|
|
44
|
+
left: r.x - radius,
|
|
45
|
+
top: r.y - radius,
|
|
46
|
+
width: radius * 2,
|
|
47
|
+
height: radius * 2,
|
|
48
|
+
borderRadius: '50%',
|
|
49
|
+
border: `3px solid ${timeline.style.accent}`,
|
|
50
|
+
opacity: 1 - r.progress,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const Captions: FC<LayerProps> = ({ timeline, srcMs }) => {
|
|
59
|
+
const active = timeline.captions.filter((c) => srcMs >= c.startMs && srcMs <= c.endMs);
|
|
60
|
+
const c = active[active.length - 1];
|
|
61
|
+
if (!c) return null;
|
|
62
|
+
const o = envelope(srcMs, c.startMs, c.endMs, 220);
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
style={{
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
left: 0,
|
|
68
|
+
right: 0,
|
|
69
|
+
bottom: Math.round(timeline.output.height * 0.075),
|
|
70
|
+
display: 'flex',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<div
|
|
75
|
+
style={{
|
|
76
|
+
maxWidth: '70%',
|
|
77
|
+
padding: '18px 34px',
|
|
78
|
+
borderRadius: 22,
|
|
79
|
+
background: 'rgba(15, 23, 42, 0.9)',
|
|
80
|
+
color: '#fff',
|
|
81
|
+
fontFamily: timeline.style.font,
|
|
82
|
+
fontSize: 36,
|
|
83
|
+
fontWeight: 600,
|
|
84
|
+
lineHeight: 1.25,
|
|
85
|
+
textAlign: 'center',
|
|
86
|
+
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.35)',
|
|
87
|
+
opacity: o,
|
|
88
|
+
transform: `translateY(${(1 - o) * 12}px)`,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{c.text}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Composition } from 'remotion';
|
|
2
|
+
import { totalFrames } from '../../timeline/frames';
|
|
3
|
+
import { ShowtimeVideo, type ShowtimeVideoProps } from './ShowtimeVideo';
|
|
4
|
+
|
|
5
|
+
export const RemotionRoot = () => (
|
|
6
|
+
<Composition
|
|
7
|
+
id="ShowtimeVideo"
|
|
8
|
+
component={ShowtimeVideo}
|
|
9
|
+
width={1920}
|
|
10
|
+
height={1080}
|
|
11
|
+
fps={30}
|
|
12
|
+
durationInFrames={1}
|
|
13
|
+
defaultProps={{} as ShowtimeVideoProps}
|
|
14
|
+
calculateMetadata={({ props }) => ({
|
|
15
|
+
durationInFrames: totalFrames(props.timeline),
|
|
16
|
+
fps: props.timeline.fps,
|
|
17
|
+
width: props.timeline.output.width,
|
|
18
|
+
height: props.timeline.output.height,
|
|
19
|
+
})}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import { AbsoluteFill, Easing, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
|
|
3
|
+
import { cameraAt, cameraTransform } from '../../timeline/camera';
|
|
4
|
+
import { introFrames, segmentPlan, sourceMsAtFrame } from '../../timeline/frames';
|
|
5
|
+
import { BROWSER_BAR, windowLayout } from '../../timeline/layout';
|
|
6
|
+
import type { Timeline } from '../../timeline/types';
|
|
7
|
+
import { BrowserBar } from './BrowserBar';
|
|
8
|
+
import { Cursor } from './Cursor';
|
|
9
|
+
import { Intro } from './Intro';
|
|
10
|
+
import { Captions, Ripples, Spotlights } from './Overlays';
|
|
11
|
+
|
|
12
|
+
export type ShowtimeVideoProps = { timeline: Timeline };
|
|
13
|
+
|
|
14
|
+
const SHADOW = '0 30px 80px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.25)';
|
|
15
|
+
|
|
16
|
+
export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
|
|
17
|
+
const frame = useCurrentFrame();
|
|
18
|
+
const intro = introFrames(t);
|
|
19
|
+
if (frame < intro) return <Intro timeline={t} frame={frame} />;
|
|
20
|
+
|
|
21
|
+
const src = sourceMsAtFrame(t, frame) ?? 0;
|
|
22
|
+
const win = windowLayout(t.output, t.stage, t.style.padding, t.chrome ? BROWSER_BAR : 0);
|
|
23
|
+
const cam = cameraAt(t.camera, src);
|
|
24
|
+
const k = win.width / t.stage.width;
|
|
25
|
+
// After an intro card the window rises into place instead of cutting in.
|
|
26
|
+
const enter = intro
|
|
27
|
+
? interpolate(frame - intro, [0, 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic) })
|
|
28
|
+
: 1;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AbsoluteFill style={{ background: t.style.background, fontFamily: t.style.font }}>
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
left: win.x,
|
|
36
|
+
top: win.y - win.bar,
|
|
37
|
+
width: win.width,
|
|
38
|
+
height: win.height + win.bar,
|
|
39
|
+
borderRadius: t.style.radius,
|
|
40
|
+
overflow: 'hidden',
|
|
41
|
+
background: '#fff',
|
|
42
|
+
boxShadow: t.style.shadow ? SHADOW : undefined,
|
|
43
|
+
opacity: enter,
|
|
44
|
+
transform: `translateY(${(1 - enter) * 40}px) scale(${0.96 + 0.04 * enter})`,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{t.chrome ? <BrowserBar width={win.width} height={win.bar} address={t.chrome.address} dark={t.chrome.dark} font={t.style.font} /> : null}
|
|
48
|
+
<div style={{ position: 'absolute', left: 0, top: win.bar, width: win.width, height: win.height, overflow: 'hidden' }}>
|
|
49
|
+
<div
|
|
50
|
+
style={{
|
|
51
|
+
position: 'absolute',
|
|
52
|
+
left: 0,
|
|
53
|
+
top: 0,
|
|
54
|
+
width: win.width,
|
|
55
|
+
height: win.height,
|
|
56
|
+
transformOrigin: '0 0',
|
|
57
|
+
transform: cameraTransform(cam, win.width, win.height),
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{segmentPlan(t).map((p) => (
|
|
61
|
+
<Sequence key={p.fromFrame} from={p.fromFrame} durationInFrames={p.durationInFrames} layout="none">
|
|
62
|
+
<OffthreadVideo
|
|
63
|
+
src={staticFile(t.video.src)}
|
|
64
|
+
trimBefore={p.trimBefore}
|
|
65
|
+
playbackRate={p.speed}
|
|
66
|
+
muted
|
|
67
|
+
style={{ position: 'absolute', left: 0, top: 0, width: win.width, height: win.height }}
|
|
68
|
+
/>
|
|
69
|
+
</Sequence>
|
|
70
|
+
))}
|
|
71
|
+
<div
|
|
72
|
+
style={{
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
left: 0,
|
|
75
|
+
top: 0,
|
|
76
|
+
width: t.stage.width,
|
|
77
|
+
height: t.stage.height,
|
|
78
|
+
transformOrigin: '0 0',
|
|
79
|
+
transform: `scale(${k})`,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<Spotlights timeline={t} srcMs={src} />
|
|
83
|
+
<Ripples timeline={t} srcMs={src} />
|
|
84
|
+
<Cursor timeline={t} srcMs={src} />
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<Captions timeline={t} srcMs={src} />
|
|
90
|
+
</AbsoluteFill>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { bundle } from '@remotion/bundler';
|
|
4
|
+
import { renderMedia, renderStill, selectComposition } from '@remotion/renderer';
|
|
5
|
+
import type { Shot } from '../timeline/frames';
|
|
6
|
+
import type { Timeline } from '../timeline/types';
|
|
7
|
+
|
|
8
|
+
const ENTRY = fileURLToPath(new URL('./remotion/index.ts', import.meta.url));
|
|
9
|
+
const COMPOSITION_ID = 'ShowtimeVideo';
|
|
10
|
+
|
|
11
|
+
/** Bundle the composition with the take directory as public dir, so the raw video is staticFile('raw.mp4'). */
|
|
12
|
+
async function prepare(timeline: Timeline, publicDir: string) {
|
|
13
|
+
const serveUrl = await bundle({ entryPoint: ENTRY, publicDir, symlinkPublicDir: true });
|
|
14
|
+
const inputProps = { timeline };
|
|
15
|
+
const composition = await selectComposition({ serveUrl, id: COMPOSITION_ID, inputProps, logLevel: 'error' });
|
|
16
|
+
return { serveUrl, inputProps, composition };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function renderVideo(
|
|
20
|
+
timeline: Timeline,
|
|
21
|
+
publicDir: string,
|
|
22
|
+
out: string,
|
|
23
|
+
onProgress?: (progress: number) => void,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const r = await prepare(timeline, publicDir);
|
|
26
|
+
await renderMedia({
|
|
27
|
+
...r,
|
|
28
|
+
codec: 'h264',
|
|
29
|
+
crf: 18,
|
|
30
|
+
outputLocation: out,
|
|
31
|
+
overwrite: true,
|
|
32
|
+
logLevel: 'error',
|
|
33
|
+
onProgress: ({ progress }) => onProgress?.(progress),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function renderStills(timeline: Timeline, publicDir: string, outDir: string, shots: Shot[]): Promise<string[]> {
|
|
38
|
+
const r = await prepare(timeline, publicDir);
|
|
39
|
+
const files: string[] = [];
|
|
40
|
+
for (const shot of shots) {
|
|
41
|
+
const output = path.join(outDir, `${shot.name}.png`);
|
|
42
|
+
await renderStill({ ...r, frame: shot.frame, output, imageFormat: 'png', overwrite: true, logLevel: 'error' });
|
|
43
|
+
files.push(output);
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
2
|
+
import { ScriptError, type Script } from '../script/schema';
|
|
3
|
+
import type { Pt } from '../timeline/types';
|
|
4
|
+
import { EventLog } from './events';
|
|
5
|
+
import { runStep, STEP_TIMEOUT_MS, type StepContext } from './steps';
|
|
6
|
+
import { sleep } from './time';
|
|
7
|
+
|
|
8
|
+
export interface NetworkTracker {
|
|
9
|
+
/** Resolve once no request has been in flight for `quietMs` (counted from now), or after `capMs`. */
|
|
10
|
+
idle(quietMs: number, capMs: number): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Session {
|
|
14
|
+
browser: Browser;
|
|
15
|
+
context: BrowserContext;
|
|
16
|
+
page: Page;
|
|
17
|
+
net: NetworkTracker;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function trackNetwork(page: Page): NetworkTracker {
|
|
22
|
+
let inflight = 0;
|
|
23
|
+
let lastChange = Date.now();
|
|
24
|
+
const up = () => {
|
|
25
|
+
inflight++;
|
|
26
|
+
lastChange = Date.now();
|
|
27
|
+
};
|
|
28
|
+
const down = () => {
|
|
29
|
+
inflight = Math.max(0, inflight - 1);
|
|
30
|
+
lastChange = Date.now();
|
|
31
|
+
};
|
|
32
|
+
page.on('request', up);
|
|
33
|
+
page.on('requestfinished', down);
|
|
34
|
+
page.on('requestfailed', down);
|
|
35
|
+
return {
|
|
36
|
+
async idle(quietMs, capMs) {
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
while (Date.now() - start < capMs) {
|
|
39
|
+
if (inflight === 0 && Date.now() - Math.max(lastChange, start) >= quietMs) return;
|
|
40
|
+
await sleep(50);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Init script that blurs `selectors`. Built as a string on purpose: a function would be serialized
|
|
48
|
+
* with whatever helpers the transpiler injected (tsx adds `__name(...)`), which are undefined in the page.
|
|
49
|
+
*/
|
|
50
|
+
export function maskScript(selectors: string[]): string {
|
|
51
|
+
// One rule per selector: an invalid selector only drops its own rule.
|
|
52
|
+
const css = JSON.stringify(selectors.map((s) => `${s} { filter: blur(10px) !important; }`).join('\n'));
|
|
53
|
+
return `(() => {
|
|
54
|
+
const css = ${css};
|
|
55
|
+
const add = () => {
|
|
56
|
+
const el = document.createElement('style');
|
|
57
|
+
el.textContent = css;
|
|
58
|
+
document.documentElement.appendChild(el);
|
|
59
|
+
};
|
|
60
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
|
|
61
|
+
else add();
|
|
62
|
+
})();`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function openSession(script: Script, opts: { headed?: boolean } = {}): Promise<Session> {
|
|
66
|
+
const { app } = script;
|
|
67
|
+
const browser = await chromium.launch({
|
|
68
|
+
headless: !opts.headed,
|
|
69
|
+
// Both the flag and the context DPR are needed for device-pixel screencast frames.
|
|
70
|
+
args: [`--force-device-scale-factor=${app.deviceScaleFactor}`, '--hide-scrollbars'],
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const context = await browser.newContext({
|
|
74
|
+
viewport: app.viewport,
|
|
75
|
+
deviceScaleFactor: app.deviceScaleFactor,
|
|
76
|
+
storageState: app.storageState,
|
|
77
|
+
colorScheme: app.colorScheme,
|
|
78
|
+
locale: app.locale,
|
|
79
|
+
timezoneId: app.timezoneId,
|
|
80
|
+
});
|
|
81
|
+
if (app.mask.length) await context.addInitScript({ content: maskScript(app.mask) });
|
|
82
|
+
const page = await context.newPage();
|
|
83
|
+
page.setDefaultTimeout(STEP_TIMEOUT_MS);
|
|
84
|
+
const invalid = await page.evaluate(
|
|
85
|
+
(selectors) =>
|
|
86
|
+
selectors.map((s) => {
|
|
87
|
+
try {
|
|
88
|
+
document.querySelector(s);
|
|
89
|
+
return false;
|
|
90
|
+
} catch {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
app.mask,
|
|
95
|
+
);
|
|
96
|
+
const bad = invalid.indexOf(true);
|
|
97
|
+
if (bad >= 0) {
|
|
98
|
+
throw new ScriptError(
|
|
99
|
+
`app.mask[${bad}] "${app.mask[bad]}" is not a valid CSS selector — masks take CSS selectors (.email, #card), not Playwright ones like text= or role=`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { browser, context, page, net: trackNetwork(page), close: () => browser.close() };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
await browser.close();
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Open app.url, run setup steps (fast, unlogged), park the mouse at the centre. */
|
|
110
|
+
export async function prepare(session: Session, script: Script): Promise<Pt> {
|
|
111
|
+
const { page } = session;
|
|
112
|
+
try {
|
|
113
|
+
await page.goto(script.app.url, { waitUntil: 'load', timeout: 30_000 });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw new Error(`Cannot open app.url ${script.app.url}: ${(err as Error).message.split('\n')[0]}`);
|
|
116
|
+
}
|
|
117
|
+
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 };
|
|
119
|
+
for (const [index, step] of script.setup.entries()) await runStep(ctx, step, { sceneId: 'setup', index });
|
|
120
|
+
await page.mouse.move(centre.x, centre.y);
|
|
121
|
+
return centre;
|
|
122
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Page } from 'playwright';
|
|
4
|
+
|
|
5
|
+
export interface Frame {
|
|
6
|
+
file: string;
|
|
7
|
+
/** ms since t0 */
|
|
8
|
+
tMs: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Capture {
|
|
12
|
+
t0: number;
|
|
13
|
+
stop(): Promise<Frame[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** CDP screencast → numbered JPEGs. Chrome only sends frames when something repaints. */
|
|
17
|
+
export async function startCapture(page: Page, dir: string, size: { width: number; height: number }): Promise<Capture> {
|
|
18
|
+
const cdp = await page.context().newCDPSession(page);
|
|
19
|
+
const frames: Frame[] = [];
|
|
20
|
+
const writes: Promise<void>[] = [];
|
|
21
|
+
let n = 0;
|
|
22
|
+
let stopped: Promise<Frame[]> | null = null;
|
|
23
|
+
const t0 = Date.now();
|
|
24
|
+
|
|
25
|
+
cdp.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
|
|
26
|
+
cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
|
|
27
|
+
const tMs = metadata.timestamp ? metadata.timestamp * 1000 - t0 : Date.now() - t0;
|
|
28
|
+
const file = path.join(dir, `${String(++n).padStart(6, '0')}.jpg`);
|
|
29
|
+
frames.push({ file, tMs: Math.max(0, tMs) });
|
|
30
|
+
writes.push(writeFile(file, Buffer.from(data, 'base64')));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await cdp.send('Page.startScreencast', {
|
|
34
|
+
format: 'jpeg',
|
|
35
|
+
quality: 92,
|
|
36
|
+
maxWidth: size.width,
|
|
37
|
+
maxHeight: size.height,
|
|
38
|
+
everyNthFrame: 1,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
t0,
|
|
43
|
+
stop() {
|
|
44
|
+
stopped ??= (async () => {
|
|
45
|
+
await cdp.send('Page.stopScreencast').catch(() => undefined);
|
|
46
|
+
await Promise.all(writes);
|
|
47
|
+
await cdp.detach().catch(() => undefined);
|
|
48
|
+
return frames.sort((a, b) => a.tMs - b.tMs);
|
|
49
|
+
})();
|
|
50
|
+
return stopped;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { workPaths } from '../paths';
|
|
4
|
+
import type { LoadedScript } from '../script/load';
|
|
5
|
+
import { openSession, prepare } from './browser';
|
|
6
|
+
import { EventLog } from './events';
|
|
7
|
+
import { describeStep, runStep, StepError, type StepContext } from './steps';
|
|
8
|
+
|
|
9
|
+
export interface RunOptions {
|
|
10
|
+
headed?: boolean;
|
|
11
|
+
log?: (line: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CheckStep {
|
|
15
|
+
sceneId: string;
|
|
16
|
+
index: number;
|
|
17
|
+
action: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
screenshot?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CheckFailure {
|
|
24
|
+
sceneId: string;
|
|
25
|
+
index: number;
|
|
26
|
+
error: string;
|
|
27
|
+
screenshot: string;
|
|
28
|
+
aria: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CheckReport {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
steps: CheckStep[];
|
|
34
|
+
failure: CheckFailure | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ARIA_LIMIT = 8000;
|
|
38
|
+
|
|
39
|
+
export async function check(loaded: LoadedScript, opts: RunOptions = {}): Promise<CheckReport> {
|
|
40
|
+
const { script } = loaded;
|
|
41
|
+
const paths = workPaths(loaded);
|
|
42
|
+
await rm(paths.check, { recursive: true, force: true });
|
|
43
|
+
await mkdir(paths.check, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const report: CheckReport = { ok: true, steps: [], failure: null };
|
|
46
|
+
const session = await openSession(script, opts);
|
|
47
|
+
try {
|
|
48
|
+
const cursor = await prepare(session, script);
|
|
49
|
+
const ctx: StepContext = { page: session.page, mode: 'check', log: new EventLog(), net: session.net, cursor };
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (const scene of script.scenes) {
|
|
52
|
+
for (const [index, step] of scene.steps.entries()) {
|
|
53
|
+
await runStep(ctx, step, { sceneId: scene.id, index });
|
|
54
|
+
const screenshot = path.join(paths.check, `${String(++n).padStart(2, '0')}-${scene.id}-${index + 1}-${step.do}.png`);
|
|
55
|
+
await session.page.screenshot({ path: screenshot, scale: 'css' });
|
|
56
|
+
report.steps.push({ sceneId: scene.id, index, action: describeStep(step), ok: true, screenshot });
|
|
57
|
+
opts.log?.(`✓ ${scene.id} #${index + 1} ${describeStep(step)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!(err instanceof StepError)) throw err;
|
|
62
|
+
const screenshot = path.join(paths.check, 'failure.png');
|
|
63
|
+
await session.page.screenshot({ path: screenshot, scale: 'css' }).catch(() => undefined);
|
|
64
|
+
const aria = await session.page.locator('body').ariaSnapshot({ timeout: 5000 }).catch(() => '');
|
|
65
|
+
report.ok = false;
|
|
66
|
+
report.failure = { sceneId: err.where.sceneId, index: err.where.index, error: err.message, screenshot, aria: aria.slice(0, ARIA_LIMIT) };
|
|
67
|
+
report.steps.push({ sceneId: err.where.sceneId, index: err.where.index, action: describeStep(err.step), ok: false, error: err.message });
|
|
68
|
+
opts.log?.(`✗ ${err.message}`);
|
|
69
|
+
} finally {
|
|
70
|
+
await session.close();
|
|
71
|
+
}
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { runWithInput } from '../ffmpeg';
|
|
3
|
+
import type { Frame } from './capture';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resample the variable-rate screencast to constant fps: output frame k shows the latest frame
|
|
7
|
+
* captured at or before k/fps (the first frame also covers the time before it arrived).
|
|
8
|
+
* Yields exactly round(duration × fps) frames, so static stretches keep wall-clock time.
|
|
9
|
+
*/
|
|
10
|
+
export function sampleFrames(frames: Frame[], durationMs: number, fps: number): string[] {
|
|
11
|
+
const n = Math.max(1, Math.round((durationMs * fps) / 1000));
|
|
12
|
+
const out: string[] = [];
|
|
13
|
+
let j = 0;
|
|
14
|
+
for (let k = 0; k < n; k++) {
|
|
15
|
+
const t = (k * 1000) / fps;
|
|
16
|
+
while (j + 1 < frames.length && frames[j + 1].tMs <= t) j++;
|
|
17
|
+
out.push(frames[j].file);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function encodeFrames(frames: Frame[], out: string, opts: { fps: number; durationMs: number }): Promise<void> {
|
|
23
|
+
if (!frames.length) throw new Error('No frames were captured (did the page render anything?)');
|
|
24
|
+
const files = sampleFrames(frames, opts.durationMs, opts.fps);
|
|
25
|
+
const fps = String(opts.fps);
|
|
26
|
+
await runWithInput(
|
|
27
|
+
'ffmpeg',
|
|
28
|
+
[
|
|
29
|
+
'-y', '-loglevel', 'error',
|
|
30
|
+
'-f', 'image2pipe', '-framerate', fps, '-c:v', 'mjpeg', '-i', '-',
|
|
31
|
+
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p',
|
|
32
|
+
'-r', fps,
|
|
33
|
+
'-c:v', 'libx264', '-preset', 'medium', '-crf', '14',
|
|
34
|
+
'-movflags', '+faststart',
|
|
35
|
+
out,
|
|
36
|
+
],
|
|
37
|
+
async (write) => {
|
|
38
|
+
let lastFile = '';
|
|
39
|
+
let lastData = Buffer.alloc(0);
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (file !== lastFile) {
|
|
42
|
+
lastData = await readFile(file);
|
|
43
|
+
lastFile = file;
|
|
44
|
+
}
|
|
45
|
+
await write(lastData);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Click, Interval, Move, StepEvent } from '../timeline/types';
|
|
2
|
+
|
|
3
|
+
/** Collects what happened during a run, timed relative to capture start. */
|
|
4
|
+
export class EventLog {
|
|
5
|
+
readonly steps: StepEvent[] = [];
|
|
6
|
+
readonly moves: Move[] = [];
|
|
7
|
+
readonly clicks: Click[] = [];
|
|
8
|
+
readonly idle: Interval[] = [];
|
|
9
|
+
|
|
10
|
+
constructor(private readonly t0: number = Date.now()) {}
|
|
11
|
+
|
|
12
|
+
now(): number {
|
|
13
|
+
return Date.now() - this.t0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assertFfmpeg, probeVideo } from '../ffmpeg';
|
|
4
|
+
import { workPaths } from '../paths';
|
|
5
|
+
import { actionHash } from '../script/hash';
|
|
6
|
+
import type { LoadedScript } from '../script/load';
|
|
7
|
+
import type { TakeEvents } from '../timeline/types';
|
|
8
|
+
import { openSession, prepare, type Session } from './browser';
|
|
9
|
+
import { startCapture, type Frame } from './capture';
|
|
10
|
+
import type { RunOptions } from './check';
|
|
11
|
+
import { encodeFrames } from './encode';
|
|
12
|
+
import { EventLog } from './events';
|
|
13
|
+
import { describeStep, runStep, StepError, type StepContext } from './steps';
|
|
14
|
+
import { sleep } from './time';
|
|
15
|
+
|
|
16
|
+
export const ESTABLISH_MS = 800;
|
|
17
|
+
export const TAIL_MS = 800;
|
|
18
|
+
export const TAKE_FPS = 30;
|
|
19
|
+
|
|
20
|
+
export interface RecordOptions extends RunOptions {
|
|
21
|
+
keepFrames?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Shot {
|
|
25
|
+
frames: Frame[];
|
|
26
|
+
durationMs: number;
|
|
27
|
+
log: EventLog;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function shoot(session: Session, loaded: LoadedScript, framesDir: string, opts: RecordOptions): Promise<Shot> {
|
|
31
|
+
const { script } = loaded;
|
|
32
|
+
const { viewport, deviceScaleFactor } = script.app;
|
|
33
|
+
const cursor = await prepare(session, script);
|
|
34
|
+
const capture = await startCapture(session.page, framesDir, {
|
|
35
|
+
width: viewport.width * deviceScaleFactor,
|
|
36
|
+
height: viewport.height * deviceScaleFactor,
|
|
37
|
+
});
|
|
38
|
+
const log = new EventLog(capture.t0);
|
|
39
|
+
const ctx: StepContext = { page: session.page, mode: 'record', log, net: session.net, cursor };
|
|
40
|
+
try {
|
|
41
|
+
await sleep(ESTABLISH_MS);
|
|
42
|
+
for (const scene of script.scenes) {
|
|
43
|
+
for (const [index, step] of scene.steps.entries()) {
|
|
44
|
+
opts.log?.(`● ${scene.id} #${index + 1} ${describeStep(step)}`);
|
|
45
|
+
await runStep(ctx, step, { sceneId: scene.id, index });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await sleep(TAIL_MS);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
await capture.stop();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
const durationMs = log.now();
|
|
54
|
+
const frames = await capture.stop();
|
|
55
|
+
return { frames, durationMs, log };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function record(loaded: LoadedScript, opts: RecordOptions = {}): Promise<TakeEvents> {
|
|
59
|
+
const { script } = loaded;
|
|
60
|
+
await assertFfmpeg();
|
|
61
|
+
const paths = workPaths(loaded);
|
|
62
|
+
await rm(paths.take, { recursive: true, force: true });
|
|
63
|
+
await mkdir(paths.rawFrames, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const session = await openSession(script, opts);
|
|
66
|
+
let shot: Shot;
|
|
67
|
+
try {
|
|
68
|
+
shot = await shoot(session, loaded, paths.rawFrames, opts);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof StepError) {
|
|
71
|
+
await session.page.screenshot({ path: path.join(paths.take, 'failure.png'), scale: 'css' }).catch(() => undefined);
|
|
72
|
+
throw new Error(`${err.message}\nRecording aborted. Run \`showtime check ${loaded.scriptPath}\` to debug.`);
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
} finally {
|
|
76
|
+
await session.close();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
opts.log?.('Encoding raw.mp4…');
|
|
80
|
+
await encodeFrames(shot.frames, paths.raw, { fps: TAKE_FPS, durationMs: shot.durationMs });
|
|
81
|
+
const probe = await probeVideo(paths.raw);
|
|
82
|
+
const events: TakeEvents = {
|
|
83
|
+
version: 1,
|
|
84
|
+
actionHash: actionHash(script),
|
|
85
|
+
viewport: script.app.viewport,
|
|
86
|
+
deviceScaleFactor: script.app.deviceScaleFactor,
|
|
87
|
+
video: { file: 'raw.mp4', width: probe.width, height: probe.height, fps: TAKE_FPS, durationMs: shot.durationMs },
|
|
88
|
+
steps: shot.log.steps,
|
|
89
|
+
moves: shot.log.moves,
|
|
90
|
+
clicks: shot.log.clicks,
|
|
91
|
+
idle: shot.log.idle,
|
|
92
|
+
};
|
|
93
|
+
await writeFile(paths.events, JSON.stringify(events, null, 2));
|
|
94
|
+
if (!opts.keepFrames) await rm(paths.rawFrames, { recursive: true, force: true });
|
|
95
|
+
return events;
|
|
96
|
+
}
|