screenci 0.0.69 → 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.map +1 -1
- package/dist/cli.js +12 -4
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +54 -23
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +18 -7
- 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.video.js → studio.screenci.js} +1 -1
- 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/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 +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +119 -15
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +310 -124
- package/dist/src/asset.js.map +1 -1
- package/dist/src/audio.d.ts +14 -0
- package/dist/src/audio.d.ts.map +1 -1
- package/dist/src/audio.js +4 -1
- package/dist/src/audio.js.map +1 -1
- 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 +6 -0
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +6 -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 +152 -10
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +61 -3
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +43 -0
- package/dist/src/htmlRasterizer.d.ts.map +1 -1
- package/dist/src/htmlRasterizer.js +32 -10
- package/dist/src/htmlRasterizer.js.map +1 -1
- package/dist/src/init.js +7 -7
- package/dist/src/init.js.map +1 -1
- 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/recording.d.ts +3 -2
- package/dist/src/recording.d.ts.map +1 -1
- package/dist/src/recordingData.d.ts +20 -0
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/runtimeContext.d.ts +19 -1
- package/dist/src/runtimeContext.d.ts.map +1 -1
- package/dist/src/runtimeContext.js +15 -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 +100 -8
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +20 -0
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +71 -24
- package/dist/src/video.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/skills/screenci/SKILL.md +10 -10
- package/skills/screenci/references/init.md +1 -1
- package/skills/screenci/references/record.md +2 -2
- 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/docs/video-sources/studio.video.d.ts +0 -2
- package/dist/docs/video-sources/studio.video.d.ts.map +0 -1
- package/dist/docs/video-sources/studio.video.js.map +0 -1
package/dist/src/asset.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { overlayRect } from './overlayRect.js';
|
|
1
2
|
import { access, readFile } from 'fs/promises';
|
|
2
3
|
import { dirname, resolve } from 'path';
|
|
3
4
|
import { resolveRecordingTimingDuration } from './runtimeMode.js';
|
|
4
|
-
import {
|
|
5
|
+
import { resolveOverlayCss, DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR, DEFAULT_ANIMATION_FPS, } from './htmlRasterizer.js';
|
|
5
6
|
export { setOverlayCss } from './htmlRasterizer.js';
|
|
6
|
-
import { isInsideHide, isInsideTime } from './timelineBlock.js';
|
|
7
7
|
import { getScreenCIRuntimeContext, getRuntimeAssetRecorder, getRuntimePage, getRuntimeRecordingDir, setRuntimeAssetRecorder, resetAssetRuntimeState, } from './runtimeContext.js';
|
|
8
8
|
/**
|
|
9
9
|
* Upper bound for an audio level (linear gain). `4` is +12 dB, plenty of
|
|
@@ -71,21 +71,25 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Creates a set of typed overlay controllers, one per key in the map. Each value
|
|
73
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.
|
|
74
76
|
*
|
|
75
77
|
* Calling a controller shows the overlay in the recording timeline. Image
|
|
76
78
|
* (`.svg`/`.png`), HTML, and React overlays need a `durationMs` (in the config
|
|
77
79
|
* or passed to the blocking call) unless driven with `start()`/`end()`; `.mp4`
|
|
78
80
|
* overlays use their natural duration and default `audio` to `1` (natural level).
|
|
79
81
|
*
|
|
80
|
-
* Placement defaults to the full recording area (`relativeTo: 'recording'
|
|
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.
|
|
82
85
|
*
|
|
83
86
|
* @example
|
|
84
87
|
* ```tsx
|
|
85
88
|
* const overlays = createOverlays({
|
|
86
89
|
* hint: 'callout.html', // HTML file
|
|
87
90
|
* badge: <Badge label="New" />, // React element
|
|
88
|
-
*
|
|
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 },
|
|
89
93
|
* intro: { path: 'intro.mp4', fullScreen: true },
|
|
90
94
|
* })
|
|
91
95
|
*
|
|
@@ -95,6 +99,12 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
95
99
|
* await overlays.logo(1200)
|
|
96
100
|
* })
|
|
97
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.
|
|
98
108
|
*/
|
|
99
109
|
export function createOverlays(overlays) {
|
|
100
110
|
const result = {};
|
|
@@ -109,11 +119,17 @@ function isReactElementLike(value) {
|
|
|
109
119
|
// A real React element carries a symbol `$$typeof`.
|
|
110
120
|
if (typeof value.$$typeof === 'symbol')
|
|
111
121
|
return true;
|
|
112
|
-
// Playwright transpiles JSX in `.
|
|
122
|
+
// Playwright transpiles JSX in `.screenci.tsx` files with its own automatic
|
|
113
123
|
// runtime, which produces `{ __pw_type: 'jsx', ... }` nodes instead.
|
|
114
124
|
return value.__pw_type === 'jsx';
|
|
115
125
|
}
|
|
116
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
|
+
}
|
|
117
133
|
if (typeof input === 'string') {
|
|
118
134
|
return buildOverlayFromConfig(name, { path: input });
|
|
119
135
|
}
|
|
@@ -125,13 +141,22 @@ function buildOverlayController(name, input) {
|
|
|
125
141
|
function buildOverlayFromConfig(name, config) {
|
|
126
142
|
const hasPath = config.path !== undefined;
|
|
127
143
|
const hasElement = config.element !== undefined;
|
|
128
|
-
|
|
129
|
-
|
|
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".`);
|
|
130
151
|
}
|
|
131
|
-
if (
|
|
132
|
-
|
|
152
|
+
if (hasHtml) {
|
|
153
|
+
validateInlineHtmlFragment(name, config.html);
|
|
133
154
|
}
|
|
134
|
-
const
|
|
155
|
+
const placementSource = resolvePlacementSource(name, config, {
|
|
156
|
+
hasElement,
|
|
157
|
+
hasHtml,
|
|
158
|
+
hasPath,
|
|
159
|
+
});
|
|
135
160
|
const fullScreen = config.fullScreen ?? false;
|
|
136
161
|
const animate = config.animate === true;
|
|
137
162
|
if (config.fps !== undefined && !animate) {
|
|
@@ -152,17 +177,32 @@ function buildOverlayFromConfig(name, config) {
|
|
|
152
177
|
capturePadding: config.capturePadding,
|
|
153
178
|
}),
|
|
154
179
|
};
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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);
|
|
159
193
|
if (animate) {
|
|
160
|
-
return createAnimatedOverlayController(name, getMarkup,
|
|
194
|
+
return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
161
195
|
}
|
|
162
|
-
return createRenderedOverlayController(name, getMarkup,
|
|
196
|
+
return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
|
|
163
197
|
}
|
|
164
198
|
const path = config.path;
|
|
165
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);
|
|
166
206
|
if (animate && extension !== '.html') {
|
|
167
207
|
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for HTML files and React elements.`);
|
|
168
208
|
}
|
|
@@ -175,9 +215,9 @@ function buildOverlayFromConfig(name, config) {
|
|
|
175
215
|
registeredAssetPaths.add(path);
|
|
176
216
|
const getMarkup = () => readHtmlOverlayFile(path);
|
|
177
217
|
if (animate) {
|
|
178
|
-
return createAnimatedOverlayController(name, getMarkup,
|
|
218
|
+
return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
|
|
179
219
|
}
|
|
180
|
-
return createRenderedOverlayController(name, getMarkup,
|
|
220
|
+
return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
|
|
181
221
|
}
|
|
182
222
|
// File-backed image / video overlays.
|
|
183
223
|
if (extension === '.svg' || extension === '.png') {
|
|
@@ -191,7 +231,7 @@ function buildOverlayFromConfig(name, config) {
|
|
|
191
231
|
return createFileOverlayController(name, {
|
|
192
232
|
kind: 'image',
|
|
193
233
|
path,
|
|
194
|
-
placement,
|
|
234
|
+
...(placement !== undefined && { placement }),
|
|
195
235
|
fullScreen,
|
|
196
236
|
...(config.durationMs !== undefined && { durationMs: config.durationMs }),
|
|
197
237
|
});
|
|
@@ -206,17 +246,44 @@ function buildOverlayFromConfig(name, config) {
|
|
|
206
246
|
config.audio > MAX_AUDIO_LEVEL)) {
|
|
207
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.`);
|
|
208
248
|
}
|
|
249
|
+
validateSpeedTime(`Overlay "${name}" (${path})`, config.speed, config.time);
|
|
209
250
|
registeredAssetPaths.add(path);
|
|
210
251
|
return createFileOverlayController(name, {
|
|
211
252
|
kind: 'video',
|
|
212
253
|
path,
|
|
213
|
-
placement,
|
|
254
|
+
...(placement !== undefined && { placement }),
|
|
214
255
|
fullScreen,
|
|
215
256
|
...(config.audio !== undefined && { audio: config.audio }),
|
|
257
|
+
...(config.speed !== undefined && { speed: config.speed }),
|
|
258
|
+
...(config.time !== undefined && { time: config.time }),
|
|
216
259
|
});
|
|
217
260
|
}
|
|
218
261
|
throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
|
|
219
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
|
+
}
|
|
220
287
|
async function renderElementToMarkup(name, element) {
|
|
221
288
|
let reactDomServer;
|
|
222
289
|
let react;
|
|
@@ -402,47 +469,77 @@ function createFileOverlayController(name, resolved) {
|
|
|
402
469
|
* An overlay rendered to a transparent PNG at recording time, from either an
|
|
403
470
|
* HTML file or a React element. `getMarkup` produces the HTML to rasterize.
|
|
404
471
|
*/
|
|
405
|
-
function createRenderedOverlayController(name, getMarkup,
|
|
406
|
-
|
|
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;
|
|
407
479
|
let skipped = false;
|
|
408
|
-
return createAssetControllerCore(name,
|
|
409
|
-
|
|
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)
|
|
410
485
|
return;
|
|
411
|
-
|
|
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.
|
|
412
516
|
// Outside recording (e.g. plain test runs) there is nothing to upload, so
|
|
413
517
|
// the controller is a no-op, mirroring the no-op recorder.
|
|
414
518
|
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
415
519
|
skipped = true;
|
|
416
520
|
return;
|
|
417
521
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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));
|
|
522
|
+
const { placement, sizePx } = await resolvePlacement(placementSource);
|
|
523
|
+
resolvedPlacement = placement;
|
|
524
|
+
const markup = await getMarkup();
|
|
525
|
+
resolvedHtml =
|
|
526
|
+
sizePx !== undefined ? sizeWrapMarkup(markup, sizePx) : markup;
|
|
436
527
|
});
|
|
437
528
|
}
|
|
438
529
|
/**
|
|
439
|
-
* An animated overlay rendered to a transparent clip
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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.
|
|
443
539
|
*/
|
|
444
|
-
function createAnimatedOverlayController(name, getMarkup,
|
|
445
|
-
let
|
|
540
|
+
function createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, fps, configDurationMs, renderOpts = {}) {
|
|
541
|
+
let resolved;
|
|
542
|
+
let skipped = false;
|
|
446
543
|
const resolveDurationMs = (mode) => {
|
|
447
544
|
if (mode.type === 'blocking') {
|
|
448
545
|
const durationMs = mode.durationMs ?? configDurationMs;
|
|
@@ -459,60 +556,41 @@ function createAnimatedOverlayController(name, getMarkup, placement, fullScreen,
|
|
|
459
556
|
return configDurationMs;
|
|
460
557
|
};
|
|
461
558
|
return createAssetControllerCore(name, () => Promise.resolve(), (recorder, mode) => {
|
|
462
|
-
if (
|
|
559
|
+
if (skipped || resolved === undefined)
|
|
463
560
|
return;
|
|
464
|
-
recorder.
|
|
561
|
+
recorder.addPendingAssetStart(name, {
|
|
465
562
|
kind: 'animation',
|
|
466
|
-
|
|
467
|
-
fileHash: generated.fileHash,
|
|
468
|
-
...(mode.type === 'blocking' && { durationMs: generated.durationMs }),
|
|
563
|
+
...(mode.type === 'blocking' && { durationMs: resolved.durationMs }),
|
|
469
564
|
fullScreen,
|
|
470
|
-
placement
|
|
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
|
+
},
|
|
471
578
|
});
|
|
472
579
|
}, async (mode) => {
|
|
473
580
|
// Outside recording there is nothing to upload, so the controller is a
|
|
474
581
|
// no-op, mirroring the no-op recorder and the static rendered controller.
|
|
475
582
|
if (getRuntimePage() === null || getRuntimeRecordingDir() === null) {
|
|
583
|
+
skipped = true;
|
|
476
584
|
return;
|
|
477
585
|
}
|
|
478
586
|
const durationMs = resolveDurationMs(mode);
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
};
|
|
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 }),
|
|
496
593
|
};
|
|
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
594
|
});
|
|
517
595
|
}
|
|
518
596
|
function getAssetExtension(path) {
|
|
@@ -536,6 +614,28 @@ function validateDurationMs(name, path, durationMs) {
|
|
|
536
614
|
throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite durationMs greater than or equal to 0.`);
|
|
537
615
|
}
|
|
538
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
|
+
}
|
|
539
639
|
function validatePlacement(name, placement) {
|
|
540
640
|
if ('fullScreen' in placement) {
|
|
541
641
|
if (placement.fullScreen !== true) {
|
|
@@ -548,27 +648,38 @@ function validatePlacement(name, placement) {
|
|
|
548
648
|
throw new Error(`[screenci] Overlay "${name}" relativeTo must be 'screen' or 'recording'.`);
|
|
549
649
|
}
|
|
550
650
|
const hasWidth = 'width' in placement && placement.width !== undefined;
|
|
551
|
-
const
|
|
651
|
+
const sizeLabel = hasWidth ? 'width' : 'height';
|
|
652
|
+
const sizeValue = hasWidth
|
|
653
|
+
? placement.width
|
|
654
|
+
: placement.height;
|
|
655
|
+
for (const [label, value] of [
|
|
552
656
|
['x', placement.x],
|
|
553
657
|
['y', placement.y],
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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)}`);
|
|
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)}`);
|
|
564
661
|
}
|
|
565
662
|
}
|
|
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)}`);
|
|
674
|
+
}
|
|
566
675
|
}
|
|
567
676
|
/**
|
|
568
677
|
* Resolves an {@link OverlayConfig}'s flat placement fields into the event-shape
|
|
569
|
-
* {@link OverlayPlacement}
|
|
570
|
-
* `
|
|
571
|
-
*
|
|
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.
|
|
572
683
|
*/
|
|
573
684
|
function resolveOverlayPlacement(name, config) {
|
|
574
685
|
if (config.fullScreen === true) {
|
|
@@ -577,33 +688,104 @@ function resolveOverlayPlacement(name, config) {
|
|
|
577
688
|
if (config.width !== undefined && config.height !== undefined) {
|
|
578
689
|
throw new Error(`[screenci] Overlay "${name}" must set only one of width or height (the other is derived from the aspect ratio).`);
|
|
579
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
|
+
}
|
|
580
703
|
const relativeTo = config.relativeTo ?? 'recording';
|
|
581
704
|
const x = config.x ?? 0;
|
|
582
705
|
const y = config.y ?? 0;
|
|
706
|
+
const aspectRatio = config.aspectRatio;
|
|
583
707
|
const placement = config.height !== undefined
|
|
584
|
-
? {
|
|
585
|
-
|
|
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 }),
|
|
721
|
+
};
|
|
586
722
|
validatePlacement(name, placement);
|
|
587
723
|
return placement;
|
|
588
724
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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.`);
|
|
733
|
+
}
|
|
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)}`);
|
|
597
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 });
|
|
598
770
|
return {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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 },
|
|
605
778
|
};
|
|
606
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
|
+
}
|
|
607
789
|
function toRecordedFileStart(name, resolved, mode) {
|
|
608
790
|
if (resolved.kind === 'image') {
|
|
609
791
|
let durationMs;
|
|
@@ -619,7 +801,9 @@ function toRecordedFileStart(name, resolved, mode) {
|
|
|
619
801
|
path: resolved.path,
|
|
620
802
|
...(durationMs !== undefined && { durationMs }),
|
|
621
803
|
fullScreen: resolved.fullScreen,
|
|
622
|
-
|
|
804
|
+
...(resolved.placement !== undefined && {
|
|
805
|
+
placement: resolved.placement,
|
|
806
|
+
}),
|
|
623
807
|
};
|
|
624
808
|
}
|
|
625
809
|
return {
|
|
@@ -627,7 +811,9 @@ function toRecordedFileStart(name, resolved, mode) {
|
|
|
627
811
|
path: resolved.path,
|
|
628
812
|
audio: resolved.audio ?? 1,
|
|
629
813
|
fullScreen: resolved.fullScreen,
|
|
630
|
-
placement: resolved.placement,
|
|
814
|
+
...(resolved.placement !== undefined && { placement: resolved.placement }),
|
|
815
|
+
...(resolved.speed !== undefined && { speed: resolved.speed }),
|
|
816
|
+
...(resolved.time !== undefined && { time: resolved.time }),
|
|
631
817
|
};
|
|
632
818
|
}
|
|
633
819
|
//# sourceMappingURL=asset.js.map
|