screenci 0.0.89 → 0.0.91
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/dist/cli.d.ts +39 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +357 -75
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +29 -29
- package/dist/docs/manifest.js +9 -9
- package/dist/docs/manifest.js.map +1 -1
- package/dist/docs/video-sources/assets/StyleCard.d.ts +4 -0
- package/dist/docs/video-sources/assets/StyleCard.d.ts.map +1 -0
- package/dist/docs/video-sources/assets/StyleCard.js +27 -0
- package/dist/docs/video-sources/assets/StyleCard.js.map +1 -0
- package/dist/docs/video-sources/camera-and-zooming.screenci.js +2 -9
- package/dist/docs/video-sources/camera-and-zooming.screenci.js.map +1 -1
- package/dist/docs/video-sources/editor.screenci.d.ts +2 -0
- package/dist/docs/video-sources/editor.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/{studio.screenci.js → editor.screenci.js} +18 -19
- package/dist/docs/video-sources/editor.screenci.js.map +1 -0
- package/dist/docs/video-sources/languages.screenci.js +12 -12
- package/dist/docs/video-sources/languages.screenci.js.map +1 -1
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js +2 -15
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
- package/dist/docs/video-sources/locator-highlight-still.screenci.js +2 -1
- package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -1
- package/dist/docs/video-sources/narration.screenci.js +11 -11
- package/dist/docs/video-sources/narration.screenci.js.map +1 -1
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.js +2 -2
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.js.map +1 -1
- package/dist/docs/video-sources/speaking-style.screenci.js +4 -28
- package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -1
- package/dist/docs/videos.d.ts +2 -2
- package/dist/docs/videos.js +2 -2
- package/dist/e2e/fixtures/ClientCounter.d.ts +5 -0
- package/dist/e2e/fixtures/ClientCounter.d.ts.map +1 -0
- package/dist/e2e/fixtures/ClientCounter.js +29 -0
- package/dist/e2e/fixtures/ClientCounter.js.map +1 -0
- package/dist/e2e/htmlRasterizer.e2e.js +104 -23
- package/dist/e2e/htmlRasterizer.e2e.js.map +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/src/anonSession.d.ts +20 -6
- package/dist/src/anonSession.d.ts.map +1 -1
- package/dist/src/anonSession.js +60 -13
- package/dist/src/anonSession.js.map +1 -1
- package/dist/src/asset.d.ts +68 -100
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +134 -289
- package/dist/src/asset.js.map +1 -1
- package/dist/src/audio.d.ts +1 -1
- package/dist/src/audio.js +1 -1
- package/dist/src/builder.d.ts +25 -25
- package/dist/src/builder.d.ts.map +1 -1
- package/dist/src/builder.js +8 -8
- package/dist/src/builder.js.map +1 -1
- package/dist/src/clientOverlay.d.ts +29 -0
- package/dist/src/clientOverlay.d.ts.map +1 -0
- package/dist/src/clientOverlay.js +83 -0
- package/dist/src/clientOverlay.js.map +1 -0
- package/dist/src/cue.d.ts +9 -9
- package/dist/src/cue.d.ts.map +1 -1
- package/dist/src/cue.js +6 -6
- package/dist/src/cue.js.map +1 -1
- package/dist/src/declare.d.ts +6 -6
- package/dist/src/declare.d.ts.map +1 -1
- package/dist/src/declare.js +9 -9
- package/dist/src/declare.js.map +1 -1
- package/dist/src/events.d.ts +14 -46
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +14 -24
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +21 -42
- package/dist/src/htmlRasterizer.d.ts.map +1 -1
- package/dist/src/htmlRasterizer.js +47 -66
- package/dist/src/htmlRasterizer.js.map +1 -1
- package/dist/src/init.d.ts +2 -1
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +131 -137
- package/dist/src/init.js.map +1 -1
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +146 -0
- package/dist/src/instrument.js.map +1 -1
- package/dist/src/localize.d.ts +17 -11
- package/dist/src/localize.d.ts.map +1 -1
- package/dist/src/localize.js +1 -1
- package/dist/src/localize.js.map +1 -1
- package/dist/src/localizeRuntime.d.ts +8 -8
- package/dist/src/localizeRuntime.d.ts.map +1 -1
- package/dist/src/localizeRuntime.js +4 -4
- package/dist/src/localizeRuntime.js.map +1 -1
- package/dist/src/manualZoom.d.ts.map +1 -1
- package/dist/src/manualZoom.js +8 -3
- package/dist/src/manualZoom.js.map +1 -1
- package/dist/src/overlayFlush.js +6 -6
- package/dist/src/overlayFlush.js.map +1 -1
- package/dist/src/recordingData.d.ts +2 -2
- package/dist/src/runtimeContext.d.ts +0 -11
- package/dist/src/runtimeContext.d.ts.map +1 -1
- package/dist/src/runtimeContext.js +0 -7
- package/dist/src/runtimeContext.js.map +1 -1
- package/dist/src/screenshot.d.ts +7 -7
- package/dist/src/screenshot.d.ts.map +1 -1
- package/dist/src/studio.d.ts +41 -38
- package/dist/src/studio.d.ts.map +1 -1
- package/dist/src/studio.js +9 -9
- package/dist/src/studio.js.map +1 -1
- package/dist/src/types.d.ts +19 -10
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/video.d.ts +15 -15
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +10 -10
- package/dist/src/video.js.map +1 -1
- package/dist/src/voiceConfig.d.ts +26 -7
- package/dist/src/voiceConfig.d.ts.map +1 -1
- package/dist/src/voices.d.ts +22 -2
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/src/voices.js +18 -0
- package/dist/src/voices.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -1
- package/skills/screenci/SKILL.md +1 -1
- package/skills/screenci/references/init.md +8 -1
- package/skills/screenci/references/record.md +1 -1
- package/dist/docs/video-sources/studio.screenci.d.ts +0 -2
- package/dist/docs/video-sources/studio.screenci.d.ts.map +0 -1
- package/dist/docs/video-sources/studio.screenci.js.map +0 -1
- package/dist/src/recordingSize.d.ts +0 -32
- package/dist/src/recordingSize.d.ts.map +0 -1
- package/dist/src/recordingSize.js +0 -54
- package/dist/src/recordingSize.js.map +0 -1
package/dist/src/asset.js
CHANGED
|
@@ -3,12 +3,12 @@ import { parseTimelineOffset } from './timelineOffset.js';
|
|
|
3
3
|
import { validateCrop, resolveSourceTrim } from './sourceTrim.js';
|
|
4
4
|
import { overlayRect } from './overlayRect.js';
|
|
5
5
|
import { captureCallerFile } from './callerFile.js';
|
|
6
|
+
import { buildClientOverlayDocument } from './clientOverlay.js';
|
|
6
7
|
import { logMissingAsset } from './missingAssetLog.js';
|
|
7
8
|
import { access, readFile } from 'fs/promises';
|
|
8
9
|
import { dirname, resolve } from 'path';
|
|
9
10
|
import { resolveRecordingTimingDuration } from './runtimeMode.js';
|
|
10
|
-
import {
|
|
11
|
-
export { setOverlayCss } from './htmlRasterizer.js';
|
|
11
|
+
import { DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR, DEFAULT_ANIMATION_FPS, } from './htmlRasterizer.js';
|
|
12
12
|
import { getScreenCIRuntimeContext, getRuntimeAssetRecorder, getRuntimePage, getRuntimeRecordingDir, setRuntimeAssetRecorder, resetAssetRuntimeState, } from './runtimeContext.js';
|
|
13
13
|
/**
|
|
14
14
|
* Upper bound for an audio level (linear gain). `4` is +12 dB, plenty of
|
|
@@ -186,8 +186,8 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
186
186
|
* `element`, or an inline `html` fragment.
|
|
187
187
|
*
|
|
188
188
|
* Calling a controller shows the overlay in the recording timeline. Image
|
|
189
|
-
* (`.svg`/`.png`), HTML, and React overlays need a `
|
|
190
|
-
* or passed to
|
|
189
|
+
* (`.svg`/`.png`), HTML, and React overlays need a `duration` (in the config
|
|
190
|
+
* or passed to `.for(...)`) unless driven with `start()`/`end()`; `.mp4`
|
|
191
191
|
* overlays use their natural duration and default `audio` to `1` (natural level).
|
|
192
192
|
*
|
|
193
193
|
* Placement defaults to the full recording area (`relativeTo: 'recording'`);
|
|
@@ -207,13 +207,13 @@ async function validateAssetPath(assetPath, testFilePath) {
|
|
|
207
207
|
* video('Product demo', async ({ page }) => {
|
|
208
208
|
* await overlays.intro()
|
|
209
209
|
* await page.goto('/dashboard')
|
|
210
|
-
* await overlays.logo(1200)
|
|
210
|
+
* await overlays.logo.for(1200)
|
|
211
211
|
* })
|
|
212
212
|
* ```
|
|
213
213
|
*
|
|
214
214
|
* A value can also be an {@link OverlayConfigFactory} `(props) => OverlayConfig`,
|
|
215
215
|
* making the overlay programmatic. Calling `overlays.name(props)` builds and
|
|
216
|
-
* returns a controller you then drive with
|
|
216
|
+
* returns a controller you then drive with `.for(...)`, `start()`, or
|
|
217
217
|
* `end()`. The factory runs (and its config is validated) on each call, so
|
|
218
218
|
* content and placement can depend on runtime values.
|
|
219
219
|
*/
|
|
@@ -224,56 +224,43 @@ export function createOverlays(overlays) {
|
|
|
224
224
|
}
|
|
225
225
|
return result;
|
|
226
226
|
}
|
|
227
|
-
function isReactElementLike(value) {
|
|
228
|
-
if (typeof value !== 'object' || value === null)
|
|
229
|
-
return false;
|
|
230
|
-
// A real React element carries a symbol `$$typeof`.
|
|
231
|
-
if (typeof value.$$typeof === 'symbol')
|
|
232
|
-
return true;
|
|
233
|
-
// Playwright transpiles JSX in `.screenci.tsx` files with its own automatic
|
|
234
|
-
// runtime, which produces `{ __pw_type: 'jsx', ... }` nodes instead.
|
|
235
|
-
return value.__pw_type === 'jsx';
|
|
236
|
-
}
|
|
237
227
|
function buildOverlayController(name, input) {
|
|
238
228
|
// A render dependency (selected(...)) embeds another render's output. It is a
|
|
239
229
|
// plain (branded) object, so it is matched before the generic config path,
|
|
240
|
-
// which would otherwise reject it for having no path
|
|
230
|
+
// which would otherwise reject it for having no path.
|
|
241
231
|
if (isDependencyOverlayInput(input)) {
|
|
242
232
|
return createDependencyOverlayController(name, input);
|
|
243
233
|
}
|
|
244
|
-
// A factory is the only callable input.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
234
|
+
// A factory is the only callable input. Config objects are plain objects, so
|
|
235
|
+
// this branch never captures them. The config (and its validation) is built
|
|
236
|
+
// per call so props can vary placement and content.
|
|
247
237
|
if (typeof input === 'function') {
|
|
248
238
|
return (props) => buildOverlayFromConfig(name, input(props));
|
|
249
239
|
}
|
|
250
240
|
if (typeof input === 'string') {
|
|
251
241
|
return buildOverlayFromConfig(name, { path: input });
|
|
252
242
|
}
|
|
253
|
-
if (isReactElementLike(input)) {
|
|
254
|
-
return buildOverlayFromConfig(name, { element: input });
|
|
255
|
-
}
|
|
256
243
|
return buildOverlayFromConfig(name, input);
|
|
257
244
|
}
|
|
258
245
|
function buildOverlayFromConfig(name, config) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const hasHtml = config.html !== undefined;
|
|
262
|
-
const sourceCount = Number(hasPath) + Number(hasElement) + Number(hasHtml);
|
|
263
|
-
if (sourceCount > 1) {
|
|
264
|
-
throw new Error(`[screenci] Overlay "${name}" must provide only one of "path", "element", or "html".`);
|
|
265
|
-
}
|
|
266
|
-
if (sourceCount === 0) {
|
|
267
|
-
throw new Error(`[screenci] Overlay "${name}" must provide a "path", an "element", or inline "html".`);
|
|
268
|
-
}
|
|
269
|
-
if (hasHtml) {
|
|
270
|
-
validateInlineHtmlFragment(name, config.html);
|
|
246
|
+
if (config.path === undefined) {
|
|
247
|
+
throw new Error(`[screenci] Overlay "${name}" must provide a "path" (a .tsx, .html, .svg, .png, or .mp4 file).`);
|
|
271
248
|
}
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
249
|
+
const path = config.path;
|
|
250
|
+
const extension = getAssetExtension(path);
|
|
251
|
+
if (extension === null) {
|
|
252
|
+
throw new Error(`[screenci] Overlay "${name}" must use one of: .tsx, .html, .svg, .png, .mp4. Received: ${path}`);
|
|
253
|
+
}
|
|
254
|
+
const isRendered = extension === '.tsx' || extension === '.html';
|
|
255
|
+
const tsxConfig = config;
|
|
256
|
+
// The video-only fields live on MediaOverlayConfig; read them through this
|
|
257
|
+
// accessor since the union member is selected at runtime by the extension.
|
|
258
|
+
const media = config;
|
|
259
|
+
// `props` are a .tsx-page-only concept (enforced at the type level too).
|
|
260
|
+
if (tsxConfig.props !== undefined && extension !== '.tsx') {
|
|
261
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "props": props are only supported for .tsx page overlays.`);
|
|
262
|
+
}
|
|
263
|
+
const placementSource = resolvePlacementSource(name, config, { isRendered });
|
|
277
264
|
const fullScreen = config.fill === 'screen';
|
|
278
265
|
const pinToScreen = config.pinToScreen === true;
|
|
279
266
|
const overMouse = config.overMouse === true;
|
|
@@ -288,74 +275,58 @@ function buildOverlayFromConfig(name, config) {
|
|
|
288
275
|
throw new Error(`[screenci] Overlay "${name}" must provide a finite "fps" greater than 0. Received: ${String(config.fps)}`);
|
|
289
276
|
}
|
|
290
277
|
}
|
|
291
|
-
if (
|
|
292
|
-
(
|
|
293
|
-
throw new Error(`[screenci] Overlay "${name}" must provide a finite "capturePadding" greater than or equal to 0. Received: ${String(config.capturePadding)}`);
|
|
278
|
+
if (animate && !isRendered) {
|
|
279
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for .html and .tsx page overlays.`);
|
|
294
280
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}),
|
|
300
|
-
};
|
|
301
|
-
// speed/time re-time a moving picture, so they only apply to .mp4 video
|
|
302
|
-
// overlays. Images, HTML, React, and animated overlays have no source rate.
|
|
303
|
-
if ((config.speed !== undefined || config.time !== undefined) &&
|
|
304
|
-
(hasElement || hasHtml || getAssetExtension(config.path ?? '') !== '.mp4')) {
|
|
281
|
+
// speed/time/start/end re-time a moving picture, so they only apply to .mp4
|
|
282
|
+
// video overlays.
|
|
283
|
+
if ((media.speed !== undefined || media.time !== undefined) &&
|
|
284
|
+
extension !== '.mp4') {
|
|
305
285
|
throw new Error(`[screenci] Overlay "${name}" only supports speed/time on .mp4 video overlays.`);
|
|
306
286
|
}
|
|
307
|
-
|
|
308
|
-
// recording time. Both follow the identical placement/animate/css/padding
|
|
309
|
-
// path; only how the markup is produced differs.
|
|
310
|
-
if (hasElement || hasHtml) {
|
|
311
|
-
const getMarkup = hasElement
|
|
312
|
-
? () => renderElementToMarkup(name, config.element)
|
|
313
|
-
: () => Promise.resolve(config.html);
|
|
314
|
-
if (animate) {
|
|
315
|
-
return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
|
|
316
|
-
}
|
|
317
|
-
return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, configDurationMs, renderOpts);
|
|
318
|
-
}
|
|
319
|
-
const path = config.path;
|
|
320
|
-
const extension = getAssetExtension(path);
|
|
321
|
-
// Image/video file overlays never use `over` (rejected in
|
|
322
|
-
// resolvePlacementSource), so the source is always a concrete placement for
|
|
323
|
-
// them. The `.html` file branch supports `over`, so it keeps the source.
|
|
324
|
-
const placement = placementSource.kind === 'fixed'
|
|
325
|
-
? placementSource.placement
|
|
326
|
-
: resolveOverlayPlacement(name, config);
|
|
327
|
-
if (animate && extension !== '.html') {
|
|
328
|
-
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for HTML files and React elements.`);
|
|
329
|
-
}
|
|
330
|
-
if ((config.css !== undefined || config.capturePadding !== undefined) &&
|
|
331
|
-
extension !== '.html') {
|
|
332
|
-
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "css" or "capturePadding": they are only supported for HTML files and React elements.`);
|
|
333
|
-
}
|
|
334
|
-
// crop applies to image and video files (not rasterized .html); source trim
|
|
335
|
-
// (start/end) re-times a moving picture, so it applies to .mp4 videos only.
|
|
336
|
-
if (config.crop !== undefined && extension === '.html') {
|
|
337
|
-
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "crop": crop is only supported for image (.svg/.png) and video (.mp4) file overlays.`);
|
|
338
|
-
}
|
|
339
|
-
if ((config.start !== undefined || config.end !== undefined) &&
|
|
287
|
+
if ((media.start !== undefined || media.end !== undefined) &&
|
|
340
288
|
extension !== '.mp4') {
|
|
341
289
|
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "start"/"end": source trim is only supported for .mp4 video overlays.`);
|
|
342
290
|
}
|
|
343
|
-
//
|
|
344
|
-
if (
|
|
291
|
+
// crop applies to image and video files only (not rendered pages).
|
|
292
|
+
if (media.crop !== undefined && isRendered) {
|
|
293
|
+
throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "crop": crop is only supported for image (.svg/.png) and video (.mp4) file overlays.`);
|
|
294
|
+
}
|
|
295
|
+
// .tsx (client-rendered React page) and .html (full document) both rasterize
|
|
296
|
+
// to a transparent PNG (or animated clip). They differ only in how the full
|
|
297
|
+
// document is produced: a .tsx is bundled and mounts its React app into the
|
|
298
|
+
// overlay root (so we wait for it to mount before measuring); a .html file is
|
|
299
|
+
// loaded as-is.
|
|
300
|
+
if (isRendered) {
|
|
345
301
|
registerAssetPath(path);
|
|
346
|
-
const
|
|
302
|
+
const awaitMount = extension === '.tsx';
|
|
303
|
+
const getDocument = extension === '.tsx'
|
|
304
|
+
? () => {
|
|
305
|
+
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
306
|
+
const entryPath = testFilePath !== null
|
|
307
|
+
? resolve(dirname(testFilePath), path)
|
|
308
|
+
: resolve(path);
|
|
309
|
+
return buildClientOverlayDocument(entryPath, tsxConfig.props);
|
|
310
|
+
}
|
|
311
|
+
: () => readHtmlOverlayFile(path);
|
|
312
|
+
const renderOpts = awaitMount ? { awaitMount: true } : {};
|
|
347
313
|
if (animate) {
|
|
348
|
-
return createAnimatedOverlayController(name,
|
|
314
|
+
return createAnimatedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
|
|
349
315
|
}
|
|
350
|
-
return createRenderedOverlayController(name,
|
|
316
|
+
return createRenderedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, configDurationMs, renderOpts);
|
|
351
317
|
}
|
|
318
|
+
// Image/video file overlays never use `over` (rejected in
|
|
319
|
+
// resolvePlacementSource), so the source is always a concrete placement.
|
|
320
|
+
const placement = placementSource.kind === 'fixed'
|
|
321
|
+
? placementSource.placement
|
|
322
|
+
: resolveOverlayPlacement(name, config);
|
|
352
323
|
// File-backed image / video overlays.
|
|
353
324
|
if (extension === '.svg' || extension === '.png') {
|
|
354
|
-
if (
|
|
325
|
+
if (media.volume !== undefined) {
|
|
355
326
|
throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide volume. Use duration instead.`);
|
|
356
327
|
}
|
|
357
|
-
if (
|
|
358
|
-
validateCrop(`Overlay "${name}" (${path})`,
|
|
328
|
+
if (media.crop !== undefined) {
|
|
329
|
+
validateCrop(`Overlay "${name}" (${path})`, media.crop);
|
|
359
330
|
}
|
|
360
331
|
registerAssetPath(path);
|
|
361
332
|
return createFileOverlayController(name, {
|
|
@@ -366,24 +337,24 @@ function buildOverlayFromConfig(name, config) {
|
|
|
366
337
|
...(pinToScreen && { pinToScreen: true }),
|
|
367
338
|
...(overMouse && { overMouse: true }),
|
|
368
339
|
...(configDurationMs !== undefined && { durationMs: configDurationMs }),
|
|
369
|
-
...(
|
|
340
|
+
...(media.crop !== undefined && { crop: media.crop }),
|
|
370
341
|
});
|
|
371
342
|
}
|
|
372
343
|
if (extension === '.mp4') {
|
|
373
344
|
if (config.duration !== undefined) {
|
|
374
345
|
throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide duration. Its natural media duration is used instead.`);
|
|
375
346
|
}
|
|
376
|
-
if (
|
|
377
|
-
(!Number.isFinite(
|
|
378
|
-
|
|
379
|
-
|
|
347
|
+
if (media.volume !== undefined &&
|
|
348
|
+
(!Number.isFinite(media.volume) ||
|
|
349
|
+
media.volume < 0 ||
|
|
350
|
+
media.volume > MAX_AUDIO_LEVEL)) {
|
|
380
351
|
throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite volume between 0 and ${MAX_AUDIO_LEVEL} for .mp4 overlays. 1 is the natural level, 0 is silent, and values above 1 boost it.`);
|
|
381
352
|
}
|
|
382
|
-
validateSpeedTime(`Overlay "${name}" (${path})`,
|
|
383
|
-
if (
|
|
384
|
-
validateCrop(`Overlay "${name}" (${path})`,
|
|
353
|
+
validateSpeedTime(`Overlay "${name}" (${path})`, media.speed, media.time);
|
|
354
|
+
if (media.crop !== undefined) {
|
|
355
|
+
validateCrop(`Overlay "${name}" (${path})`, media.crop);
|
|
385
356
|
}
|
|
386
|
-
const { sourceStart, sourceEnd } = resolveSourceTrim(`Overlay "${name}" (${path})`,
|
|
357
|
+
const { sourceStart, sourceEnd } = resolveSourceTrim(`Overlay "${name}" (${path})`, media.start, media.end);
|
|
387
358
|
registerAssetPath(path);
|
|
388
359
|
return createFileOverlayController(name, {
|
|
389
360
|
kind: 'video',
|
|
@@ -392,161 +363,15 @@ function buildOverlayFromConfig(name, config) {
|
|
|
392
363
|
fullScreen,
|
|
393
364
|
...(pinToScreen && { pinToScreen: true }),
|
|
394
365
|
...(overMouse && { overMouse: true }),
|
|
395
|
-
...(
|
|
396
|
-
...(
|
|
397
|
-
...(
|
|
398
|
-
...(
|
|
366
|
+
...(media.volume !== undefined && { audio: media.volume }),
|
|
367
|
+
...(media.speed !== undefined && { speed: media.speed }),
|
|
368
|
+
...(media.time !== undefined && { time: media.time }),
|
|
369
|
+
...(media.crop !== undefined && { crop: media.crop }),
|
|
399
370
|
...(sourceStart !== undefined && { sourceStart }),
|
|
400
371
|
...(sourceEnd !== undefined && { sourceEnd }),
|
|
401
372
|
});
|
|
402
373
|
}
|
|
403
|
-
throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Validates an inline `html` overlay fragment. It must be non-empty and must
|
|
407
|
-
* not contain document-level tags (`<!doctype>`, `<html>`, `<head>`, `<body>`):
|
|
408
|
-
* screenci wraps the markup in its own document before rasterizing, so a full
|
|
409
|
-
* document here would nest documents and break the capture. Mirrors the
|
|
410
|
-
* fragment contract of a React `element`.
|
|
411
|
-
*/
|
|
412
|
-
function validateInlineHtmlFragment(name, html) {
|
|
413
|
-
if (html.trim().length === 0) {
|
|
414
|
-
throw new Error(`[screenci] Overlay "${name}" inline "html" must not be empty.`);
|
|
415
|
-
}
|
|
416
|
-
const lower = html.toLowerCase();
|
|
417
|
-
const forbidden = [
|
|
418
|
-
{ token: '<!doctype', label: '<!doctype>' },
|
|
419
|
-
{ token: '<html', label: '<html>' },
|
|
420
|
-
{ token: '<head', label: '<head>' },
|
|
421
|
-
{ token: '<body', label: '<body>' },
|
|
422
|
-
];
|
|
423
|
-
for (const { token, label } of forbidden) {
|
|
424
|
-
if (lower.includes(token)) {
|
|
425
|
-
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.`);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
validateSingleRootElement(name, html);
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* HTML void elements: they never have a closing tag, so they do not open a
|
|
432
|
-
* nesting level when counting top-level nodes.
|
|
433
|
-
*/
|
|
434
|
-
const VOID_ELEMENTS = new Set([
|
|
435
|
-
'area',
|
|
436
|
-
'base',
|
|
437
|
-
'br',
|
|
438
|
-
'col',
|
|
439
|
-
'embed',
|
|
440
|
-
'hr',
|
|
441
|
-
'img',
|
|
442
|
-
'input',
|
|
443
|
-
'link',
|
|
444
|
-
'meta',
|
|
445
|
-
'param',
|
|
446
|
-
'source',
|
|
447
|
-
'track',
|
|
448
|
-
'wbr',
|
|
449
|
-
]);
|
|
450
|
-
/**
|
|
451
|
-
* Ensures an inline `html` fragment has exactly one top-level (root) element and
|
|
452
|
-
* no loose top-level text, so it wraps cleanly into screenci's overlay document
|
|
453
|
-
* and sizes predictably. Multiple siblings (for example `<div/><div/>`) or stray
|
|
454
|
-
* text outside the root are rejected. The markup inside the root may be anything,
|
|
455
|
-
* including `<script>`/`<style>`, which are left to the overlay renderer.
|
|
456
|
-
*
|
|
457
|
-
* This is a lightweight tag scanner, not a full HTML parser: it tracks nesting
|
|
458
|
-
* depth across opening, closing, void, and self-closing tags (skipping comments
|
|
459
|
-
* and quoted attribute values) which covers ordinary fragment markup.
|
|
460
|
-
*/
|
|
461
|
-
function validateSingleRootElement(name, html) {
|
|
462
|
-
const fail = () => {
|
|
463
|
-
throw new Error(`[screenci] Overlay "${name}" inline "html" must contain a single root element (for example '<div class="badge">New</div>'). Wrap multiple top-level nodes in one container.`);
|
|
464
|
-
};
|
|
465
|
-
// Drop comments so they never count as top-level content.
|
|
466
|
-
const stripped = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
467
|
-
const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
468
|
-
let depth = 0;
|
|
469
|
-
let rootElements = 0;
|
|
470
|
-
let lastIndex = 0;
|
|
471
|
-
let match;
|
|
472
|
-
while ((match = tagRe.exec(stripped)) !== null) {
|
|
473
|
-
const before = stripped.slice(lastIndex, match.index);
|
|
474
|
-
if (depth === 0 && before.trim().length > 0)
|
|
475
|
-
fail();
|
|
476
|
-
lastIndex = match.index + match[0].length;
|
|
477
|
-
const isClosing = match[1] === '/';
|
|
478
|
-
const tagName = match[2].toLowerCase();
|
|
479
|
-
const selfClosing = match[4] === '/';
|
|
480
|
-
if (isClosing) {
|
|
481
|
-
depth = Math.max(0, depth - 1);
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
if (depth === 0)
|
|
485
|
-
rootElements += 1;
|
|
486
|
-
if (!selfClosing && !VOID_ELEMENTS.has(tagName))
|
|
487
|
-
depth += 1;
|
|
488
|
-
}
|
|
489
|
-
if (stripped.slice(lastIndex).trim().length > 0 && depth === 0)
|
|
490
|
-
fail();
|
|
491
|
-
if (rootElements !== 1)
|
|
492
|
-
fail();
|
|
493
|
-
}
|
|
494
|
-
async function renderElementToMarkup(name, element) {
|
|
495
|
-
let reactDomServer;
|
|
496
|
-
let react;
|
|
497
|
-
try {
|
|
498
|
-
reactDomServer = (await import('react-dom/server'));
|
|
499
|
-
react = (await import('react'));
|
|
500
|
-
}
|
|
501
|
-
catch {
|
|
502
|
-
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.`);
|
|
503
|
-
}
|
|
504
|
-
// Playwright's JSX runtime produces `__pw_type` nodes rather than real React
|
|
505
|
-
// elements; convert them (invoking function components, whose bodies are also
|
|
506
|
-
// pw-jsx) before handing the tree to react-dom.
|
|
507
|
-
const renderable = isPwJsxNode(element)
|
|
508
|
-
? pwJsxToReactNode(element, react)
|
|
509
|
-
: element;
|
|
510
|
-
return reactDomServer.renderToStaticMarkup(renderable);
|
|
511
|
-
}
|
|
512
|
-
function isPwJsxNode(value) {
|
|
513
|
-
return (typeof value === 'object' &&
|
|
514
|
-
value !== null &&
|
|
515
|
-
value.__pw_type === 'jsx');
|
|
516
|
-
}
|
|
517
|
-
function isPwFragment(type) {
|
|
518
|
-
return (typeof type === 'object' &&
|
|
519
|
-
type !== null &&
|
|
520
|
-
type.__pw_jsx_fragment === true);
|
|
521
|
-
}
|
|
522
|
-
function pwChildrenToArray(children) {
|
|
523
|
-
if (children === undefined || children === null)
|
|
524
|
-
return [];
|
|
525
|
-
return Array.isArray(children) ? children : [children];
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Converts a Playwright JSX node tree into real React nodes. Function components
|
|
529
|
-
* are invoked (their bodies are pw-jsx too) and their output converted, so the
|
|
530
|
-
* result is a host-element/primitive tree that react-dom can render statically.
|
|
531
|
-
*/
|
|
532
|
-
function pwJsxToReactNode(node, react) {
|
|
533
|
-
const createElement = react.createElement;
|
|
534
|
-
if (Array.isArray(node)) {
|
|
535
|
-
return node.map((child) => pwJsxToReactNode(child, react));
|
|
536
|
-
}
|
|
537
|
-
if (!isPwJsxNode(node))
|
|
538
|
-
return node;
|
|
539
|
-
const { type, props } = node;
|
|
540
|
-
if (isPwFragment(type)) {
|
|
541
|
-
const kids = pwChildrenToArray(props?.children).map((c) => pwJsxToReactNode(c, react));
|
|
542
|
-
return createElement(react.Fragment, null, ...kids);
|
|
543
|
-
}
|
|
544
|
-
if (typeof type === 'function') {
|
|
545
|
-
return pwJsxToReactNode(type(props ?? {}), react);
|
|
546
|
-
}
|
|
547
|
-
const { children, ...rest } = props ?? {};
|
|
548
|
-
const kids = pwChildrenToArray(children).map((c) => pwJsxToReactNode(c, react));
|
|
549
|
-
return createElement(type, rest, ...kids);
|
|
374
|
+
throw new Error(`[screenci] Overlay "${name}" must use one of: .tsx, .html, .svg, .png, .mp4. Received: ${path}`);
|
|
550
375
|
}
|
|
551
376
|
async function readHtmlOverlayFile(path) {
|
|
552
377
|
const testFilePath = getScreenCIRuntimeContext().testFilePath;
|
|
@@ -555,7 +380,7 @@ async function readHtmlOverlayFile(path) {
|
|
|
555
380
|
}
|
|
556
381
|
/**
|
|
557
382
|
* Builds overlay controllers for Studio-managed overlays declared via
|
|
558
|
-
* `video.
|
|
383
|
+
* `video.overlays(editable([...]))`. Their file (`.svg`, `.png`, or `.mp4`),
|
|
559
384
|
* placement, image duration, and video audio level are configured on the
|
|
560
385
|
* ScreenCI Studio page instead of in code. Each name becomes a callable overlay
|
|
561
386
|
* controller with the same timeline behavior as a {@link createOverlays}
|
|
@@ -609,13 +434,20 @@ function resolveOverlayAnchor(until) {
|
|
|
609
434
|
}
|
|
610
435
|
/**
|
|
611
436
|
* Resolves a relative length string (`.for('2s')` or a config `duration`) into
|
|
612
|
-
* milliseconds.
|
|
613
|
-
*
|
|
614
|
-
* an absolute position
|
|
437
|
+
* milliseconds. Numbers are already milliseconds. String seconds and timecodes
|
|
438
|
+
* are accepted; a percentage is rejected because a relative length has nothing
|
|
439
|
+
* to take a percentage of (use `.until('<n>%')` for an absolute position
|
|
440
|
+
* instead).
|
|
615
441
|
*/
|
|
616
442
|
function resolveRelativeDuration(value, label) {
|
|
443
|
+
if (typeof value === 'number') {
|
|
444
|
+
if (!isFiniteNonNegative(value)) {
|
|
445
|
+
throw new Error(`[screenci] ${label} must be a finite number of milliseconds greater than or equal to 0. Received: ${String(value)}.`);
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
617
449
|
if (typeof value !== 'string') {
|
|
618
|
-
throw new Error(`[screenci] ${label} must be a time string such as '2s' or '0:02', got ${typeof value}.`);
|
|
450
|
+
throw new Error(`[screenci] ${label} must be a time string such as '2s' or '0:02', or a number of milliseconds, got ${typeof value}.`);
|
|
619
451
|
}
|
|
620
452
|
const parsed = parseTimelineOffset(value);
|
|
621
453
|
if (parsed.kind === 'percent') {
|
|
@@ -623,7 +455,7 @@ function resolveRelativeDuration(value, label) {
|
|
|
623
455
|
}
|
|
624
456
|
return parsed.ms;
|
|
625
457
|
}
|
|
626
|
-
/** Parses an optional config `duration` (
|
|
458
|
+
/** Parses an optional config `duration` (relative time string or ms number) into ms. */
|
|
627
459
|
function resolveConfigDuration(name, duration) {
|
|
628
460
|
if (duration === undefined)
|
|
629
461
|
return undefined;
|
|
@@ -775,14 +607,15 @@ function createFileOverlayController(name, resolved) {
|
|
|
775
607
|
});
|
|
776
608
|
}
|
|
777
609
|
/**
|
|
778
|
-
* An overlay rendered to a transparent PNG at recording time, from either
|
|
779
|
-
*
|
|
610
|
+
* An overlay rendered to a transparent PNG at recording time, from either a full
|
|
611
|
+
* `.html` document or a bundled `.tsx` page. `getDocument` produces the full
|
|
612
|
+
* overlay document to rasterize.
|
|
780
613
|
*/
|
|
781
|
-
function createRenderedOverlayController(name,
|
|
782
|
-
// The
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
614
|
+
function createRenderedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, durationMs, renderOpts = {}) {
|
|
615
|
+
// The document and placement are resolved during the test (cheap: a file read
|
|
616
|
+
// or an esbuild bundle, plus a boundingBox read for `over`), but rasterization
|
|
617
|
+
// (a browser screenshot) is deferred to after the test so identical overlays
|
|
618
|
+
// render once. See overlayFlush.ts.
|
|
786
619
|
let resolvedHtml;
|
|
787
620
|
let resolvedPlacement;
|
|
788
621
|
let skipped = false;
|
|
@@ -822,8 +655,7 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
|
|
|
822
655
|
kind: 'image',
|
|
823
656
|
name,
|
|
824
657
|
html: resolvedHtml,
|
|
825
|
-
|
|
826
|
-
capturePadding: renderOpts.capturePadding ?? 0,
|
|
658
|
+
...(renderOpts.awaitMount === true && { awaitMount: true }),
|
|
827
659
|
deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
|
|
828
660
|
},
|
|
829
661
|
});
|
|
@@ -840,16 +672,18 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
|
|
|
840
672
|
}
|
|
841
673
|
const { placement, sizePx } = await resolvePlacement(placementSource);
|
|
842
674
|
resolvedPlacement = placement;
|
|
843
|
-
const
|
|
675
|
+
const document = await getDocument();
|
|
844
676
|
resolvedHtml =
|
|
845
|
-
sizePx !== undefined
|
|
677
|
+
sizePx !== undefined
|
|
678
|
+
? injectOverlayRootSize(document, sizePx)
|
|
679
|
+
: document;
|
|
846
680
|
},
|
|
847
681
|
});
|
|
848
682
|
}
|
|
849
683
|
/**
|
|
850
684
|
* An animated overlay rendered to a transparent clip, from either an HTML file
|
|
851
685
|
* or a React element. The capture length is resolved from the call argument or
|
|
852
|
-
* config `
|
|
686
|
+
* config `duration`; `start()`/`end()` requires a config `duration` (the
|
|
853
687
|
* capture length is otherwise unknown).
|
|
854
688
|
*
|
|
855
689
|
* Markup and duration are captured during the test; the clip itself is encoded
|
|
@@ -857,7 +691,7 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
|
|
|
857
691
|
* the recording, the old "hide block" that cut capture wall-clock from the
|
|
858
692
|
* timeline is no longer needed.
|
|
859
693
|
*/
|
|
860
|
-
function createAnimatedOverlayController(name,
|
|
694
|
+
function createAnimatedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, fps, configDurationMs, renderOpts = {}) {
|
|
861
695
|
let resolved;
|
|
862
696
|
let skipped = false;
|
|
863
697
|
const resolveDurationMs = (mode) => {
|
|
@@ -898,8 +732,7 @@ function createAnimatedOverlayController(name, getMarkup, placementSource, fullS
|
|
|
898
732
|
kind: 'animation',
|
|
899
733
|
name,
|
|
900
734
|
html: resolved.html,
|
|
901
|
-
|
|
902
|
-
capturePadding: renderOpts.capturePadding ?? 0,
|
|
735
|
+
...(renderOpts.awaitMount === true && { awaitMount: true }),
|
|
903
736
|
deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
|
|
904
737
|
fps: fps ?? DEFAULT_ANIMATION_FPS,
|
|
905
738
|
durationMs: resolved.durationMs,
|
|
@@ -915,9 +748,11 @@ function createAnimatedOverlayController(name, getMarkup, placementSource, fullS
|
|
|
915
748
|
}
|
|
916
749
|
const durationMs = resolveDurationMs(mode);
|
|
917
750
|
const { placement, sizePx } = await resolvePlacement(placementSource);
|
|
918
|
-
const
|
|
751
|
+
const document = await getDocument();
|
|
919
752
|
resolved = {
|
|
920
|
-
html: sizePx !== undefined
|
|
753
|
+
html: sizePx !== undefined
|
|
754
|
+
? injectOverlayRootSize(document, sizePx)
|
|
755
|
+
: document,
|
|
921
756
|
durationMs,
|
|
922
757
|
...(placement !== undefined && { placement }),
|
|
923
758
|
};
|
|
@@ -929,7 +764,8 @@ function getAssetExtension(path) {
|
|
|
929
764
|
if (dotIndex === -1)
|
|
930
765
|
return null;
|
|
931
766
|
const extension = path.slice(dotIndex).toLowerCase();
|
|
932
|
-
if (extension === '.
|
|
767
|
+
if (extension === '.tsx' ||
|
|
768
|
+
extension === '.html' ||
|
|
933
769
|
extension === '.svg' ||
|
|
934
770
|
extension === '.png' ||
|
|
935
771
|
extension === '.mp4') {
|
|
@@ -1069,11 +905,8 @@ function resolvePlacementSource(name, config, flags) {
|
|
|
1069
905
|
if (config.over === undefined) {
|
|
1070
906
|
return { kind: 'fixed', placement: resolveOverlayPlacement(name, config) };
|
|
1071
907
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
(flags.hasPath && getAssetExtension(config.path ?? '') === '.html');
|
|
1075
|
-
if (!isRendered) {
|
|
1076
|
-
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).`);
|
|
908
|
+
if (!flags.isRendered) {
|
|
909
|
+
throw new Error(`[screenci] Overlay "${name}" can only use "over" with a .html or .tsx page overlay (the overlay is sized to the element's box).`);
|
|
1077
910
|
}
|
|
1078
911
|
if (config.fill !== undefined) {
|
|
1079
912
|
throw new Error(`[screenci] Overlay "${name}" cannot set both "over" and "fill".`);
|
|
@@ -1113,13 +946,25 @@ async function resolvePlacement(source) {
|
|
|
1113
946
|
};
|
|
1114
947
|
}
|
|
1115
948
|
/**
|
|
1116
|
-
*
|
|
1117
|
-
* PNG carries the element's
|
|
1118
|
-
*
|
|
1119
|
-
* content should fill the
|
|
949
|
+
* Injects a fixed CSS-pixel size for the overlay root into a full overlay
|
|
950
|
+
* document, so the rasterized PNG carries the element's box (used by `over`,
|
|
951
|
+
* which sizes the overlay to a locator). The renderer then lands it exactly on
|
|
952
|
+
* the element's box. The page's content should fill the root
|
|
953
|
+
* (`width:100%;height:100%`). Applied by inserting a `<style>` right after
|
|
954
|
+
* `<head>` (or at the start of the document if there is no head), sizing
|
|
955
|
+
* `#screenci-overlay-root` and falling back to `body`.
|
|
1120
956
|
*/
|
|
1121
|
-
function
|
|
1122
|
-
|
|
957
|
+
function injectOverlayRootSize(document, size) {
|
|
958
|
+
const style = `<style>html,body{margin:0}` +
|
|
959
|
+
`#screenci-overlay-root,body{` +
|
|
960
|
+
`width:${size.width}px;height:${size.height}px;box-sizing:border-box}` +
|
|
961
|
+
`</style>`;
|
|
962
|
+
const headMatch = /<head[^>]*>/i.exec(document);
|
|
963
|
+
if (headMatch !== null) {
|
|
964
|
+
const at = headMatch.index + headMatch[0].length;
|
|
965
|
+
return document.slice(0, at) + style + document.slice(at);
|
|
966
|
+
}
|
|
967
|
+
return style + document;
|
|
1123
968
|
}
|
|
1124
969
|
function toRecordedFileStart(name, resolved, mode) {
|
|
1125
970
|
if (resolved.kind === 'image') {
|