screenci 0.0.67 → 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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.d.ts +3 -18
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +105 -154
  5. package/dist/cli.js.map +1 -1
  6. package/dist/docs/manifest.d.ts +13 -13
  7. package/dist/docs/manifest.js +4 -4
  8. package/dist/docs/manifest.js.map +1 -1
  9. package/dist/docs/video-sources/studio.video.d.ts +2 -0
  10. package/dist/docs/video-sources/studio.video.d.ts.map +1 -0
  11. package/dist/docs/video-sources/studio.video.js +57 -0
  12. package/dist/docs/video-sources/studio.video.js.map +1 -0
  13. package/dist/e2e/htmlRasterizer.e2e.d.ts +2 -0
  14. package/dist/e2e/htmlRasterizer.e2e.d.ts.map +1 -0
  15. package/dist/e2e/htmlRasterizer.e2e.js +107 -0
  16. package/dist/e2e/htmlRasterizer.e2e.js.map +1 -0
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/asset.d.ts +141 -53
  22. package/dist/src/asset.d.ts.map +1 -1
  23. package/dist/src/asset.js +533 -78
  24. package/dist/src/asset.js.map +1 -1
  25. package/dist/src/audio.d.ts +113 -0
  26. package/dist/src/audio.d.ts.map +1 -0
  27. package/dist/src/audio.js +204 -0
  28. package/dist/src/audio.js.map +1 -0
  29. package/dist/src/changeFocus.d.ts.map +1 -1
  30. package/dist/src/changeFocus.js +17 -3
  31. package/dist/src/changeFocus.js.map +1 -1
  32. package/dist/src/defaults.d.ts +7 -1
  33. package/dist/src/defaults.d.ts.map +1 -1
  34. package/dist/src/defaults.js +9 -2
  35. package/dist/src/defaults.js.map +1 -1
  36. package/dist/src/events.d.ts +138 -6
  37. package/dist/src/events.d.ts.map +1 -1
  38. package/dist/src/events.js +80 -2
  39. package/dist/src/events.js.map +1 -1
  40. package/dist/src/htmlRasterizer.d.ts +110 -0
  41. package/dist/src/htmlRasterizer.d.ts.map +1 -0
  42. package/dist/src/htmlRasterizer.js +364 -0
  43. package/dist/src/htmlRasterizer.js.map +1 -0
  44. package/dist/src/init.d.ts +17 -0
  45. package/dist/src/init.d.ts.map +1 -1
  46. package/dist/src/init.js +127 -7
  47. package/dist/src/init.js.map +1 -1
  48. package/dist/src/instrument.d.ts.map +1 -1
  49. package/dist/src/instrument.js +5 -3
  50. package/dist/src/instrument.js.map +1 -1
  51. package/dist/src/linkSession.d.ts +36 -0
  52. package/dist/src/linkSession.d.ts.map +1 -0
  53. package/dist/src/linkSession.js +122 -0
  54. package/dist/src/linkSession.js.map +1 -0
  55. package/dist/src/mouse.d.ts +4 -0
  56. package/dist/src/mouse.d.ts.map +1 -1
  57. package/dist/src/mouse.js +31 -10
  58. package/dist/src/mouse.js.map +1 -1
  59. package/dist/src/performance.d.ts +41 -0
  60. package/dist/src/performance.d.ts.map +1 -0
  61. package/dist/src/performance.js +49 -0
  62. package/dist/src/performance.js.map +1 -0
  63. package/dist/src/recording.d.ts +1 -1
  64. package/dist/src/recording.d.ts.map +1 -1
  65. package/dist/src/recordingData.d.ts +2 -2
  66. package/dist/src/recordingData.d.ts.map +1 -1
  67. package/dist/src/runtimeContext.d.ts +38 -0
  68. package/dist/src/runtimeContext.d.ts.map +1 -1
  69. package/dist/src/runtimeContext.js +25 -0
  70. package/dist/src/runtimeContext.js.map +1 -1
  71. package/dist/src/runtimeMode.d.ts +7 -0
  72. package/dist/src/runtimeMode.d.ts.map +1 -1
  73. package/dist/src/runtimeMode.js +10 -0
  74. package/dist/src/runtimeMode.js.map +1 -1
  75. package/dist/src/types.d.ts +40 -0
  76. package/dist/src/types.d.ts.map +1 -1
  77. package/dist/src/types.js.map +1 -1
  78. package/dist/src/video.d.ts +34 -1
  79. package/dist/src/video.d.ts.map +1 -1
  80. package/dist/src/video.js +120 -19
  81. package/dist/src/video.js.map +1 -1
  82. package/dist/src/voices.d.ts +22 -9
  83. package/dist/src/voices.d.ts.map +1 -1
  84. package/dist/src/voices.js +6 -24
  85. package/dist/src/voices.js.map +1 -1
  86. package/dist/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +16 -2
  88. package/skills/screenci/SKILL.md +27 -5
  89. package/skills/screenci/references/init.md +2 -0
  90. 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 { getScreenCIRuntimeContext, getRuntimeAssetRecorder, setRuntimeAssetRecorder, } from './runtimeContext.js';
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
- async function validateAssetPath(assetPath, testFilePath) {
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 asset controllers, one per key in the map.
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
- * Calling a controller marks the asset in the recording timeline.
36
- * `.svg`/`.png` assets require explicit `durationMs`; `.mp4` assets use their
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
- * ```ts
41
- * const assets = createAssets({
42
- * logo: { path: './logo.png', durationMs: 1200, fullScreen: false },
43
- * intro: { path: './intro.mp4', fullScreen: true },
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 assets.intro()
93
+ * await overlays.intro()
48
94
  * await page.goto('/dashboard')
49
- * await assets.logo()
95
+ * await overlays.logo(1200)
50
96
  * })
51
97
  * ```
52
98
  */
53
- export function createAssets(assetsMap) {
99
+ export function createOverlays(overlays) {
54
100
  const result = {};
55
- for (const name in assetsMap) {
56
- const config = assetsMap[name];
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 asset controllers whose files and display options are
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 asset controller with the same timeline
69
- * behavior as {@link createAssets} controllers. The file (`.svg`, `.png`, or
70
- * `.mp4`), full-screen mode, image duration, and video audio level all come
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 assets = createStudioAssets('intro', 'logo')
298
+ * const overlays = createStudioOverlays('intro', 'logo')
80
299
  *
81
300
  * video('Product demo', async ({ page }) => {
82
- * await assets.intro()
301
+ * await overlays.intro()
83
302
  * await page.goto('/dashboard')
84
- * await assets.logo()
303
+ * await overlays.logo()
85
304
  * })
86
305
  * ```
87
306
  */
88
- export function createStudioAssets(...keys) {
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 asset key "${key}" passed to createStudioAssets. Asset keys must be unique.`);
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 createStudioAssetController(name) {
103
- return () => {
104
- const activeRecorder = getRuntimeAssetRecorder();
105
- activeRecorder.addStudioAssetStart(name);
106
- return Promise.resolve();
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 createAssetController(name, config) {
110
- return async () => {
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(config.path, testFilePath);
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
- const activeRecorder = getRuntimeAssetRecorder();
116
- activeRecorder.addAssetStart(name, toRecordedAssetStart(name, config));
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 === '.svg' || extension === '.png' || extension === '.mp4') {
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 validateAssetConfig(name, config) {
131
- const extension = getAssetExtension(config.path);
132
- if (extension === null) {
133
- throw new Error(`[screenci] Asset "${name}" must use one of: .svg, .png, .mp4. Received: ${config.path}`);
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
- if (extension === '.svg' || extension === '.png') {
136
- if ('audio' in config && config.audio !== undefined) {
137
- throw new Error(`[screenci] Asset "${name}" (${config.path}) is an image asset and must not provide audio. Use durationMs instead.`);
138
- }
139
- if (!('durationMs' in config) ||
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
- if ('durationMs' in config && config.durationMs !== undefined) {
148
- throw new Error(`[screenci] Asset "${name}" (${config.path}) is a video asset and must not provide durationMs. Its natural media duration is used instead.`);
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
- if ('audio' in config &&
151
- config.audio !== undefined &&
152
- (!Number.isFinite(config.audio) || config.audio < 0 || config.audio > 1)) {
153
- throw new Error(`[screenci] Asset "${name}" (${config.path}) must provide a finite audio value between 0 and 1 for .mp4 assets when audio is specified. Use audio: 0 for silent playback.`);
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
- function toRecordedAssetStart(name, config) {
157
- const extension = getAssetExtension(config.path);
158
- if (extension === '.svg' || extension === '.png') {
159
- const imageConfig = config;
160
- return {
161
- kind: 'image',
162
- path: imageConfig.path,
163
- durationMs: imageConfig.durationMs,
164
- fullScreen: imageConfig.fullScreen,
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 (extension === '.mp4') {
168
- const videoConfig = config;
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: 'video',
171
- path: videoConfig.path,
172
- audio: videoConfig.audio ?? 1,
173
- fullScreen: videoConfig.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
- throw new Error(`[screenci] Asset "${name}" has an unsupported path: ${config.path}`);
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