screenci 0.0.68 → 0.0.69
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 +1 -1
- package/dist/cli.d.ts +3 -18
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +105 -154
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +13 -13
- package/dist/docs/manifest.js +4 -4
- package/dist/docs/manifest.js.map +1 -1
- package/dist/docs/video-sources/studio.video.d.ts +2 -0
- package/dist/docs/video-sources/studio.video.d.ts.map +1 -0
- package/dist/docs/video-sources/studio.video.js +57 -0
- package/dist/docs/video-sources/studio.video.js.map +1 -0
- 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/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +141 -53
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +533 -78
- package/dist/src/asset.js.map +1 -1
- package/dist/src/audio.d.ts +113 -0
- package/dist/src/audio.d.ts.map +1 -0
- package/dist/src/audio.js +204 -0
- package/dist/src/audio.js.map +1 -0
- package/dist/src/defaults.d.ts +7 -1
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +7 -0
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/events.d.ts +134 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +73 -2
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +110 -0
- package/dist/src/htmlRasterizer.d.ts.map +1 -0
- package/dist/src/htmlRasterizer.js +364 -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 +127 -7
- 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/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 +1 -1
- package/dist/src/recording.d.ts.map +1 -1
- package/dist/src/recordingData.d.ts +2 -2
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/runtimeContext.d.ts +38 -0
- package/dist/src/runtimeContext.d.ts.map +1 -1
- package/dist/src/runtimeContext.js +25 -0
- package/dist/src/runtimeContext.js.map +1 -1
- package/dist/src/types.d.ts +25 -7
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +34 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +120 -19
- 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 +27 -5
- package/skills/screenci/references/init.md +2 -0
- package/skills/screenci/references/record.md +6 -1
package/dist/src/asset.js
CHANGED
|
@@ -1,19 +1,55 @@
|
|
|
1
|
-
import { access } from 'fs/promises';
|
|
1
|
+
import { access, readFile } from 'fs/promises';
|
|
2
2
|
import { dirname, resolve } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { resolveRecordingTimingDuration } from './runtimeMode.js';
|
|
4
|
+
import { rasterizeHtmlOverlay, rasterizeAnimatedHtmlOverlay, } from './htmlRasterizer.js';
|
|
5
|
+
export { setOverlayCss } from './htmlRasterizer.js';
|
|
6
|
+
import { isInsideHide, isInsideTime } from './timelineBlock.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,229 @@ 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
|
+
*
|
|
75
|
+
* Calling a controller shows the overlay in the recording timeline. Image
|
|
76
|
+
* (`.svg`/`.png`), HTML, and React overlays need a `durationMs` (in the config
|
|
77
|
+
* or passed to the blocking call) unless driven with `start()`/`end()`; `.mp4`
|
|
78
|
+
* overlays use their natural duration and default `audio` to `1` (natural level).
|
|
34
79
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* natural duration and default `audio` to `1` when omitted.
|
|
80
|
+
* Placement defaults to the full recording area (`relativeTo: 'recording',
|
|
81
|
+
* x: 0, y: 0, width: 1`); override any field independently.
|
|
38
82
|
*
|
|
39
83
|
* @example
|
|
40
|
-
* ```
|
|
41
|
-
* const
|
|
42
|
-
*
|
|
43
|
-
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* const overlays = createOverlays({
|
|
86
|
+
* hint: 'callout.html', // HTML file
|
|
87
|
+
* badge: <Badge label="New" />, // React element
|
|
88
|
+
* logo: { path: 'logo.png', x: 0.05, y: 0.05, width: 0.2 },
|
|
89
|
+
* intro: { path: 'intro.mp4', fullScreen: true },
|
|
44
90
|
* })
|
|
45
91
|
*
|
|
46
92
|
* video('Product demo', async ({ page }) => {
|
|
47
|
-
* await
|
|
93
|
+
* await overlays.intro()
|
|
48
94
|
* await page.goto('/dashboard')
|
|
49
|
-
* await
|
|
95
|
+
* await overlays.logo(1200)
|
|
50
96
|
* })
|
|
51
97
|
* ```
|
|
52
98
|
*/
|
|
53
|
-
export function
|
|
99
|
+
export function createOverlays(overlays) {
|
|
54
100
|
const result = {};
|
|
55
|
-
for (const name in
|
|
56
|
-
|
|
57
|
-
validateAssetConfig(name, config);
|
|
58
|
-
registeredAssetPaths.add(config.path);
|
|
59
|
-
result[name] = createAssetController(name, config);
|
|
101
|
+
for (const name in overlays) {
|
|
102
|
+
result[name] = buildOverlayController(name, overlays[name]);
|
|
60
103
|
}
|
|
61
104
|
return result;
|
|
62
105
|
}
|
|
106
|
+
function isReactElementLike(value) {
|
|
107
|
+
if (typeof value !== 'object' || value === null)
|
|
108
|
+
return false;
|
|
109
|
+
// A real React element carries a symbol `$$typeof`.
|
|
110
|
+
if (typeof value.$$typeof === 'symbol')
|
|
111
|
+
return true;
|
|
112
|
+
// Playwright transpiles JSX in `.video.tsx` files with its own automatic
|
|
113
|
+
// runtime, which produces `{ __pw_type: 'jsx', ... }` nodes instead.
|
|
114
|
+
return value.__pw_type === 'jsx';
|
|
115
|
+
}
|
|
116
|
+
function buildOverlayController(name, input) {
|
|
117
|
+
if (typeof input === 'string') {
|
|
118
|
+
return buildOverlayFromConfig(name, { path: input });
|
|
119
|
+
}
|
|
120
|
+
if (isReactElementLike(input)) {
|
|
121
|
+
return buildOverlayFromConfig(name, { element: input });
|
|
122
|
+
}
|
|
123
|
+
return buildOverlayFromConfig(name, input);
|
|
124
|
+
}
|
|
125
|
+
function buildOverlayFromConfig(name, config) {
|
|
126
|
+
const hasPath = config.path !== undefined;
|
|
127
|
+
const hasElement = config.element !== undefined;
|
|
128
|
+
if (hasPath && hasElement) {
|
|
129
|
+
throw new Error(`[screenci] Overlay "${name}" must provide only one of "path" or "element".`);
|
|
130
|
+
}
|
|
131
|
+
if (!hasPath && !hasElement) {
|
|
132
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a "path" or an "element".`);
|
|
133
|
+
}
|
|
134
|
+
const placement = resolveOverlayPlacement(name, config);
|
|
135
|
+
const fullScreen = config.fullScreen ?? false;
|
|
136
|
+
const animate = config.animate === true;
|
|
137
|
+
if (config.fps !== undefined && !animate) {
|
|
138
|
+
throw new Error(`[screenci] Overlay "${name}" sets "fps" without "animate: true". "fps" only applies to animated overlays.`);
|
|
139
|
+
}
|
|
140
|
+
if (animate && config.fps !== undefined) {
|
|
141
|
+
if (!Number.isFinite(config.fps) || config.fps <= 0) {
|
|
142
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a finite "fps" greater than 0. Received: ${String(config.fps)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (config.capturePadding !== undefined &&
|
|
146
|
+
(!Number.isFinite(config.capturePadding) || config.capturePadding < 0)) {
|
|
147
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a finite "capturePadding" greater than or equal to 0. Received: ${String(config.capturePadding)}`);
|
|
148
|
+
}
|
|
149
|
+
const renderOpts = {
|
|
150
|
+
...(config.css !== undefined && { css: config.css }),
|
|
151
|
+
...(config.capturePadding !== undefined && {
|
|
152
|
+
capturePadding: config.capturePadding,
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
// React element: rendered to markup lazily at recording time.
|
|
156
|
+
if (hasElement) {
|
|
157
|
+
const element = config.element;
|
|
158
|
+
const getMarkup = () => renderElementToMarkup(name, element);
|
|
159
|
+
if (animate) {
|
|
160
|
+
return createAnimatedOverlayController(name, getMarkup, placement, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
161
|
+
}
|
|
162
|
+
return createRenderedOverlayController(name, getMarkup, placement, fullScreen, config.durationMs, renderOpts);
|
|
163
|
+
}
|
|
164
|
+
const path = config.path;
|
|
165
|
+
const extension = getAssetExtension(path);
|
|
166
|
+
if (animate && extension !== '.html') {
|
|
167
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for HTML files and React elements.`);
|
|
168
|
+
}
|
|
169
|
+
if ((config.css !== undefined || config.capturePadding !== undefined) &&
|
|
170
|
+
extension !== '.html') {
|
|
171
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "css" or "capturePadding": they are only supported for HTML files and React elements.`);
|
|
172
|
+
}
|
|
173
|
+
// HTML file: read + rasterize to a transparent PNG (or an animated clip).
|
|
174
|
+
if (extension === '.html') {
|
|
175
|
+
registeredAssetPaths.add(path);
|
|
176
|
+
const getMarkup = () => readHtmlOverlayFile(path);
|
|
177
|
+
if (animate) {
|
|
178
|
+
return createAnimatedOverlayController(name, getMarkup, placement, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
179
|
+
}
|
|
180
|
+
return createRenderedOverlayController(name, getMarkup, placement, fullScreen, config.durationMs, renderOpts);
|
|
181
|
+
}
|
|
182
|
+
// File-backed image / video overlays.
|
|
183
|
+
if (extension === '.svg' || extension === '.png') {
|
|
184
|
+
if (config.audio !== undefined) {
|
|
185
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide audio. Use durationMs instead.`);
|
|
186
|
+
}
|
|
187
|
+
if (config.durationMs !== undefined) {
|
|
188
|
+
validateDurationMs(name, path, config.durationMs);
|
|
189
|
+
}
|
|
190
|
+
registeredAssetPaths.add(path);
|
|
191
|
+
return createFileOverlayController(name, {
|
|
192
|
+
kind: 'image',
|
|
193
|
+
path,
|
|
194
|
+
placement,
|
|
195
|
+
fullScreen,
|
|
196
|
+
...(config.durationMs !== undefined && { durationMs: config.durationMs }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (extension === '.mp4') {
|
|
200
|
+
if (config.durationMs !== undefined) {
|
|
201
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide durationMs. Its natural media duration is used instead.`);
|
|
202
|
+
}
|
|
203
|
+
if (config.audio !== undefined &&
|
|
204
|
+
(!Number.isFinite(config.audio) ||
|
|
205
|
+
config.audio < 0 ||
|
|
206
|
+
config.audio > MAX_AUDIO_LEVEL)) {
|
|
207
|
+
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.`);
|
|
208
|
+
}
|
|
209
|
+
registeredAssetPaths.add(path);
|
|
210
|
+
return createFileOverlayController(name, {
|
|
211
|
+
kind: 'video',
|
|
212
|
+
path,
|
|
213
|
+
placement,
|
|
214
|
+
fullScreen,
|
|
215
|
+
...(config.audio !== undefined && { audio: config.audio }),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
|
|
219
|
+
}
|
|
220
|
+
async function renderElementToMarkup(name, element) {
|
|
221
|
+
let reactDomServer;
|
|
222
|
+
let react;
|
|
223
|
+
try {
|
|
224
|
+
reactDomServer = (await import('react-dom/server'));
|
|
225
|
+
react = (await import('react'));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
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.`);
|
|
229
|
+
}
|
|
230
|
+
// Playwright's JSX runtime produces `__pw_type` nodes rather than real React
|
|
231
|
+
// elements; convert them (invoking function components, whose bodies are also
|
|
232
|
+
// pw-jsx) before handing the tree to react-dom.
|
|
233
|
+
const renderable = isPwJsxNode(element)
|
|
234
|
+
? pwJsxToReactNode(element, react)
|
|
235
|
+
: element;
|
|
236
|
+
return reactDomServer.renderToStaticMarkup(renderable);
|
|
237
|
+
}
|
|
238
|
+
function isPwJsxNode(value) {
|
|
239
|
+
return (typeof value === 'object' &&
|
|
240
|
+
value !== null &&
|
|
241
|
+
value.__pw_type === 'jsx');
|
|
242
|
+
}
|
|
243
|
+
function isPwFragment(type) {
|
|
244
|
+
return (typeof type === 'object' &&
|
|
245
|
+
type !== null &&
|
|
246
|
+
type.__pw_jsx_fragment === true);
|
|
247
|
+
}
|
|
248
|
+
function pwChildrenToArray(children) {
|
|
249
|
+
if (children === undefined || children === null)
|
|
250
|
+
return [];
|
|
251
|
+
return Array.isArray(children) ? children : [children];
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Converts a Playwright JSX node tree into real React nodes. Function components
|
|
255
|
+
* are invoked (their bodies are pw-jsx too) and their output converted, so the
|
|
256
|
+
* result is a host-element/primitive tree that react-dom can render statically.
|
|
257
|
+
*/
|
|
258
|
+
function pwJsxToReactNode(node, react) {
|
|
259
|
+
const createElement = react.createElement;
|
|
260
|
+
if (Array.isArray(node)) {
|
|
261
|
+
return node.map((child) => pwJsxToReactNode(child, react));
|
|
262
|
+
}
|
|
263
|
+
if (!isPwJsxNode(node))
|
|
264
|
+
return node;
|
|
265
|
+
const { type, props } = node;
|
|
266
|
+
if (isPwFragment(type)) {
|
|
267
|
+
const kids = pwChildrenToArray(props?.children).map((c) => pwJsxToReactNode(c, react));
|
|
268
|
+
return createElement(react.Fragment, null, ...kids);
|
|
269
|
+
}
|
|
270
|
+
if (typeof type === 'function') {
|
|
271
|
+
return pwJsxToReactNode(type(props ?? {}), react);
|
|
272
|
+
}
|
|
273
|
+
const { children, ...rest } = props ?? {};
|
|
274
|
+
const kids = pwChildrenToArray(children).map((c) => pwJsxToReactNode(c, react));
|
|
275
|
+
return createElement(type, rest, ...kids);
|
|
276
|
+
}
|
|
277
|
+
async function readHtmlOverlayFile(path) {
|
|
278
|
+
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
279
|
+
const resolved = await resolveExistingAssetPath(path, testFilePath);
|
|
280
|
+
return readFile(resolved, 'utf-8');
|
|
281
|
+
}
|
|
63
282
|
/**
|
|
64
|
-
* Creates typed
|
|
283
|
+
* Creates typed overlay controllers whose files and display options are
|
|
65
284
|
* configured on the ScreenCI Studio page instead of in code. Business tier
|
|
66
285
|
* only.
|
|
67
286
|
*
|
|
68
|
-
* Each key becomes a callable
|
|
69
|
-
* behavior as {@link
|
|
70
|
-
* `.mp4`),
|
|
71
|
-
* from Studio.
|
|
287
|
+
* Each key becomes a callable overlay controller with the same timeline
|
|
288
|
+
* behavior as {@link createOverlays} controllers, including `start()`/`end()`.
|
|
289
|
+
* The file (`.svg`, `.png`, or `.mp4`), placement, image duration, and video
|
|
290
|
+
* audio level all come from Studio.
|
|
72
291
|
*
|
|
73
292
|
* On the first upload of a studio-mode video, rendering is held until the
|
|
74
293
|
* video is configured in Studio (the CLI prints a direct link). Later uploads
|
|
@@ -76,20 +295,20 @@ export function createAssets(assetsMap) {
|
|
|
76
295
|
*
|
|
77
296
|
* @example
|
|
78
297
|
* ```ts
|
|
79
|
-
* const
|
|
298
|
+
* const overlays = createStudioOverlays('intro', 'logo')
|
|
80
299
|
*
|
|
81
300
|
* video('Product demo', async ({ page }) => {
|
|
82
|
-
* await
|
|
301
|
+
* await overlays.intro()
|
|
83
302
|
* await page.goto('/dashboard')
|
|
84
|
-
* await
|
|
303
|
+
* await overlays.logo()
|
|
85
304
|
* })
|
|
86
305
|
* ```
|
|
87
306
|
*/
|
|
88
|
-
export function
|
|
307
|
+
export function createStudioOverlays(...keys) {
|
|
89
308
|
const seen = new Set();
|
|
90
309
|
for (const key of keys) {
|
|
91
310
|
if (seen.has(key)) {
|
|
92
|
-
throw new Error(`Duplicate
|
|
311
|
+
throw new Error(`Duplicate overlay key "${key}" passed to createStudioOverlays. Overlay keys must be unique.`);
|
|
93
312
|
}
|
|
94
313
|
seen.add(key);
|
|
95
314
|
}
|
|
@@ -99,80 +318,316 @@ export function createStudioAssets(...keys) {
|
|
|
99
318
|
}
|
|
100
319
|
return result;
|
|
101
320
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
321
|
+
function createActiveAssetRun(startedWithExplicitStart) {
|
|
322
|
+
let resolve;
|
|
323
|
+
const finished = new Promise((resolveFn) => {
|
|
324
|
+
resolve = resolveFn;
|
|
325
|
+
});
|
|
326
|
+
return { finished, resolveFinished: resolve, startedWithExplicitStart };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Ends a single live overlay identified by name, emitting its `assetEnd`,
|
|
330
|
+
* holding a frame, and clearing it from the active map. Overlays may overlap,
|
|
331
|
+
* so ending one never touches the others.
|
|
332
|
+
*/
|
|
333
|
+
function endLiveAsset(name, reason) {
|
|
334
|
+
const context = getScreenCIRuntimeContext();
|
|
335
|
+
const run = context.asset.activeRuns.get(name);
|
|
336
|
+
if (run === undefined)
|
|
337
|
+
return;
|
|
338
|
+
getRuntimeAssetRecorder().addAssetEnd(name, reason);
|
|
339
|
+
sleepForAssetFrameGap();
|
|
340
|
+
context.asset.activeRuns.delete(name);
|
|
341
|
+
run.resolveFinished();
|
|
342
|
+
}
|
|
343
|
+
function createAssetControllerCore(name, validate, emitStart,
|
|
344
|
+
/**
|
|
345
|
+
* Optional async step run after {@link validate} and before {@link emitStart},
|
|
346
|
+
* receiving the resolved start mode. Used by animated overlays to rasterize
|
|
347
|
+
* the clip once the capture length (mode duration) is known.
|
|
348
|
+
*/
|
|
349
|
+
prepare) {
|
|
350
|
+
const start = async (startedWithExplicitStart = true) => {
|
|
351
|
+
await validate();
|
|
352
|
+
await prepare?.({ type: 'live' });
|
|
353
|
+
const recorder = getRuntimeAssetRecorder();
|
|
354
|
+
const context = getScreenCIRuntimeContext();
|
|
355
|
+
if (context.asset.activeRuns.has(name)) {
|
|
356
|
+
throw new Error(`[screenci] Overlay "${name}" is already started. Call end() for it before starting it again.`);
|
|
357
|
+
}
|
|
358
|
+
const run = createActiveAssetRun(startedWithExplicitStart);
|
|
359
|
+
context.asset.activeRuns.set(name, run);
|
|
360
|
+
emitStart(recorder, { type: 'live' });
|
|
361
|
+
};
|
|
362
|
+
const end = async () => {
|
|
363
|
+
const context = getScreenCIRuntimeContext();
|
|
364
|
+
const run = context.asset.activeRuns.get(name);
|
|
365
|
+
if (run === undefined) {
|
|
366
|
+
throw new Error(`Cannot call end() for overlay "${name}" because it is not a started overlay`);
|
|
367
|
+
}
|
|
368
|
+
endLiveAsset(name, 'wait');
|
|
369
|
+
await run.finished;
|
|
107
370
|
};
|
|
371
|
+
const controller = (async (durationMs) => {
|
|
372
|
+
// A blocking overlay holds a frozen frame for a fixed duration. It never
|
|
373
|
+
// registers a live run and never ends overlays that are already live, so it
|
|
374
|
+
// can run while other overlays stay composited across the frozen frame.
|
|
375
|
+
const mode = {
|
|
376
|
+
type: 'blocking',
|
|
377
|
+
...(durationMs !== undefined && { durationMs }),
|
|
378
|
+
};
|
|
379
|
+
await validate();
|
|
380
|
+
await prepare?.(mode);
|
|
381
|
+
const recorder = getRuntimeAssetRecorder();
|
|
382
|
+
emitStart(recorder, mode);
|
|
383
|
+
});
|
|
384
|
+
controller.start = () => start(true);
|
|
385
|
+
controller.end = end;
|
|
386
|
+
return controller;
|
|
108
387
|
}
|
|
109
|
-
function
|
|
110
|
-
return
|
|
388
|
+
function createStudioAssetController(name) {
|
|
389
|
+
return createAssetControllerCore(name, () => Promise.resolve(), (recorder) => recorder.addStudioAssetStart(name));
|
|
390
|
+
}
|
|
391
|
+
function createFileOverlayController(name, resolved) {
|
|
392
|
+
return createAssetControllerCore(name, async () => {
|
|
111
393
|
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
112
394
|
if (testFilePath !== null) {
|
|
113
|
-
await validateAssetPath(
|
|
395
|
+
await validateAssetPath(resolved.path, testFilePath);
|
|
396
|
+
}
|
|
397
|
+
}, (recorder, mode) => {
|
|
398
|
+
recorder.addAssetStart(name, toRecordedFileStart(name, resolved, mode));
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* An overlay rendered to a transparent PNG at recording time, from either an
|
|
403
|
+
* HTML file or a React element. `getMarkup` produces the HTML to rasterize.
|
|
404
|
+
*/
|
|
405
|
+
function createRenderedOverlayController(name, getMarkup, placement, fullScreen, durationMs, renderOpts = {}) {
|
|
406
|
+
let generated;
|
|
407
|
+
let skipped = false;
|
|
408
|
+
return createAssetControllerCore(name, async () => {
|
|
409
|
+
if (generated !== undefined || skipped)
|
|
410
|
+
return;
|
|
411
|
+
// Generating an overlay needs an active recording page and output dir.
|
|
412
|
+
// Outside recording (e.g. plain test runs) there is nothing to upload, so
|
|
413
|
+
// the controller is a no-op, mirroring the no-op recorder.
|
|
414
|
+
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
415
|
+
skipped = true;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const html = await getMarkup();
|
|
419
|
+
const result = await rasterizeHtmlOverlay({
|
|
420
|
+
name,
|
|
421
|
+
html,
|
|
422
|
+
...(renderOpts.css !== undefined && { css: renderOpts.css }),
|
|
423
|
+
...(renderOpts.capturePadding !== undefined && {
|
|
424
|
+
capturePadding: renderOpts.capturePadding,
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
generated = { path: result.path, fileHash: result.fileHash };
|
|
428
|
+
}, (recorder, mode) => {
|
|
429
|
+
if (generated === undefined)
|
|
430
|
+
return;
|
|
431
|
+
recorder.addAssetStart(name, toRecordedRenderedStart(name, {
|
|
432
|
+
placement,
|
|
433
|
+
fullScreen,
|
|
434
|
+
...(durationMs !== undefined && { durationMs }),
|
|
435
|
+
}, generated, mode));
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* An animated overlay rendered to a transparent clip at recording time, from
|
|
440
|
+
* either an HTML file or a React element. The capture length is resolved from
|
|
441
|
+
* the call argument or config `durationMs`; `start()`/`end()` requires a config
|
|
442
|
+
* `durationMs` (the capture length is otherwise unknown).
|
|
443
|
+
*/
|
|
444
|
+
function createAnimatedOverlayController(name, getMarkup, placement, fullScreen, fps, configDurationMs, renderOpts = {}) {
|
|
445
|
+
let generated;
|
|
446
|
+
const resolveDurationMs = (mode) => {
|
|
447
|
+
if (mode.type === 'blocking') {
|
|
448
|
+
const durationMs = mode.durationMs ?? configDurationMs;
|
|
449
|
+
if (durationMs === undefined) {
|
|
450
|
+
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).`);
|
|
451
|
+
}
|
|
452
|
+
validateDurationMs(name, `overlay "${name}"`, durationMs);
|
|
453
|
+
return durationMs;
|
|
454
|
+
}
|
|
455
|
+
if (configDurationMs === undefined) {
|
|
456
|
+
throw new Error(`[screenci] Animated overlay "${name}" driven with .start()/.end() needs durationMs in its config (the capture length is otherwise unknown).`);
|
|
114
457
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return Promise.resolve();
|
|
458
|
+
validateDurationMs(name, `overlay "${name}"`, configDurationMs);
|
|
459
|
+
return configDurationMs;
|
|
118
460
|
};
|
|
461
|
+
return createAssetControllerCore(name, () => Promise.resolve(), (recorder, mode) => {
|
|
462
|
+
if (generated === undefined)
|
|
463
|
+
return;
|
|
464
|
+
recorder.addAssetStart(name, {
|
|
465
|
+
kind: 'animation',
|
|
466
|
+
path: generated.path,
|
|
467
|
+
fileHash: generated.fileHash,
|
|
468
|
+
...(mode.type === 'blocking' && { durationMs: generated.durationMs }),
|
|
469
|
+
fullScreen,
|
|
470
|
+
placement,
|
|
471
|
+
});
|
|
472
|
+
}, async (mode) => {
|
|
473
|
+
// Outside recording there is nothing to upload, so the controller is a
|
|
474
|
+
// no-op, mirroring the no-op recorder and the static rendered controller.
|
|
475
|
+
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const durationMs = resolveDurationMs(mode);
|
|
479
|
+
const html = await getMarkup();
|
|
480
|
+
const rasterize = async () => {
|
|
481
|
+
const result = await rasterizeAnimatedHtmlOverlay({
|
|
482
|
+
name,
|
|
483
|
+
html,
|
|
484
|
+
durationMs,
|
|
485
|
+
...(fps !== undefined && { fps }),
|
|
486
|
+
...(renderOpts.css !== undefined && { css: renderOpts.css }),
|
|
487
|
+
...(renderOpts.capturePadding !== undefined && {
|
|
488
|
+
capturePadding: renderOpts.capturePadding,
|
|
489
|
+
}),
|
|
490
|
+
});
|
|
491
|
+
generated = {
|
|
492
|
+
path: result.path,
|
|
493
|
+
fileHash: result.fileHash,
|
|
494
|
+
durationMs,
|
|
495
|
+
};
|
|
496
|
+
};
|
|
497
|
+
// Capturing the animation (screenshotting each frame + encoding) takes
|
|
498
|
+
// real wall-clock that would otherwise be baked into the recording as a
|
|
499
|
+
// long frozen pause before the overlay appears. Cut that time from the
|
|
500
|
+
// output by wrapping the capture in a hide block. On a cache hit this is
|
|
501
|
+
// ~instant, so the cut is a negligible sliver. Skip the wrapping when
|
|
502
|
+
// already inside a hide()/time() block (a hide cannot nest, and the
|
|
503
|
+
// surrounding block already governs that time).
|
|
504
|
+
if (isInsideHide() || isInsideTime()) {
|
|
505
|
+
await rasterize();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const recorder = getRuntimeAssetRecorder();
|
|
509
|
+
recorder.addHideStart();
|
|
510
|
+
try {
|
|
511
|
+
await rasterize();
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
recorder.addHideEnd();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
119
517
|
}
|
|
120
518
|
function getAssetExtension(path) {
|
|
121
519
|
const dotIndex = path.lastIndexOf('.');
|
|
122
520
|
if (dotIndex === -1)
|
|
123
521
|
return null;
|
|
124
522
|
const extension = path.slice(dotIndex).toLowerCase();
|
|
125
|
-
if (extension === '.
|
|
523
|
+
if (extension === '.html' ||
|
|
524
|
+
extension === '.svg' ||
|
|
525
|
+
extension === '.png' ||
|
|
526
|
+
extension === '.mp4') {
|
|
126
527
|
return extension;
|
|
127
528
|
}
|
|
128
529
|
return null;
|
|
129
530
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
531
|
+
function isFiniteNonNegative(value) {
|
|
532
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
533
|
+
}
|
|
534
|
+
function validateDurationMs(name, path, durationMs) {
|
|
535
|
+
if (!isFiniteNonNegative(durationMs)) {
|
|
536
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite durationMs greater than or equal to 0.`);
|
|
134
537
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
config.durationMs === undefined ||
|
|
141
|
-
!Number.isFinite(config.durationMs) ||
|
|
142
|
-
config.durationMs < 0) {
|
|
143
|
-
throw new Error(`[screenci] Asset "${name}" (${config.path}) must provide a finite durationMs greater than or equal to 0.`);
|
|
538
|
+
}
|
|
539
|
+
function validatePlacement(name, placement) {
|
|
540
|
+
if ('fullScreen' in placement) {
|
|
541
|
+
if (placement.fullScreen !== true) {
|
|
542
|
+
throw new Error(`[screenci] Overlay "${name}" fullScreen must be true when set.`);
|
|
144
543
|
}
|
|
145
544
|
return;
|
|
146
545
|
}
|
|
147
|
-
|
|
148
|
-
|
|
546
|
+
const relativeTo = placement.relativeTo;
|
|
547
|
+
if (relativeTo !== 'screen' && relativeTo !== 'recording') {
|
|
548
|
+
throw new Error(`[screenci] Overlay "${name}" relativeTo must be 'screen' or 'recording'.`);
|
|
149
549
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
550
|
+
const hasWidth = 'width' in placement && placement.width !== undefined;
|
|
551
|
+
const entries = [
|
|
552
|
+
['x', placement.x],
|
|
553
|
+
['y', placement.y],
|
|
554
|
+
hasWidth
|
|
555
|
+
? ['width', placement.width]
|
|
556
|
+
: ['height', placement.height],
|
|
557
|
+
];
|
|
558
|
+
for (const [label, value] of entries) {
|
|
559
|
+
if (typeof value !== 'number' ||
|
|
560
|
+
!Number.isFinite(value) ||
|
|
561
|
+
value < 0 ||
|
|
562
|
+
value > 1) {
|
|
563
|
+
throw new Error(`[screenci] Overlay "${name}" ${label} must be a number between 0 and 1 (normalized fraction). Received: ${String(value)}`);
|
|
564
|
+
}
|
|
154
565
|
}
|
|
155
566
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
};
|
|
567
|
+
/**
|
|
568
|
+
* Resolves an {@link OverlayConfig}'s flat placement fields into the event-shape
|
|
569
|
+
* {@link OverlayPlacement}, applying the defaults `relativeTo: 'recording'`,
|
|
570
|
+
* `x: 0`, `y: 0`, and `width: 1` (when neither width nor height is given). The
|
|
571
|
+
* default box therefore fills the recording area.
|
|
572
|
+
*/
|
|
573
|
+
function resolveOverlayPlacement(name, config) {
|
|
574
|
+
if (config.fullScreen === true) {
|
|
575
|
+
return { fullScreen: true };
|
|
166
576
|
}
|
|
167
|
-
if (
|
|
168
|
-
|
|
577
|
+
if (config.width !== undefined && config.height !== undefined) {
|
|
578
|
+
throw new Error(`[screenci] Overlay "${name}" must set only one of width or height (the other is derived from the aspect ratio).`);
|
|
579
|
+
}
|
|
580
|
+
const relativeTo = config.relativeTo ?? 'recording';
|
|
581
|
+
const x = config.x ?? 0;
|
|
582
|
+
const y = config.y ?? 0;
|
|
583
|
+
const placement = config.height !== undefined
|
|
584
|
+
? { relativeTo, x, y, height: config.height }
|
|
585
|
+
: { relativeTo, x, y, width: config.width ?? 1 };
|
|
586
|
+
validatePlacement(name, placement);
|
|
587
|
+
return placement;
|
|
588
|
+
}
|
|
589
|
+
function toRecordedRenderedStart(name, resolved, generated, mode) {
|
|
590
|
+
let durationMs;
|
|
591
|
+
if (mode.type === 'blocking') {
|
|
592
|
+
durationMs = mode.durationMs ?? resolved.durationMs;
|
|
593
|
+
if (durationMs === undefined) {
|
|
594
|
+
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().`);
|
|
595
|
+
}
|
|
596
|
+
validateDurationMs(name, `overlay "${name}"`, durationMs);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
kind: 'image',
|
|
600
|
+
path: generated.path,
|
|
601
|
+
fileHash: generated.fileHash,
|
|
602
|
+
...(durationMs !== undefined && { durationMs }),
|
|
603
|
+
fullScreen: resolved.fullScreen,
|
|
604
|
+
placement: resolved.placement,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function toRecordedFileStart(name, resolved, mode) {
|
|
608
|
+
if (resolved.kind === 'image') {
|
|
609
|
+
let durationMs;
|
|
610
|
+
if (mode.type === 'blocking') {
|
|
611
|
+
durationMs = mode.durationMs ?? resolved.durationMs;
|
|
612
|
+
if (durationMs === undefined) {
|
|
613
|
+
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().`);
|
|
614
|
+
}
|
|
615
|
+
validateDurationMs(name, resolved.path, durationMs);
|
|
616
|
+
}
|
|
169
617
|
return {
|
|
170
|
-
kind: '
|
|
171
|
-
path:
|
|
172
|
-
|
|
173
|
-
fullScreen:
|
|
618
|
+
kind: 'image',
|
|
619
|
+
path: resolved.path,
|
|
620
|
+
...(durationMs !== undefined && { durationMs }),
|
|
621
|
+
fullScreen: resolved.fullScreen,
|
|
622
|
+
placement: resolved.placement,
|
|
174
623
|
};
|
|
175
624
|
}
|
|
176
|
-
|
|
625
|
+
return {
|
|
626
|
+
kind: 'video',
|
|
627
|
+
path: resolved.path,
|
|
628
|
+
audio: resolved.audio ?? 1,
|
|
629
|
+
fullScreen: resolved.fullScreen,
|
|
630
|
+
placement: resolved.placement,
|
|
631
|
+
};
|
|
177
632
|
}
|
|
178
633
|
//# sourceMappingURL=asset.js.map
|