@mineo71/showtime 0.2.1 → 0.4.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 +32 -0
- package/README.md +155 -19
- package/docs/script.schema.json +395 -0
- package/package.json +1 -1
- package/skill/SKILL.md +70 -13
- package/src/audio/decode.ts +19 -0
- package/src/audio/mix.ts +72 -0
- package/src/audio/sfx.ts +55 -0
- package/src/audio/soundtrack.ts +49 -0
- package/src/audio/tts.ts +186 -0
- package/src/audio/wav.ts +96 -0
- package/src/cli/format.ts +10 -0
- package/src/cli/main.ts +125 -40
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +128 -0
- package/src/logo.ts +20 -0
- package/src/paths.ts +19 -0
- package/src/pipeline.ts +97 -19
- package/src/render/remotion/Intro.tsx +3 -1
- package/src/render/remotion/Logo.tsx +6 -0
- package/src/render/remotion/Outro.tsx +38 -0
- package/src/render/remotion/Overlays.tsx +41 -16
- package/src/render/remotion/ShowtimeVideo.tsx +56 -18
- package/src/render/render.ts +1 -1
- package/src/runner/browser.ts +47 -6
- package/src/runner/check.ts +10 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/hooks.ts +111 -0
- package/src/runner/record.ts +53 -34
- package/src/runner/steps.ts +4 -1
- package/src/script/config.ts +74 -0
- package/src/script/hash.ts +7 -1
- package/src/script/load.ts +15 -2
- package/src/script/schema.ts +115 -1
- package/src/setup.ts +13 -4
- package/src/sheet.ts +47 -0
- package/src/timeline/build.ts +113 -13
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +15 -4
- package/src/timeline/layout.ts +70 -0
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- package/src/timeline/types.ts +44 -1
|
@@ -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);
|
|
@@ -19,7 +20,8 @@ export const Intro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t,
|
|
|
19
20
|
textAlign: 'center',
|
|
20
21
|
}}
|
|
21
22
|
>
|
|
22
|
-
<div style={{ opacity: Math.min(enter, exit), transform: `scale(${0.94 + 0.06 * enter})` }}>
|
|
23
|
+
<div style={{ maxWidth: '86%', 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={{ maxWidth: '86%', 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
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { FC } from 'react';
|
|
1
|
+
import type { CSSProperties, FC } from 'react';
|
|
2
2
|
import { ripplesAt } from '../../timeline/cursor';
|
|
3
|
+
import { CAPTION_FADE_MS, SPOTLIGHT_FADE_MS } from '../../timeline/fades';
|
|
3
4
|
import { clamp, easeOutCubic } from '../../timeline/math';
|
|
4
5
|
import type { Timeline } from '../../timeline/types';
|
|
5
6
|
|
|
@@ -25,7 +26,7 @@ export const Spotlights: FC<LayerProps> = ({ timeline, srcMs }) => (
|
|
|
25
26
|
boxShadow: '0 0 0 9999px rgba(2, 6, 23, 0.55)',
|
|
26
27
|
outline: `3px solid ${timeline.style.accent}`,
|
|
27
28
|
outlineOffset: 2,
|
|
28
|
-
opacity: envelope(srcMs, s.startMs, s.endMs,
|
|
29
|
+
opacity: envelope(srcMs, s.startMs, s.endMs, SPOTLIGHT_FADE_MS),
|
|
29
30
|
}}
|
|
30
31
|
/>
|
|
31
32
|
))}
|
|
@@ -55,22 +56,19 @@ export const Ripples: FC<LayerProps> = ({ timeline, srcMs }) => (
|
|
|
55
56
|
</>
|
|
56
57
|
);
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
/** Where an overlay row sits: an inset from the top or bottom, or a band (top + height / top + bottom). */
|
|
60
|
+
type Place = Pick<CSSProperties, 'top' | 'bottom' | 'height' | 'alignItems'>;
|
|
61
|
+
|
|
62
|
+
const row = (place: Place): CSSProperties => ({ position: 'absolute', left: 0, right: 0, display: 'flex', justifyContent: 'center', ...place });
|
|
63
|
+
|
|
64
|
+
/** The active caption, placed at `top` or `bottom` by its position. */
|
|
65
|
+
export const Captions: FC<LayerProps & { top: Place; bottom: Place; fontSize: number }> = ({ timeline, srcMs, top, bottom, fontSize }) => {
|
|
59
66
|
const active = timeline.captions.filter((c) => srcMs >= c.startMs && srcMs <= c.endMs);
|
|
60
67
|
const c = active[active.length - 1];
|
|
61
68
|
if (!c) return null;
|
|
62
|
-
const o = envelope(srcMs, c.startMs, c.endMs,
|
|
69
|
+
const o = envelope(srcMs, c.startMs, c.endMs, CAPTION_FADE_MS);
|
|
63
70
|
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
|
-
>
|
|
71
|
+
<div style={row(c.position === 'top' ? top : bottom)}>
|
|
74
72
|
<div
|
|
75
73
|
style={{
|
|
76
74
|
maxWidth: '70%',
|
|
@@ -79,13 +77,40 @@ export const Captions: FC<LayerProps> = ({ timeline, srcMs }) => {
|
|
|
79
77
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
80
78
|
color: '#fff',
|
|
81
79
|
fontFamily: timeline.style.font,
|
|
82
|
-
fontSize
|
|
80
|
+
fontSize,
|
|
83
81
|
fontWeight: 600,
|
|
84
82
|
lineHeight: 1.25,
|
|
85
83
|
textAlign: 'center',
|
|
86
84
|
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.35)',
|
|
87
85
|
opacity: o,
|
|
88
|
-
transform: `translateY(${(1 - o) * 12}px)`,
|
|
86
|
+
transform: `translateY(${(1 - o) * (c.position === 'top' ? -12 : 12)}px)`,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{c.text}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Burned-in narration subtitles. `videoMs` is output time from the start of the video. */
|
|
96
|
+
export const Subtitles: FC<{ timeline: Timeline; videoMs: number; place: Place; fontSize: number }> = ({ timeline, videoMs, place, fontSize }) => {
|
|
97
|
+
if (!timeline.subtitlesBurned) return null;
|
|
98
|
+
const c = timeline.subtitles.find((s) => videoMs >= s.startMs && videoMs < s.endMs);
|
|
99
|
+
if (!c) return null;
|
|
100
|
+
return (
|
|
101
|
+
<div style={row(place)}>
|
|
102
|
+
<div
|
|
103
|
+
style={{
|
|
104
|
+
maxWidth: '80%',
|
|
105
|
+
padding: '10px 24px',
|
|
106
|
+
borderRadius: 12,
|
|
107
|
+
background: 'rgba(0, 0, 0, 0.72)',
|
|
108
|
+
color: '#fff',
|
|
109
|
+
fontFamily: timeline.style.font,
|
|
110
|
+
fontSize,
|
|
111
|
+
fontWeight: 500,
|
|
112
|
+
lineHeight: 1.35,
|
|
113
|
+
textAlign: 'center',
|
|
89
114
|
}}
|
|
90
115
|
>
|
|
91
116
|
{c.text}
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
import type { FC } from 'react';
|
|
2
|
-
import { AbsoluteFill, Easing, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
|
|
2
|
+
import { AbsoluteFill, Easing, Freeze, Html5Audio, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
|
|
3
3
|
import { cameraAt, cameraTransform } from '../../timeline/camera';
|
|
4
|
-
import { introFrames, segmentPlan, sourceMsAtFrame } from '../../timeline/frames';
|
|
5
|
-
import { BROWSER_BAR,
|
|
4
|
+
import { introFrames, segmentPlan, sourceMsAtFrame, videoEndFrame } from '../../timeline/frames';
|
|
5
|
+
import { BROWSER_BAR, viewLayout } 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 {
|
|
10
|
+
import { Outro } from './Outro';
|
|
11
|
+
import { Captions, Ripples, Spotlights, Subtitles } from './Overlays';
|
|
11
12
|
|
|
12
13
|
export type ShowtimeVideoProps = { timeline: Timeline };
|
|
13
14
|
|
|
14
15
|
const SHADOW = '0 30px 80px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.25)';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/** The soundtrack plays across every frame; the picture switches between cards and the take. */
|
|
18
|
+
export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => (
|
|
19
|
+
<>
|
|
20
|
+
{t.audio ? <Html5Audio src={staticFile(t.audio.src)} /> : null}
|
|
21
|
+
<Picture timeline={t} />
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const Picture: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
|
|
17
26
|
const frame = useCurrentFrame();
|
|
18
27
|
const intro = introFrames(t);
|
|
19
28
|
if (frame < intro) return <Intro timeline={t} frame={frame} />;
|
|
29
|
+
const end = videoEndFrame(t);
|
|
30
|
+
if (t.outro && frame >= end) return <Outro timeline={t} frame={frame - end} />;
|
|
20
31
|
|
|
21
32
|
const src = sourceMsAtFrame(t, frame) ?? 0;
|
|
22
|
-
const win =
|
|
33
|
+
const win = viewLayout(t.output, t.stage, t.style.padding, t.chrome ? BROWSER_BAR : 0, t.aspect);
|
|
23
34
|
const cam = cameraAt(t.camera, src);
|
|
24
|
-
const
|
|
35
|
+
const { content } = win;
|
|
36
|
+
const k = content.width / t.stage.width;
|
|
37
|
+
const H = t.output.height;
|
|
38
|
+
// 9:16 puts captions and subtitles in the bands above and below the window; the others overlay it.
|
|
39
|
+
const bands = t.aspect === '9:16';
|
|
40
|
+
const above = { top: 0, height: win.y - win.bar, alignItems: 'center' } as const;
|
|
41
|
+
const below = { top: win.y + win.height, bottom: 0, alignItems: 'center' } as const;
|
|
25
42
|
// After an intro card the window rises into place instead of cutting in.
|
|
26
43
|
const enter = intro
|
|
27
44
|
? interpolate(frame - intro, [0, 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic) })
|
|
28
45
|
: 1;
|
|
46
|
+
// Before an outro card the window and its captions fade out over the last 12 frames.
|
|
47
|
+
const leave = t.outro ? interpolate(frame, [end - 12, end], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) : 1;
|
|
29
48
|
|
|
30
49
|
return (
|
|
31
50
|
<AbsoluteFill style={{ background: t.style.background, fontFamily: t.style.font }}>
|
|
@@ -40,7 +59,7 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
|
|
|
40
59
|
overflow: 'hidden',
|
|
41
60
|
background: '#fff',
|
|
42
61
|
boxShadow: t.style.shadow ? SHADOW : undefined,
|
|
43
|
-
opacity: enter,
|
|
62
|
+
opacity: enter * leave,
|
|
44
63
|
transform: `translateY(${(1 - enter) * 40}px) scale(${0.96 + 0.04 * enter})`,
|
|
45
64
|
}}
|
|
46
65
|
>
|
|
@@ -51,23 +70,28 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
|
|
|
51
70
|
position: 'absolute',
|
|
52
71
|
left: 0,
|
|
53
72
|
top: 0,
|
|
54
|
-
width:
|
|
55
|
-
height:
|
|
73
|
+
width: content.width,
|
|
74
|
+
height: content.height,
|
|
56
75
|
transformOrigin: '0 0',
|
|
57
|
-
transform: cameraTransform(cam, win.width, win.height),
|
|
76
|
+
transform: cameraTransform(cam, win.width, win.height, content),
|
|
58
77
|
}}
|
|
59
78
|
>
|
|
60
|
-
{segmentPlan(t).map((p) =>
|
|
61
|
-
|
|
79
|
+
{segmentPlan(t).map((p) => {
|
|
80
|
+
const video = (
|
|
62
81
|
<OffthreadVideo
|
|
63
82
|
src={staticFile(t.video.src)}
|
|
64
83
|
trimBefore={p.trimBefore}
|
|
65
|
-
playbackRate={p.speed}
|
|
84
|
+
playbackRate={p.speed || 1}
|
|
66
85
|
muted
|
|
67
|
-
style={{ position: 'absolute', left: 0, top: 0, width:
|
|
86
|
+
style={{ position: 'absolute', left: 0, top: 0, width: content.width, height: content.height }}
|
|
68
87
|
/>
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
);
|
|
89
|
+
return (
|
|
90
|
+
<Sequence key={p.fromFrame} from={p.fromFrame} durationInFrames={p.durationInFrames} layout="none">
|
|
91
|
+
{p.speed === 0 ? <Freeze frame={0}>{video}</Freeze> : video}
|
|
92
|
+
</Sequence>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
71
95
|
<div
|
|
72
96
|
style={{
|
|
73
97
|
position: 'absolute',
|
|
@@ -86,7 +110,21 @@ export const ShowtimeVideo: FC<ShowtimeVideoProps> = ({ timeline: t }) => {
|
|
|
86
110
|
</div>
|
|
87
111
|
</div>
|
|
88
112
|
</div>
|
|
89
|
-
<
|
|
113
|
+
<div style={{ opacity: leave }}>
|
|
114
|
+
<Captions
|
|
115
|
+
timeline={t}
|
|
116
|
+
srcMs={src}
|
|
117
|
+
top={bands ? above : { top: win.y + Math.round(H * 0.03) }}
|
|
118
|
+
bottom={bands ? below : { bottom: Math.round(H * 0.075) }}
|
|
119
|
+
fontSize={bands ? 46 : 36}
|
|
120
|
+
/>
|
|
121
|
+
<Subtitles
|
|
122
|
+
timeline={t}
|
|
123
|
+
videoMs={((frame - intro) * 1000) / t.fps}
|
|
124
|
+
place={bands ? below : { bottom: Math.round(H * 0.06) }}
|
|
125
|
+
fontSize={bands ? 40 : 34}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
90
128
|
</AbsoluteFill>
|
|
91
129
|
);
|
|
92
130
|
};
|
package/src/render/render.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Timeline } from '../timeline/types';
|
|
|
8
8
|
const ENTRY = fileURLToPath(new URL('./remotion/index.ts', import.meta.url));
|
|
9
9
|
const COMPOSITION_ID = 'ShowtimeVideo';
|
|
10
10
|
|
|
11
|
-
/** Bundle the composition with
|
|
11
|
+
/** Bundle the composition with `.showtime/<name>/` as public dir: the take is staticFile('take/raw.mp4'). */
|
|
12
12
|
async function prepare(timeline: Timeline, publicDir: string) {
|
|
13
13
|
const serveUrl = await bundle({ entryPoint: ENTRY, publicDir, symlinkPublicDir: true });
|
|
14
14
|
const inputProps = { timeline };
|
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()) {
|
package/src/runner/events.ts
CHANGED
|
@@ -6,6 +6,8 @@ export class EventLog {
|
|
|
6
6
|
readonly moves: Move[] = [];
|
|
7
7
|
readonly clicks: Click[] = [];
|
|
8
8
|
readonly idle: Interval[] = [];
|
|
9
|
+
/** keystroke times of human-paced typing */
|
|
10
|
+
readonly keys: number[] = [];
|
|
9
11
|
|
|
10
12
|
constructor(private readonly t0: number = Date.now()) {}
|
|
11
13
|
|
|
@@ -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,56 @@ 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
|
+
keys: shot.log.keys,
|
|
106
|
+
};
|
|
107
|
+
await writeFile(path.join(tmp, 'events.json'), JSON.stringify(events, null, 2));
|
|
108
|
+
if (!opts.keepFrames) await rm(frames, { recursive: true, force: true });
|
|
109
|
+
await rm(paths.take, { recursive: true, force: true });
|
|
110
|
+
await rename(tmp, paths.take);
|
|
111
|
+
return events;
|
|
112
|
+
},
|
|
113
|
+
opts.warn,
|
|
114
|
+
);
|
|
96
115
|
}
|