screenci 0.0.68 → 0.0.70
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/README.md +45 -122
- package/dist/cli.d.ts +3 -18
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +117 -158
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +66 -35
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +22 -11
- package/dist/docs/manifest.js.map +1 -1
- package/dist/docs/video-sources/cli.screenci.d.ts +2 -0
- package/dist/docs/video-sources/cli.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/{cli.video.js → cli.screenci.js} +1 -1
- package/dist/docs/video-sources/cli.screenci.js.map +1 -0
- package/dist/docs/video-sources/installation.screenci.d.ts +2 -0
- package/dist/docs/video-sources/installation.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/{installation.video.js → installation.screenci.js} +1 -1
- package/dist/docs/video-sources/installation.screenci.js.map +1 -0
- package/dist/docs/video-sources/landing.screenci.d.ts +2 -0
- package/dist/docs/video-sources/landing.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/{landing.video.js → landing.screenci.js} +1 -1
- package/dist/docs/video-sources/landing.screenci.js.map +1 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts +2 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js +53 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -0
- package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts +2 -0
- package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/locator-highlight-still.screenci.js +24 -0
- package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -0
- package/dist/docs/video-sources/product-pitch.screenci.d.ts +2 -0
- package/dist/docs/video-sources/product-pitch.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/product-pitch.screenci.js +136 -0
- package/dist/docs/video-sources/product-pitch.screenci.js.map +1 -0
- package/dist/docs/video-sources/public-urls.screenci.d.ts +2 -0
- package/dist/docs/video-sources/public-urls.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/public-urls.screenci.js +34 -0
- package/dist/docs/video-sources/public-urls.screenci.js.map +1 -0
- package/dist/docs/video-sources/studio.screenci.d.ts +2 -0
- package/dist/docs/video-sources/studio.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/studio.screenci.js +57 -0
- package/dist/docs/video-sources/studio.screenci.js.map +1 -0
- package/dist/docs/videos.d.ts +36 -15
- package/dist/docs/videos.d.ts.map +1 -1
- package/dist/docs/videos.js +27 -2
- package/dist/docs/videos.js.map +1 -1
- package/dist/e2e/htmlRasterizer.e2e.d.ts +2 -0
- package/dist/e2e/htmlRasterizer.e2e.d.ts.map +1 -0
- package/dist/e2e/htmlRasterizer.e2e.js +107 -0
- package/dist/e2e/htmlRasterizer.e2e.js.map +1 -0
- package/dist/e2e/screenshot.e2e.d.ts +2 -0
- package/dist/e2e/screenshot.e2e.d.ts.map +1 -0
- package/dist/e2e/screenshot.e2e.js +73 -0
- package/dist/e2e/screenshot.e2e.js.map +1 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +245 -53
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +718 -77
- package/dist/src/asset.js.map +1 -1
- package/dist/src/audio.d.ts +127 -0
- package/dist/src/audio.d.ts.map +1 -0
- package/dist/src/audio.js +207 -0
- package/dist/src/audio.js.map +1 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +9 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/contextOptions.d.ts +48 -0
- package/dist/src/contextOptions.d.ts.map +1 -0
- package/dist/src/contextOptions.js +71 -0
- package/dist/src/contextOptions.js.map +1 -0
- package/dist/src/crop.d.ts +93 -0
- package/dist/src/crop.d.ts.map +1 -0
- package/dist/src/crop.js +133 -0
- package/dist/src/crop.js.map +1 -0
- package/dist/src/cue.d.ts +17 -4
- package/dist/src/cue.d.ts.map +1 -1
- package/dist/src/cue.js +31 -3
- package/dist/src/cue.js.map +1 -1
- package/dist/src/defaults.d.ts +13 -1
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +13 -0
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/dimensions.d.ts +6 -0
- package/dist/src/dimensions.d.ts.map +1 -1
- package/dist/src/dimensions.js +15 -0
- package/dist/src/dimensions.js.map +1 -1
- package/dist/src/events.d.ts +283 -13
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +134 -5
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +153 -0
- package/dist/src/htmlRasterizer.d.ts.map +1 -0
- package/dist/src/htmlRasterizer.js +386 -0
- package/dist/src/htmlRasterizer.js.map +1 -0
- package/dist/src/init.d.ts +17 -0
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +131 -11
- package/dist/src/init.js.map +1 -1
- package/dist/src/linkSession.d.ts +36 -0
- package/dist/src/linkSession.d.ts.map +1 -0
- package/dist/src/linkSession.js +122 -0
- package/dist/src/linkSession.js.map +1 -0
- package/dist/src/overlayFlush.d.ts +18 -0
- package/dist/src/overlayFlush.d.ts.map +1 -0
- package/dist/src/overlayFlush.js +56 -0
- package/dist/src/overlayFlush.js.map +1 -0
- package/dist/src/overlayRect.d.ts +69 -0
- package/dist/src/overlayRect.d.ts.map +1 -0
- package/dist/src/overlayRect.js +55 -0
- package/dist/src/overlayRect.js.map +1 -0
- package/dist/src/performance.d.ts +1 -3
- package/dist/src/performance.d.ts.map +1 -1
- package/dist/src/performance.js +0 -12
- package/dist/src/performance.js.map +1 -1
- package/dist/src/recording.d.ts +3 -2
- package/dist/src/recording.d.ts.map +1 -1
- package/dist/src/recordingData.d.ts +22 -2
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/runtimeContext.d.ts +57 -1
- package/dist/src/runtimeContext.d.ts.map +1 -1
- package/dist/src/runtimeContext.js +40 -0
- package/dist/src/runtimeContext.js.map +1 -1
- package/dist/src/screenshot.d.ts +105 -0
- package/dist/src/screenshot.d.ts.map +1 -0
- package/dist/src/screenshot.js +188 -0
- package/dist/src/screenshot.js.map +1 -0
- package/dist/src/stillCapture.d.ts +68 -0
- package/dist/src/stillCapture.d.ts.map +1 -0
- package/dist/src/stillCapture.js +119 -0
- package/dist/src/stillCapture.js.map +1 -0
- package/dist/src/studio.d.ts +8 -11
- package/dist/src/studio.d.ts.map +1 -1
- package/dist/src/studio.js +2 -25
- package/dist/src/studio.js.map +1 -1
- package/dist/src/types.d.ts +125 -15
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +54 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +190 -42
- package/dist/src/video.js.map +1 -1
- package/dist/src/voices.d.ts +22 -9
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/src/voices.js +6 -24
- package/dist/src/voices.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -2
- package/skills/screenci/SKILL.md +37 -15
- package/skills/screenci/references/init.md +3 -1
- package/skills/screenci/references/record.md +8 -3
- package/dist/docs/video-sources/cli.video.d.ts +0 -2
- package/dist/docs/video-sources/cli.video.d.ts.map +0 -1
- package/dist/docs/video-sources/cli.video.js.map +0 -1
- package/dist/docs/video-sources/installation.video.d.ts +0 -2
- package/dist/docs/video-sources/installation.video.d.ts.map +0 -1
- package/dist/docs/video-sources/installation.video.js.map +0 -1
- package/dist/docs/video-sources/landing.video.d.ts +0 -2
- package/dist/docs/video-sources/landing.video.d.ts.map +0 -1
- package/dist/docs/video-sources/landing.video.js.map +0 -1
package/dist/src/asset.js
CHANGED
|
@@ -1,19 +1,55 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { overlayRect } from './overlayRect.js';
|
|
2
|
+
import { access, readFile } from 'fs/promises';
|
|
2
3
|
import { dirname, resolve } from 'path';
|
|
3
|
-
import {
|
|
4
|
+
import { resolveRecordingTimingDuration } from './runtimeMode.js';
|
|
5
|
+
import { resolveOverlayCss, DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR, DEFAULT_ANIMATION_FPS, } from './htmlRasterizer.js';
|
|
6
|
+
export { setOverlayCss } from './htmlRasterizer.js';
|
|
7
|
+
import { getScreenCIRuntimeContext, getRuntimeAssetRecorder, getRuntimePage, getRuntimeRecordingDir, setRuntimeAssetRecorder, resetAssetRuntimeState, } from './runtimeContext.js';
|
|
8
|
+
/**
|
|
9
|
+
* Upper bound for an audio level (linear gain). `4` is +12 dB, plenty of
|
|
10
|
+
* headroom for a boost while guarding against accidental extreme distortion.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_AUDIO_LEVEL = 4;
|
|
4
13
|
const registeredAssetPaths = new Set();
|
|
14
|
+
// One frame at 24fps — ensures at least one rendered frame captures each asset
|
|
15
|
+
// state when an overlay is started and ended back-to-back.
|
|
16
|
+
const ONE_FRAME_MS = 1000 / 24;
|
|
17
|
+
// Blocking sleep — spin until the elapsed time has passed. Injectable for tests.
|
|
18
|
+
let sleepFn = (ms) => {
|
|
19
|
+
const end = performance.now() + ms;
|
|
20
|
+
while (performance.now() < end) {
|
|
21
|
+
/* spin */
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export function setAssetSleepFn(fn) {
|
|
25
|
+
sleepFn = fn;
|
|
26
|
+
}
|
|
27
|
+
function sleepForAssetFrameGap() {
|
|
28
|
+
const durationMs = resolveRecordingTimingDuration(2 * ONE_FRAME_MS);
|
|
29
|
+
if (durationMs <= 0)
|
|
30
|
+
return;
|
|
31
|
+
sleepFn(durationMs);
|
|
32
|
+
}
|
|
5
33
|
export function setActiveAssetRecorder(recorder) {
|
|
6
34
|
setRuntimeAssetRecorder(recorder);
|
|
35
|
+
resetAssetRuntimeState();
|
|
7
36
|
}
|
|
8
37
|
export function resetRegisteredAssetPaths() {
|
|
9
38
|
registeredAssetPaths.clear();
|
|
10
39
|
}
|
|
40
|
+
export function resetAssetChain() {
|
|
41
|
+
resetAssetRuntimeState();
|
|
42
|
+
}
|
|
11
43
|
export async function validateRegisteredAssetPaths(testFilePath) {
|
|
12
44
|
for (const assetPath of registeredAssetPaths) {
|
|
13
45
|
await validateAssetPath(assetPath, testFilePath);
|
|
14
46
|
}
|
|
15
47
|
}
|
|
16
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Resolves an overlay file path to an existing absolute path, trying it as-is
|
|
50
|
+
* and relative to the test file. Throws when no candidate exists.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveExistingAssetPath(assetPath, testFilePath) {
|
|
17
53
|
const candidates = [assetPath];
|
|
18
54
|
if (testFilePath !== null) {
|
|
19
55
|
candidates.push(resolve(dirname(testFilePath), assetPath));
|
|
@@ -21,7 +57,7 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
21
57
|
for (const candidate of candidates) {
|
|
22
58
|
try {
|
|
23
59
|
await access(candidate);
|
|
24
|
-
return;
|
|
60
|
+
return candidate;
|
|
25
61
|
}
|
|
26
62
|
catch {
|
|
27
63
|
// try next candidate
|
|
@@ -29,46 +65,296 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
29
65
|
}
|
|
30
66
|
throw new Error(`Asset file not found: ${assetPath}`);
|
|
31
67
|
}
|
|
68
|
+
async function validateAssetPath(assetPath, testFilePath) {
|
|
69
|
+
await resolveExistingAssetPath(assetPath, testFilePath);
|
|
70
|
+
}
|
|
32
71
|
/**
|
|
33
|
-
* Creates a set of typed
|
|
72
|
+
* Creates a set of typed overlay controllers, one per key in the map. Each value
|
|
73
|
+
* is a file path string, a React element, or an {@link OverlayConfig} object.
|
|
74
|
+
* A config can draw its content from exactly one source: a file `path`, a React
|
|
75
|
+
* `element`, or an inline `html` fragment.
|
|
34
76
|
*
|
|
35
|
-
* Calling a controller
|
|
36
|
-
* `.svg`/`.png`
|
|
37
|
-
*
|
|
77
|
+
* Calling a controller shows the overlay in the recording timeline. Image
|
|
78
|
+
* (`.svg`/`.png`), HTML, and React overlays need a `durationMs` (in the config
|
|
79
|
+
* or passed to the blocking call) unless driven with `start()`/`end()`; `.mp4`
|
|
80
|
+
* overlays use their natural duration and default `audio` to `1` (natural level).
|
|
81
|
+
*
|
|
82
|
+
* Placement defaults to the full recording area (`relativeTo: 'recording'`);
|
|
83
|
+
* override any field independently. Coordinates are CSS px of the recording
|
|
84
|
+
* viewport.
|
|
38
85
|
*
|
|
39
86
|
* @example
|
|
40
|
-
* ```
|
|
41
|
-
* const
|
|
42
|
-
*
|
|
43
|
-
*
|
|
87
|
+
* ```tsx
|
|
88
|
+
* const overlays = createOverlays({
|
|
89
|
+
* hint: 'callout.html', // HTML file
|
|
90
|
+
* badge: <Badge label="New" />, // React element
|
|
91
|
+
* note: { html: '<div class="note">Tip</div>', x: 1340, y: 110, width: 380 },
|
|
92
|
+
* logo: { path: 'logo.png', x: 96, y: 96, width: 240 },
|
|
93
|
+
* intro: { path: 'intro.mp4', fullScreen: true },
|
|
44
94
|
* })
|
|
45
95
|
*
|
|
46
96
|
* video('Product demo', async ({ page }) => {
|
|
47
|
-
* await
|
|
97
|
+
* await overlays.intro()
|
|
48
98
|
* await page.goto('/dashboard')
|
|
49
|
-
* await
|
|
99
|
+
* await overlays.logo(1200)
|
|
50
100
|
* })
|
|
51
101
|
* ```
|
|
102
|
+
*
|
|
103
|
+
* A value can also be an {@link OverlayConfigFactory} `(props) => OverlayConfig`,
|
|
104
|
+
* making the overlay programmatic. Calling `overlays.name(props)` builds and
|
|
105
|
+
* returns a controller you then drive with `(durationMs)`, `start()`, or
|
|
106
|
+
* `end()`. The factory runs (and its config is validated) on each call, so
|
|
107
|
+
* content and placement can depend on runtime values.
|
|
52
108
|
*/
|
|
53
|
-
export function
|
|
109
|
+
export function createOverlays(overlays) {
|
|
54
110
|
const result = {};
|
|
55
|
-
for (const name in
|
|
56
|
-
|
|
57
|
-
validateAssetConfig(name, config);
|
|
58
|
-
registeredAssetPaths.add(config.path);
|
|
59
|
-
result[name] = createAssetController(name, config);
|
|
111
|
+
for (const name in overlays) {
|
|
112
|
+
result[name] = buildOverlayController(name, overlays[name]);
|
|
60
113
|
}
|
|
61
114
|
return result;
|
|
62
115
|
}
|
|
116
|
+
function isReactElementLike(value) {
|
|
117
|
+
if (typeof value !== 'object' || value === null)
|
|
118
|
+
return false;
|
|
119
|
+
// A real React element carries a symbol `$$typeof`.
|
|
120
|
+
if (typeof value.$$typeof === 'symbol')
|
|
121
|
+
return true;
|
|
122
|
+
// Playwright transpiles JSX in `.screenci.tsx` files with its own automatic
|
|
123
|
+
// runtime, which produces `{ __pw_type: 'jsx', ... }` nodes instead.
|
|
124
|
+
return value.__pw_type === 'jsx';
|
|
125
|
+
}
|
|
126
|
+
function buildOverlayController(name, input) {
|
|
127
|
+
// A factory is the only callable input. React elements and config objects are
|
|
128
|
+
// plain objects, so this branch never captures them. The config (and its
|
|
129
|
+
// validation) is built per call so props can vary placement and content.
|
|
130
|
+
if (typeof input === 'function') {
|
|
131
|
+
return (props) => buildOverlayFromConfig(name, input(props));
|
|
132
|
+
}
|
|
133
|
+
if (typeof input === 'string') {
|
|
134
|
+
return buildOverlayFromConfig(name, { path: input });
|
|
135
|
+
}
|
|
136
|
+
if (isReactElementLike(input)) {
|
|
137
|
+
return buildOverlayFromConfig(name, { element: input });
|
|
138
|
+
}
|
|
139
|
+
return buildOverlayFromConfig(name, input);
|
|
140
|
+
}
|
|
141
|
+
function buildOverlayFromConfig(name, config) {
|
|
142
|
+
const hasPath = config.path !== undefined;
|
|
143
|
+
const hasElement = config.element !== undefined;
|
|
144
|
+
const hasHtml = config.html !== undefined;
|
|
145
|
+
const sourceCount = Number(hasPath) + Number(hasElement) + Number(hasHtml);
|
|
146
|
+
if (sourceCount > 1) {
|
|
147
|
+
throw new Error(`[screenci] Overlay "${name}" must provide only one of "path", "element", or "html".`);
|
|
148
|
+
}
|
|
149
|
+
if (sourceCount === 0) {
|
|
150
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a "path", an "element", or inline "html".`);
|
|
151
|
+
}
|
|
152
|
+
if (hasHtml) {
|
|
153
|
+
validateInlineHtmlFragment(name, config.html);
|
|
154
|
+
}
|
|
155
|
+
const placementSource = resolvePlacementSource(name, config, {
|
|
156
|
+
hasElement,
|
|
157
|
+
hasHtml,
|
|
158
|
+
hasPath,
|
|
159
|
+
});
|
|
160
|
+
const fullScreen = config.fullScreen ?? false;
|
|
161
|
+
const animate = config.animate === true;
|
|
162
|
+
if (config.fps !== undefined && !animate) {
|
|
163
|
+
throw new Error(`[screenci] Overlay "${name}" sets "fps" without "animate: true". "fps" only applies to animated overlays.`);
|
|
164
|
+
}
|
|
165
|
+
if (animate && config.fps !== undefined) {
|
|
166
|
+
if (!Number.isFinite(config.fps) || config.fps <= 0) {
|
|
167
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a finite "fps" greater than 0. Received: ${String(config.fps)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (config.capturePadding !== undefined &&
|
|
171
|
+
(!Number.isFinite(config.capturePadding) || config.capturePadding < 0)) {
|
|
172
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a finite "capturePadding" greater than or equal to 0. Received: ${String(config.capturePadding)}`);
|
|
173
|
+
}
|
|
174
|
+
const renderOpts = {
|
|
175
|
+
...(config.css !== undefined && { css: config.css }),
|
|
176
|
+
...(config.capturePadding !== undefined && {
|
|
177
|
+
capturePadding: config.capturePadding,
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
// speed/time re-time a moving picture, so they only apply to .mp4 video
|
|
181
|
+
// overlays. Images, HTML, React, and animated overlays have no source rate.
|
|
182
|
+
if ((config.speed !== undefined || config.time !== undefined) &&
|
|
183
|
+
(hasElement || hasHtml || getAssetExtension(config.path ?? '') !== '.mp4')) {
|
|
184
|
+
throw new Error(`[screenci] Overlay "${name}" only supports speed/time on .mp4 video overlays.`);
|
|
185
|
+
}
|
|
186
|
+
// React element or inline HTML fragment: rendered to markup lazily at
|
|
187
|
+
// recording time. Both follow the identical placement/animate/css/padding
|
|
188
|
+
// path; only how the markup is produced differs.
|
|
189
|
+
if (hasElement || hasHtml) {
|
|
190
|
+
const getMarkup = hasElement
|
|
191
|
+
? () => renderElementToMarkup(name, config.element)
|
|
192
|
+
: () => Promise.resolve(config.html);
|
|
193
|
+
if (animate) {
|
|
194
|
+
return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
195
|
+
}
|
|
196
|
+
return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
|
|
197
|
+
}
|
|
198
|
+
const path = config.path;
|
|
199
|
+
const extension = getAssetExtension(path);
|
|
200
|
+
// Image/video file overlays never use `over` (rejected in
|
|
201
|
+
// resolvePlacementSource), so the source is always a concrete placement for
|
|
202
|
+
// them. The `.html` file branch supports `over`, so it keeps the source.
|
|
203
|
+
const placement = placementSource.kind === 'fixed'
|
|
204
|
+
? placementSource.placement
|
|
205
|
+
: resolveOverlayPlacement(name, config);
|
|
206
|
+
if (animate && extension !== '.html') {
|
|
207
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for HTML files and React elements.`);
|
|
208
|
+
}
|
|
209
|
+
if ((config.css !== undefined || config.capturePadding !== undefined) &&
|
|
210
|
+
extension !== '.html') {
|
|
211
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "css" or "capturePadding": they are only supported for HTML files and React elements.`);
|
|
212
|
+
}
|
|
213
|
+
// HTML file: read + rasterize to a transparent PNG (or an animated clip).
|
|
214
|
+
if (extension === '.html') {
|
|
215
|
+
registeredAssetPaths.add(path);
|
|
216
|
+
const getMarkup = () => readHtmlOverlayFile(path);
|
|
217
|
+
if (animate) {
|
|
218
|
+
return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
219
|
+
}
|
|
220
|
+
return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
|
|
221
|
+
}
|
|
222
|
+
// File-backed image / video overlays.
|
|
223
|
+
if (extension === '.svg' || extension === '.png') {
|
|
224
|
+
if (config.audio !== undefined) {
|
|
225
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide audio. Use durationMs instead.`);
|
|
226
|
+
}
|
|
227
|
+
if (config.durationMs !== undefined) {
|
|
228
|
+
validateDurationMs(name, path, config.durationMs);
|
|
229
|
+
}
|
|
230
|
+
registeredAssetPaths.add(path);
|
|
231
|
+
return createFileOverlayController(name, {
|
|
232
|
+
kind: 'image',
|
|
233
|
+
path,
|
|
234
|
+
...(placement !== undefined && { placement }),
|
|
235
|
+
fullScreen,
|
|
236
|
+
...(config.durationMs !== undefined && { durationMs: config.durationMs }),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (extension === '.mp4') {
|
|
240
|
+
if (config.durationMs !== undefined) {
|
|
241
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide durationMs. Its natural media duration is used instead.`);
|
|
242
|
+
}
|
|
243
|
+
if (config.audio !== undefined &&
|
|
244
|
+
(!Number.isFinite(config.audio) ||
|
|
245
|
+
config.audio < 0 ||
|
|
246
|
+
config.audio > MAX_AUDIO_LEVEL)) {
|
|
247
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite audio value between 0 and ${MAX_AUDIO_LEVEL} for .mp4 overlays. 1 is the natural level, 0 is silent, and values above 1 boost it.`);
|
|
248
|
+
}
|
|
249
|
+
validateSpeedTime(`Overlay "${name}" (${path})`, config.speed, config.time);
|
|
250
|
+
registeredAssetPaths.add(path);
|
|
251
|
+
return createFileOverlayController(name, {
|
|
252
|
+
kind: 'video',
|
|
253
|
+
path,
|
|
254
|
+
...(placement !== undefined && { placement }),
|
|
255
|
+
fullScreen,
|
|
256
|
+
...(config.audio !== undefined && { audio: config.audio }),
|
|
257
|
+
...(config.speed !== undefined && { speed: config.speed }),
|
|
258
|
+
...(config.time !== undefined && { time: config.time }),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Validates an inline `html` overlay fragment. It must be non-empty and must
|
|
265
|
+
* not contain document-level tags (`<!doctype>`, `<html>`, `<head>`, `<body>`):
|
|
266
|
+
* screenci wraps the markup in its own document before rasterizing, so a full
|
|
267
|
+
* document here would nest documents and break the capture. Mirrors the
|
|
268
|
+
* fragment contract of a React `element`.
|
|
269
|
+
*/
|
|
270
|
+
function validateInlineHtmlFragment(name, html) {
|
|
271
|
+
if (html.trim().length === 0) {
|
|
272
|
+
throw new Error(`[screenci] Overlay "${name}" inline "html" must not be empty.`);
|
|
273
|
+
}
|
|
274
|
+
const lower = html.toLowerCase();
|
|
275
|
+
const forbidden = [
|
|
276
|
+
{ token: '<!doctype', label: '<!doctype>' },
|
|
277
|
+
{ token: '<html', label: '<html>' },
|
|
278
|
+
{ token: '<head', label: '<head>' },
|
|
279
|
+
{ token: '<body', label: '<body>' },
|
|
280
|
+
];
|
|
281
|
+
for (const { token, label } of forbidden) {
|
|
282
|
+
if (lower.includes(token)) {
|
|
283
|
+
throw new Error(`[screenci] Overlay "${name}" inline "html" must be a fragment, not a full HTML document. Remove the ${label} tag; screenci wraps the markup in a document for you.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function renderElementToMarkup(name, element) {
|
|
288
|
+
let reactDomServer;
|
|
289
|
+
let react;
|
|
290
|
+
try {
|
|
291
|
+
reactDomServer = (await import('react-dom/server'));
|
|
292
|
+
react = (await import('react'));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new Error(`[screenci] Overlay "${name}" is a React element, which requires "react" and "react-dom" to be installed. Run: npm i react react-dom (plus @types/react @types/react-dom for TypeScript). Re-run "screenci init" and answer yes to React overlay support to scaffold this.`);
|
|
296
|
+
}
|
|
297
|
+
// Playwright's JSX runtime produces `__pw_type` nodes rather than real React
|
|
298
|
+
// elements; convert them (invoking function components, whose bodies are also
|
|
299
|
+
// pw-jsx) before handing the tree to react-dom.
|
|
300
|
+
const renderable = isPwJsxNode(element)
|
|
301
|
+
? pwJsxToReactNode(element, react)
|
|
302
|
+
: element;
|
|
303
|
+
return reactDomServer.renderToStaticMarkup(renderable);
|
|
304
|
+
}
|
|
305
|
+
function isPwJsxNode(value) {
|
|
306
|
+
return (typeof value === 'object' &&
|
|
307
|
+
value !== null &&
|
|
308
|
+
value.__pw_type === 'jsx');
|
|
309
|
+
}
|
|
310
|
+
function isPwFragment(type) {
|
|
311
|
+
return (typeof type === 'object' &&
|
|
312
|
+
type !== null &&
|
|
313
|
+
type.__pw_jsx_fragment === true);
|
|
314
|
+
}
|
|
315
|
+
function pwChildrenToArray(children) {
|
|
316
|
+
if (children === undefined || children === null)
|
|
317
|
+
return [];
|
|
318
|
+
return Array.isArray(children) ? children : [children];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Converts a Playwright JSX node tree into real React nodes. Function components
|
|
322
|
+
* are invoked (their bodies are pw-jsx too) and their output converted, so the
|
|
323
|
+
* result is a host-element/primitive tree that react-dom can render statically.
|
|
324
|
+
*/
|
|
325
|
+
function pwJsxToReactNode(node, react) {
|
|
326
|
+
const createElement = react.createElement;
|
|
327
|
+
if (Array.isArray(node)) {
|
|
328
|
+
return node.map((child) => pwJsxToReactNode(child, react));
|
|
329
|
+
}
|
|
330
|
+
if (!isPwJsxNode(node))
|
|
331
|
+
return node;
|
|
332
|
+
const { type, props } = node;
|
|
333
|
+
if (isPwFragment(type)) {
|
|
334
|
+
const kids = pwChildrenToArray(props?.children).map((c) => pwJsxToReactNode(c, react));
|
|
335
|
+
return createElement(react.Fragment, null, ...kids);
|
|
336
|
+
}
|
|
337
|
+
if (typeof type === 'function') {
|
|
338
|
+
return pwJsxToReactNode(type(props ?? {}), react);
|
|
339
|
+
}
|
|
340
|
+
const { children, ...rest } = props ?? {};
|
|
341
|
+
const kids = pwChildrenToArray(children).map((c) => pwJsxToReactNode(c, react));
|
|
342
|
+
return createElement(type, rest, ...kids);
|
|
343
|
+
}
|
|
344
|
+
async function readHtmlOverlayFile(path) {
|
|
345
|
+
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
346
|
+
const resolved = await resolveExistingAssetPath(path, testFilePath);
|
|
347
|
+
return readFile(resolved, 'utf-8');
|
|
348
|
+
}
|
|
63
349
|
/**
|
|
64
|
-
* Creates typed
|
|
350
|
+
* Creates typed overlay controllers whose files and display options are
|
|
65
351
|
* configured on the ScreenCI Studio page instead of in code. Business tier
|
|
66
352
|
* only.
|
|
67
353
|
*
|
|
68
|
-
* Each key becomes a callable
|
|
69
|
-
* behavior as {@link
|
|
70
|
-
* `.mp4`),
|
|
71
|
-
* from Studio.
|
|
354
|
+
* Each key becomes a callable overlay controller with the same timeline
|
|
355
|
+
* behavior as {@link createOverlays} controllers, including `start()`/`end()`.
|
|
356
|
+
* The file (`.svg`, `.png`, or `.mp4`), placement, image duration, and video
|
|
357
|
+
* audio level all come from Studio.
|
|
72
358
|
*
|
|
73
359
|
* On the first upload of a studio-mode video, rendering is held until the
|
|
74
360
|
* video is configured in Studio (the CLI prints a direct link). Later uploads
|
|
@@ -76,20 +362,20 @@ export function createAssets(assetsMap) {
|
|
|
76
362
|
*
|
|
77
363
|
* @example
|
|
78
364
|
* ```ts
|
|
79
|
-
* const
|
|
365
|
+
* const overlays = createStudioOverlays('intro', 'logo')
|
|
80
366
|
*
|
|
81
367
|
* video('Product demo', async ({ page }) => {
|
|
82
|
-
* await
|
|
368
|
+
* await overlays.intro()
|
|
83
369
|
* await page.goto('/dashboard')
|
|
84
|
-
* await
|
|
370
|
+
* await overlays.logo()
|
|
85
371
|
* })
|
|
86
372
|
* ```
|
|
87
373
|
*/
|
|
88
|
-
export function
|
|
374
|
+
export function createStudioOverlays(...keys) {
|
|
89
375
|
const seen = new Set();
|
|
90
376
|
for (const key of keys) {
|
|
91
377
|
if (seen.has(key)) {
|
|
92
|
-
throw new Error(`Duplicate
|
|
378
|
+
throw new Error(`Duplicate overlay key "${key}" passed to createStudioOverlays. Overlay keys must be unique.`);
|
|
93
379
|
}
|
|
94
380
|
seen.add(key);
|
|
95
381
|
}
|
|
@@ -99,80 +385,435 @@ export function createStudioAssets(...keys) {
|
|
|
99
385
|
}
|
|
100
386
|
return result;
|
|
101
387
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
388
|
+
function createActiveAssetRun(startedWithExplicitStart) {
|
|
389
|
+
let resolve;
|
|
390
|
+
const finished = new Promise((resolveFn) => {
|
|
391
|
+
resolve = resolveFn;
|
|
392
|
+
});
|
|
393
|
+
return { finished, resolveFinished: resolve, startedWithExplicitStart };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Ends a single live overlay identified by name, emitting its `assetEnd`,
|
|
397
|
+
* holding a frame, and clearing it from the active map. Overlays may overlap,
|
|
398
|
+
* so ending one never touches the others.
|
|
399
|
+
*/
|
|
400
|
+
function endLiveAsset(name, reason) {
|
|
401
|
+
const context = getScreenCIRuntimeContext();
|
|
402
|
+
const run = context.asset.activeRuns.get(name);
|
|
403
|
+
if (run === undefined)
|
|
404
|
+
return;
|
|
405
|
+
getRuntimeAssetRecorder().addAssetEnd(name, reason);
|
|
406
|
+
sleepForAssetFrameGap();
|
|
407
|
+
context.asset.activeRuns.delete(name);
|
|
408
|
+
run.resolveFinished();
|
|
409
|
+
}
|
|
410
|
+
function createAssetControllerCore(name, validate, emitStart,
|
|
411
|
+
/**
|
|
412
|
+
* Optional async step run after {@link validate} and before {@link emitStart},
|
|
413
|
+
* receiving the resolved start mode. Used by animated overlays to rasterize
|
|
414
|
+
* the clip once the capture length (mode duration) is known.
|
|
415
|
+
*/
|
|
416
|
+
prepare) {
|
|
417
|
+
const start = async (startedWithExplicitStart = true) => {
|
|
418
|
+
await validate();
|
|
419
|
+
await prepare?.({ type: 'live' });
|
|
420
|
+
const recorder = getRuntimeAssetRecorder();
|
|
421
|
+
const context = getScreenCIRuntimeContext();
|
|
422
|
+
if (context.asset.activeRuns.has(name)) {
|
|
423
|
+
throw new Error(`[screenci] Overlay "${name}" is already started. Call end() for it before starting it again.`);
|
|
424
|
+
}
|
|
425
|
+
const run = createActiveAssetRun(startedWithExplicitStart);
|
|
426
|
+
context.asset.activeRuns.set(name, run);
|
|
427
|
+
emitStart(recorder, { type: 'live' });
|
|
107
428
|
};
|
|
429
|
+
const end = async () => {
|
|
430
|
+
const context = getScreenCIRuntimeContext();
|
|
431
|
+
const run = context.asset.activeRuns.get(name);
|
|
432
|
+
if (run === undefined) {
|
|
433
|
+
throw new Error(`Cannot call end() for overlay "${name}" because it is not a started overlay`);
|
|
434
|
+
}
|
|
435
|
+
endLiveAsset(name, 'wait');
|
|
436
|
+
await run.finished;
|
|
437
|
+
};
|
|
438
|
+
const controller = (async (durationMs) => {
|
|
439
|
+
// A blocking overlay holds a frozen frame for a fixed duration. It never
|
|
440
|
+
// registers a live run and never ends overlays that are already live, so it
|
|
441
|
+
// can run while other overlays stay composited across the frozen frame.
|
|
442
|
+
const mode = {
|
|
443
|
+
type: 'blocking',
|
|
444
|
+
...(durationMs !== undefined && { durationMs }),
|
|
445
|
+
};
|
|
446
|
+
await validate();
|
|
447
|
+
await prepare?.(mode);
|
|
448
|
+
const recorder = getRuntimeAssetRecorder();
|
|
449
|
+
emitStart(recorder, mode);
|
|
450
|
+
});
|
|
451
|
+
controller.start = () => start(true);
|
|
452
|
+
controller.end = end;
|
|
453
|
+
return controller;
|
|
108
454
|
}
|
|
109
|
-
function
|
|
110
|
-
return
|
|
455
|
+
function createStudioAssetController(name) {
|
|
456
|
+
return createAssetControllerCore(name, () => Promise.resolve(), (recorder) => recorder.addStudioAssetStart(name));
|
|
457
|
+
}
|
|
458
|
+
function createFileOverlayController(name, resolved) {
|
|
459
|
+
return createAssetControllerCore(name, async () => {
|
|
111
460
|
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
112
461
|
if (testFilePath !== null) {
|
|
113
|
-
await validateAssetPath(
|
|
462
|
+
await validateAssetPath(resolved.path, testFilePath);
|
|
114
463
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
464
|
+
}, (recorder, mode) => {
|
|
465
|
+
recorder.addAssetStart(name, toRecordedFileStart(name, resolved, mode));
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* An overlay rendered to a transparent PNG at recording time, from either an
|
|
470
|
+
* HTML file or a React element. `getMarkup` produces the HTML to rasterize.
|
|
471
|
+
*/
|
|
472
|
+
function createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, durationMs, renderOpts = {}) {
|
|
473
|
+
// The markup and placement are resolved during the test (cheap:
|
|
474
|
+
// renderToStaticMarkup / a string, plus a boundingBox read for `over`), but
|
|
475
|
+
// rasterization (a browser screenshot) is deferred to after the test so
|
|
476
|
+
// identical overlays render once. See overlayFlush.ts.
|
|
477
|
+
let resolvedHtml;
|
|
478
|
+
let resolvedPlacement;
|
|
479
|
+
let skipped = false;
|
|
480
|
+
return createAssetControllerCore(name, () => Promise.resolve(), (recorder, mode) => {
|
|
481
|
+
// `resolvedHtml` is set only once the overlay has resolved, so it is the
|
|
482
|
+
// "is resolved" signal. `resolvedPlacement` may legitimately stay
|
|
483
|
+
// undefined (a fill-the-recording overlay emits no placement).
|
|
484
|
+
if (skipped || resolvedHtml === undefined)
|
|
485
|
+
return;
|
|
486
|
+
let durationMsForEvent;
|
|
487
|
+
if (mode.type === 'blocking') {
|
|
488
|
+
durationMsForEvent = mode.durationMs ?? durationMs;
|
|
489
|
+
if (durationMsForEvent === undefined) {
|
|
490
|
+
throw new Error(`[screenci] Overlay "${name}" needs a duration: pass one to the call (overlays.${name}(1000)), set durationMs in the config, or drive it with .start()/.end().`);
|
|
491
|
+
}
|
|
492
|
+
validateDurationMs(name, `overlay "${name}"`, durationMsForEvent);
|
|
493
|
+
}
|
|
494
|
+
recorder.addPendingAssetStart(name, {
|
|
495
|
+
kind: 'image',
|
|
496
|
+
...(durationMsForEvent !== undefined && {
|
|
497
|
+
durationMs: durationMsForEvent,
|
|
498
|
+
}),
|
|
499
|
+
fullScreen,
|
|
500
|
+
...(resolvedPlacement !== undefined && {
|
|
501
|
+
placement: resolvedPlacement,
|
|
502
|
+
}),
|
|
503
|
+
request: {
|
|
504
|
+
kind: 'image',
|
|
505
|
+
name,
|
|
506
|
+
html: resolvedHtml,
|
|
507
|
+
css: resolveOverlayCss(renderOpts.css),
|
|
508
|
+
capturePadding: renderOpts.capturePadding ?? 0,
|
|
509
|
+
deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}, async () => {
|
|
513
|
+
if (resolvedHtml !== undefined || skipped)
|
|
514
|
+
return;
|
|
515
|
+
// Resolving an overlay needs an active recording page and output dir.
|
|
516
|
+
// Outside recording (e.g. plain test runs) there is nothing to upload, so
|
|
517
|
+
// the controller is a no-op, mirroring the no-op recorder.
|
|
518
|
+
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
519
|
+
skipped = true;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const { placement, sizePx } = await resolvePlacement(placementSource);
|
|
523
|
+
resolvedPlacement = placement;
|
|
524
|
+
const markup = await getMarkup();
|
|
525
|
+
resolvedHtml =
|
|
526
|
+
sizePx !== undefined ? sizeWrapMarkup(markup, sizePx) : markup;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* An animated overlay rendered to a transparent clip, from either an HTML file
|
|
531
|
+
* or a React element. The capture length is resolved from the call argument or
|
|
532
|
+
* config `durationMs`; `start()`/`end()` requires a config `durationMs` (the
|
|
533
|
+
* capture length is otherwise unknown).
|
|
534
|
+
*
|
|
535
|
+
* Markup and duration are captured during the test; the clip itself is encoded
|
|
536
|
+
* after the test (see overlayFlush.ts). Because no rasterization happens during
|
|
537
|
+
* the recording, the old "hide block" that cut capture wall-clock from the
|
|
538
|
+
* timeline is no longer needed.
|
|
539
|
+
*/
|
|
540
|
+
function createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, fps, configDurationMs, renderOpts = {}) {
|
|
541
|
+
let resolved;
|
|
542
|
+
let skipped = false;
|
|
543
|
+
const resolveDurationMs = (mode) => {
|
|
544
|
+
if (mode.type === 'blocking') {
|
|
545
|
+
const durationMs = mode.durationMs ?? configDurationMs;
|
|
546
|
+
if (durationMs === undefined) {
|
|
547
|
+
throw new Error(`[screenci] Animated overlay "${name}" needs a duration: pass one to the call (overlays.${name}(1000)), set durationMs in the config, or drive it with .start()/.end() (with durationMs in the config).`);
|
|
548
|
+
}
|
|
549
|
+
validateDurationMs(name, `overlay "${name}"`, durationMs);
|
|
550
|
+
return durationMs;
|
|
551
|
+
}
|
|
552
|
+
if (configDurationMs === undefined) {
|
|
553
|
+
throw new Error(`[screenci] Animated overlay "${name}" driven with .start()/.end() needs durationMs in its config (the capture length is otherwise unknown).`);
|
|
554
|
+
}
|
|
555
|
+
validateDurationMs(name, `overlay "${name}"`, configDurationMs);
|
|
556
|
+
return configDurationMs;
|
|
118
557
|
};
|
|
558
|
+
return createAssetControllerCore(name, () => Promise.resolve(), (recorder, mode) => {
|
|
559
|
+
if (skipped || resolved === undefined)
|
|
560
|
+
return;
|
|
561
|
+
recorder.addPendingAssetStart(name, {
|
|
562
|
+
kind: 'animation',
|
|
563
|
+
...(mode.type === 'blocking' && { durationMs: resolved.durationMs }),
|
|
564
|
+
fullScreen,
|
|
565
|
+
...(resolved.placement !== undefined && {
|
|
566
|
+
placement: resolved.placement,
|
|
567
|
+
}),
|
|
568
|
+
request: {
|
|
569
|
+
kind: 'animation',
|
|
570
|
+
name,
|
|
571
|
+
html: resolved.html,
|
|
572
|
+
css: resolveOverlayCss(renderOpts.css),
|
|
573
|
+
capturePadding: renderOpts.capturePadding ?? 0,
|
|
574
|
+
deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
|
|
575
|
+
fps: fps ?? DEFAULT_ANIMATION_FPS,
|
|
576
|
+
durationMs: resolved.durationMs,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
}, async (mode) => {
|
|
580
|
+
// Outside recording there is nothing to upload, so the controller is a
|
|
581
|
+
// no-op, mirroring the no-op recorder and the static rendered controller.
|
|
582
|
+
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
583
|
+
skipped = true;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const durationMs = resolveDurationMs(mode);
|
|
587
|
+
const { placement, sizePx } = await resolvePlacement(placementSource);
|
|
588
|
+
const markup = await getMarkup();
|
|
589
|
+
resolved = {
|
|
590
|
+
html: sizePx !== undefined ? sizeWrapMarkup(markup, sizePx) : markup,
|
|
591
|
+
durationMs,
|
|
592
|
+
...(placement !== undefined && { placement }),
|
|
593
|
+
};
|
|
594
|
+
});
|
|
119
595
|
}
|
|
120
596
|
function getAssetExtension(path) {
|
|
121
597
|
const dotIndex = path.lastIndexOf('.');
|
|
122
598
|
if (dotIndex === -1)
|
|
123
599
|
return null;
|
|
124
600
|
const extension = path.slice(dotIndex).toLowerCase();
|
|
125
|
-
if (extension === '.
|
|
601
|
+
if (extension === '.html' ||
|
|
602
|
+
extension === '.svg' ||
|
|
603
|
+
extension === '.png' ||
|
|
604
|
+
extension === '.mp4') {
|
|
126
605
|
return extension;
|
|
127
606
|
}
|
|
128
607
|
return null;
|
|
129
608
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
609
|
+
function isFiniteNonNegative(value) {
|
|
610
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
611
|
+
}
|
|
612
|
+
function validateDurationMs(name, path, durationMs) {
|
|
613
|
+
if (!isFiniteNonNegative(durationMs)) {
|
|
614
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite durationMs greater than or equal to 0.`);
|
|
134
615
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Upper bound for a playback-speed multiplier. `16` is already an extreme rate;
|
|
619
|
+
* the cap guards against accidental runaway values producing degenerate output.
|
|
620
|
+
*/
|
|
621
|
+
export const MAX_SPEED = 16;
|
|
622
|
+
/**
|
|
623
|
+
* Validates a `speed`/`time` pair shared by `.mp4` overlays and audio tracks.
|
|
624
|
+
* `speed` must be a finite multiplier in `(0, MAX_SPEED]`; `time` must be a
|
|
625
|
+
* finite duration greater than 0 (ms). They are mutually exclusive.
|
|
626
|
+
*/
|
|
627
|
+
export function validateSpeedTime(label, speed, time) {
|
|
628
|
+
if (speed !== undefined && time !== undefined) {
|
|
629
|
+
throw new Error(`[screenci] ${label} must set only one of speed or time, not both.`);
|
|
630
|
+
}
|
|
631
|
+
if (speed !== undefined &&
|
|
632
|
+
(!Number.isFinite(speed) || speed <= 0 || speed > MAX_SPEED)) {
|
|
633
|
+
throw new Error(`[screenci] ${label} must provide a finite speed greater than 0 and at most ${MAX_SPEED}. 2 plays twice as fast, 0.5 at half speed.`);
|
|
634
|
+
}
|
|
635
|
+
if (time !== undefined && (!Number.isFinite(time) || time <= 0)) {
|
|
636
|
+
throw new Error(`[screenci] ${label} must provide a finite time (ms) greater than 0.`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function validatePlacement(name, placement) {
|
|
640
|
+
if ('fullScreen' in placement) {
|
|
641
|
+
if (placement.fullScreen !== true) {
|
|
642
|
+
throw new Error(`[screenci] Overlay "${name}" fullScreen must be true when set.`);
|
|
144
643
|
}
|
|
145
644
|
return;
|
|
146
645
|
}
|
|
147
|
-
|
|
148
|
-
|
|
646
|
+
const relativeTo = placement.relativeTo;
|
|
647
|
+
if (relativeTo !== 'screen' && relativeTo !== 'recording') {
|
|
648
|
+
throw new Error(`[screenci] Overlay "${name}" relativeTo must be 'screen' or 'recording'.`);
|
|
649
|
+
}
|
|
650
|
+
const hasWidth = 'width' in placement && placement.width !== undefined;
|
|
651
|
+
const sizeLabel = hasWidth ? 'width' : 'height';
|
|
652
|
+
const sizeValue = hasWidth
|
|
653
|
+
? placement.width
|
|
654
|
+
: placement.height;
|
|
655
|
+
for (const [label, value] of [
|
|
656
|
+
['x', placement.x],
|
|
657
|
+
['y', placement.y],
|
|
658
|
+
]) {
|
|
659
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
660
|
+
throw new Error(`[screenci] Overlay "${name}" ${label} must be a non-negative number of CSS pixels. Received: ${String(value)}`);
|
|
661
|
+
}
|
|
149
662
|
}
|
|
150
|
-
if ('
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw new Error(`[screenci]
|
|
663
|
+
if (typeof sizeValue !== 'number' ||
|
|
664
|
+
!Number.isFinite(sizeValue) ||
|
|
665
|
+
sizeValue <= 0) {
|
|
666
|
+
throw new Error(`[screenci] Overlay "${name}" ${sizeLabel} must be a positive number of CSS pixels. Received: ${String(sizeValue)}`);
|
|
667
|
+
}
|
|
668
|
+
const aspectRatio = placement.aspectRatio;
|
|
669
|
+
if (aspectRatio !== undefined &&
|
|
670
|
+
(typeof aspectRatio !== 'number' ||
|
|
671
|
+
!Number.isFinite(aspectRatio) ||
|
|
672
|
+
aspectRatio <= 0)) {
|
|
673
|
+
throw new Error(`[screenci] Overlay "${name}" aspectRatio must be a positive number (width / height). Received: ${String(aspectRatio)}`);
|
|
154
674
|
}
|
|
155
675
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Resolves an {@link OverlayConfig}'s flat placement fields into the event-shape
|
|
678
|
+
* {@link OverlayPlacement}. Coordinates are CSS pixels in the recording viewport,
|
|
679
|
+
* with the defaults `relativeTo: 'recording'`, `x: 0`, `y: 0`. When neither
|
|
680
|
+
* `width` nor `height` is given (and no other placement field is set), it returns
|
|
681
|
+
* `undefined`: the overlay fills the recording area, which the renderer resolves
|
|
682
|
+
* since the recording size is known there.
|
|
683
|
+
*/
|
|
684
|
+
function resolveOverlayPlacement(name, config) {
|
|
685
|
+
if (config.fullScreen === true) {
|
|
686
|
+
return { fullScreen: true };
|
|
687
|
+
}
|
|
688
|
+
if (config.width !== undefined && config.height !== undefined) {
|
|
689
|
+
throw new Error(`[screenci] Overlay "${name}" must set only one of width or height (the other is derived from the aspect ratio).`);
|
|
690
|
+
}
|
|
691
|
+
const hasSize = config.width !== undefined || config.height !== undefined;
|
|
692
|
+
if (!hasSize) {
|
|
693
|
+
const positioned = config.x !== undefined ||
|
|
694
|
+
config.y !== undefined ||
|
|
695
|
+
config.relativeTo !== undefined ||
|
|
696
|
+
config.aspectRatio !== undefined;
|
|
697
|
+
if (positioned) {
|
|
698
|
+
throw new Error(`[screenci] Overlay "${name}" must set "width" or "height" (in CSS px) when positioning it. Omit all placement fields to fill the recording area, or set "fullScreen".`);
|
|
699
|
+
}
|
|
700
|
+
// Fill the recording area (resolved by the renderer, which knows its size).
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
const relativeTo = config.relativeTo ?? 'recording';
|
|
704
|
+
const x = config.x ?? 0;
|
|
705
|
+
const y = config.y ?? 0;
|
|
706
|
+
const aspectRatio = config.aspectRatio;
|
|
707
|
+
const placement = config.height !== undefined
|
|
708
|
+
? {
|
|
709
|
+
relativeTo,
|
|
710
|
+
x,
|
|
711
|
+
y,
|
|
712
|
+
height: config.height,
|
|
713
|
+
...(aspectRatio !== undefined && { aspectRatio }),
|
|
714
|
+
}
|
|
715
|
+
: {
|
|
716
|
+
relativeTo,
|
|
717
|
+
x,
|
|
718
|
+
y,
|
|
719
|
+
width: config.width,
|
|
720
|
+
...(aspectRatio !== undefined && { aspectRatio }),
|
|
165
721
|
};
|
|
722
|
+
validatePlacement(name, placement);
|
|
723
|
+
return placement;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Chooses the placement source for an overlay. With {@link OverlayConfig.over}
|
|
727
|
+
* the placement is deferred to recording time (the element box is unknown until
|
|
728
|
+
* the page runs); otherwise it is resolved from the flat config fields now.
|
|
729
|
+
*/
|
|
730
|
+
function resolvePlacementSource(name, config, flags) {
|
|
731
|
+
if (config.margin !== undefined && config.over === undefined) {
|
|
732
|
+
throw new Error(`[screenci] Overlay "${name}" sets "margin" without "over". "margin" only applies when positioning over a locator.`);
|
|
166
733
|
}
|
|
167
|
-
if (
|
|
168
|
-
|
|
734
|
+
if (config.over === undefined) {
|
|
735
|
+
return { kind: 'fixed', placement: resolveOverlayPlacement(name, config) };
|
|
736
|
+
}
|
|
737
|
+
const isRendered = flags.hasElement ||
|
|
738
|
+
flags.hasHtml ||
|
|
739
|
+
(flags.hasPath && getAssetExtension(config.path ?? '') === '.html');
|
|
740
|
+
if (!isRendered) {
|
|
741
|
+
throw new Error(`[screenci] Overlay "${name}" can only use "over" with a React element, inline "html", or an .html file (the overlay is sized to the element's box).`);
|
|
742
|
+
}
|
|
743
|
+
if (config.fullScreen === true) {
|
|
744
|
+
throw new Error(`[screenci] Overlay "${name}" cannot set both "over" and "fullScreen".`);
|
|
745
|
+
}
|
|
746
|
+
if (config.x !== undefined ||
|
|
747
|
+
config.y !== undefined ||
|
|
748
|
+
config.width !== undefined ||
|
|
749
|
+
config.height !== undefined ||
|
|
750
|
+
config.relativeTo !== undefined) {
|
|
751
|
+
throw new Error(`[screenci] Overlay "${name}" cannot combine "over" with x/y/width/height/relativeTo. The placement comes from the locator's box.`);
|
|
752
|
+
}
|
|
753
|
+
const margin = config.margin ?? 0;
|
|
754
|
+
if (!Number.isFinite(margin) || margin < 0) {
|
|
755
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a finite "margin" greater than or equal to 0. Received: ${String(config.margin)}`);
|
|
756
|
+
}
|
|
757
|
+
return { kind: 'over', over: config.over, margin };
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Resolves a {@link PlacementSource} to a concrete placement at recording time.
|
|
761
|
+
* For an `over` source it reads the locator's box (plus margin) via
|
|
762
|
+
* {@link overlayRect} and returns the element's pixel size so the markup can be
|
|
763
|
+
* sized to match, making the rasterized overlay frame the element exactly.
|
|
764
|
+
*/
|
|
765
|
+
async function resolvePlacement(source) {
|
|
766
|
+
if (source.kind === 'fixed') {
|
|
767
|
+
return { placement: source.placement };
|
|
768
|
+
}
|
|
769
|
+
const rect = await overlayRect(source.over, { margin: source.margin });
|
|
770
|
+
return {
|
|
771
|
+
placement: {
|
|
772
|
+
relativeTo: rect.relativeTo,
|
|
773
|
+
x: rect.x,
|
|
774
|
+
y: rect.y,
|
|
775
|
+
width: rect.width ?? rect.pixels.width,
|
|
776
|
+
},
|
|
777
|
+
sizePx: { width: rect.pixels.width, height: rect.pixels.height },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Wraps overlay markup in a box of the given CSS pixel size so the rasterized
|
|
782
|
+
* PNG carries the element's aspect ratio. The renderer then derives the overlay
|
|
783
|
+
* height from that aspect, landing it exactly on the element's box. The wrapped
|
|
784
|
+
* content should fill the box (for example `width:100%;height:100%`).
|
|
785
|
+
*/
|
|
786
|
+
function sizeWrapMarkup(html, size) {
|
|
787
|
+
return `<div style="width:${size.width}px;height:${size.height}px;box-sizing:border-box">${html}</div>`;
|
|
788
|
+
}
|
|
789
|
+
function toRecordedFileStart(name, resolved, mode) {
|
|
790
|
+
if (resolved.kind === 'image') {
|
|
791
|
+
let durationMs;
|
|
792
|
+
if (mode.type === 'blocking') {
|
|
793
|
+
durationMs = mode.durationMs ?? resolved.durationMs;
|
|
794
|
+
if (durationMs === undefined) {
|
|
795
|
+
throw new Error(`[screenci] Overlay "${name}" (${resolved.path}) needs a duration: pass one to the call (overlays.${name}(1000)), set durationMs in the config, or drive it with .start()/.end().`);
|
|
796
|
+
}
|
|
797
|
+
validateDurationMs(name, resolved.path, durationMs);
|
|
798
|
+
}
|
|
169
799
|
return {
|
|
170
|
-
kind: '
|
|
171
|
-
path:
|
|
172
|
-
|
|
173
|
-
fullScreen:
|
|
800
|
+
kind: 'image',
|
|
801
|
+
path: resolved.path,
|
|
802
|
+
...(durationMs !== undefined && { durationMs }),
|
|
803
|
+
fullScreen: resolved.fullScreen,
|
|
804
|
+
...(resolved.placement !== undefined && {
|
|
805
|
+
placement: resolved.placement,
|
|
806
|
+
}),
|
|
174
807
|
};
|
|
175
808
|
}
|
|
176
|
-
|
|
809
|
+
return {
|
|
810
|
+
kind: 'video',
|
|
811
|
+
path: resolved.path,
|
|
812
|
+
audio: resolved.audio ?? 1,
|
|
813
|
+
fullScreen: resolved.fullScreen,
|
|
814
|
+
...(resolved.placement !== undefined && { placement: resolved.placement }),
|
|
815
|
+
...(resolved.speed !== undefined && { speed: resolved.speed }),
|
|
816
|
+
...(resolved.time !== undefined && { time: resolved.time }),
|
|
817
|
+
};
|
|
177
818
|
}
|
|
178
819
|
//# sourceMappingURL=asset.js.map
|