screenci 0.0.69 → 0.0.71

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 (144) hide show
  1. package/README.md +45 -122
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +68 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/docs/manifest.d.ts +81 -50
  7. package/dist/docs/manifest.d.ts.map +1 -1
  8. package/dist/docs/manifest.js +36 -17
  9. package/dist/docs/manifest.js.map +1 -1
  10. package/dist/docs/video-sources/installation.screenci.d.ts +2 -0
  11. package/dist/docs/video-sources/installation.screenci.d.ts.map +1 -0
  12. package/dist/docs/video-sources/{installation.video.js → installation.screenci.js} +1 -1
  13. package/dist/docs/video-sources/installation.screenci.js.map +1 -0
  14. package/dist/docs/video-sources/landing.screenci.d.ts +2 -0
  15. package/dist/docs/video-sources/landing.screenci.d.ts.map +1 -0
  16. package/dist/docs/video-sources/{landing.video.js → landing.screenci.js} +1 -1
  17. package/dist/docs/video-sources/landing.screenci.js.map +1 -0
  18. package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts +2 -0
  19. package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts.map +1 -0
  20. package/dist/docs/video-sources/locator-highlight-animated.screenci.js +41 -0
  21. package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -0
  22. package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts +2 -0
  23. package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts.map +1 -0
  24. package/dist/docs/video-sources/locator-highlight-still.screenci.js +19 -0
  25. package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -0
  26. package/dist/docs/video-sources/product-pitch.screenci.d.ts +2 -0
  27. package/dist/docs/video-sources/product-pitch.screenci.d.ts.map +1 -0
  28. package/dist/docs/video-sources/product-pitch.screenci.js +136 -0
  29. package/dist/docs/video-sources/product-pitch.screenci.js.map +1 -0
  30. package/dist/docs/video-sources/public-urls.screenci.d.ts +2 -0
  31. package/dist/docs/video-sources/public-urls.screenci.d.ts.map +1 -0
  32. package/dist/docs/video-sources/public-urls.screenci.js +34 -0
  33. package/dist/docs/video-sources/public-urls.screenci.js.map +1 -0
  34. package/dist/docs/video-sources/studio.screenci.d.ts +2 -0
  35. package/dist/docs/video-sources/studio.screenci.d.ts.map +1 -0
  36. package/dist/docs/video-sources/{studio.video.js → studio.screenci.js} +1 -1
  37. package/dist/docs/video-sources/studio.screenci.js.map +1 -0
  38. package/dist/docs/videos.d.ts +28 -18
  39. package/dist/docs/videos.d.ts.map +1 -1
  40. package/dist/docs/videos.js +14 -5
  41. package/dist/docs/videos.js.map +1 -1
  42. package/dist/e2e/screenshot.e2e.d.ts +2 -0
  43. package/dist/e2e/screenshot.e2e.d.ts.map +1 -0
  44. package/dist/e2e/screenshot.e2e.js +73 -0
  45. package/dist/e2e/screenshot.e2e.js.map +1 -0
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/src/asset.d.ts +119 -15
  51. package/dist/src/asset.d.ts.map +1 -1
  52. package/dist/src/asset.js +310 -124
  53. package/dist/src/asset.js.map +1 -1
  54. package/dist/src/audio.d.ts +14 -0
  55. package/dist/src/audio.d.ts.map +1 -1
  56. package/dist/src/audio.js +4 -1
  57. package/dist/src/audio.js.map +1 -1
  58. package/dist/src/config.d.ts.map +1 -1
  59. package/dist/src/config.js +9 -3
  60. package/dist/src/config.js.map +1 -1
  61. package/dist/src/contextOptions.d.ts +48 -0
  62. package/dist/src/contextOptions.d.ts.map +1 -0
  63. package/dist/src/contextOptions.js +71 -0
  64. package/dist/src/contextOptions.js.map +1 -0
  65. package/dist/src/crop.d.ts +93 -0
  66. package/dist/src/crop.d.ts.map +1 -0
  67. package/dist/src/crop.js +133 -0
  68. package/dist/src/crop.js.map +1 -0
  69. package/dist/src/cue.d.ts +17 -4
  70. package/dist/src/cue.d.ts.map +1 -1
  71. package/dist/src/cue.js +31 -3
  72. package/dist/src/cue.js.map +1 -1
  73. package/dist/src/defaults.d.ts +6 -0
  74. package/dist/src/defaults.d.ts.map +1 -1
  75. package/dist/src/defaults.js +6 -0
  76. package/dist/src/defaults.js.map +1 -1
  77. package/dist/src/dimensions.d.ts +6 -0
  78. package/dist/src/dimensions.d.ts.map +1 -1
  79. package/dist/src/dimensions.js +15 -0
  80. package/dist/src/dimensions.js.map +1 -1
  81. package/dist/src/events.d.ts +152 -10
  82. package/dist/src/events.d.ts.map +1 -1
  83. package/dist/src/events.js +61 -3
  84. package/dist/src/events.js.map +1 -1
  85. package/dist/src/htmlRasterizer.d.ts +43 -0
  86. package/dist/src/htmlRasterizer.d.ts.map +1 -1
  87. package/dist/src/htmlRasterizer.js +32 -10
  88. package/dist/src/htmlRasterizer.js.map +1 -1
  89. package/dist/src/init.d.ts.map +1 -1
  90. package/dist/src/init.js +19 -8
  91. package/dist/src/init.js.map +1 -1
  92. package/dist/src/overlayFlush.d.ts +18 -0
  93. package/dist/src/overlayFlush.d.ts.map +1 -0
  94. package/dist/src/overlayFlush.js +56 -0
  95. package/dist/src/overlayFlush.js.map +1 -0
  96. package/dist/src/overlayRect.d.ts +69 -0
  97. package/dist/src/overlayRect.d.ts.map +1 -0
  98. package/dist/src/overlayRect.js +55 -0
  99. package/dist/src/overlayRect.js.map +1 -0
  100. package/dist/src/recording.d.ts +3 -2
  101. package/dist/src/recording.d.ts.map +1 -1
  102. package/dist/src/recordingData.d.ts +20 -0
  103. package/dist/src/recordingData.d.ts.map +1 -1
  104. package/dist/src/runtimeContext.d.ts +19 -1
  105. package/dist/src/runtimeContext.d.ts.map +1 -1
  106. package/dist/src/runtimeContext.js +15 -0
  107. package/dist/src/runtimeContext.js.map +1 -1
  108. package/dist/src/screenshot.d.ts +105 -0
  109. package/dist/src/screenshot.d.ts.map +1 -0
  110. package/dist/src/screenshot.js +188 -0
  111. package/dist/src/screenshot.js.map +1 -0
  112. package/dist/src/stillCapture.d.ts +68 -0
  113. package/dist/src/stillCapture.d.ts.map +1 -0
  114. package/dist/src/stillCapture.js +119 -0
  115. package/dist/src/stillCapture.js.map +1 -0
  116. package/dist/src/studio.d.ts +8 -11
  117. package/dist/src/studio.d.ts.map +1 -1
  118. package/dist/src/studio.js +2 -25
  119. package/dist/src/studio.js.map +1 -1
  120. package/dist/src/types.d.ts +100 -8
  121. package/dist/src/types.d.ts.map +1 -1
  122. package/dist/src/types.js.map +1 -1
  123. package/dist/src/video.d.ts +20 -0
  124. package/dist/src/video.d.ts.map +1 -1
  125. package/dist/src/video.js +71 -24
  126. package/dist/src/video.js.map +1 -1
  127. package/dist/tsconfig.tsbuildinfo +1 -1
  128. package/package.json +1 -1
  129. package/skills/screenci/SKILL.md +10 -10
  130. package/skills/screenci/references/init.md +1 -1
  131. package/skills/screenci/references/record.md +2 -2
  132. package/dist/docs/video-sources/cli.video.d.ts +0 -2
  133. package/dist/docs/video-sources/cli.video.d.ts.map +0 -1
  134. package/dist/docs/video-sources/cli.video.js +0 -36
  135. package/dist/docs/video-sources/cli.video.js.map +0 -1
  136. package/dist/docs/video-sources/installation.video.d.ts +0 -2
  137. package/dist/docs/video-sources/installation.video.d.ts.map +0 -1
  138. package/dist/docs/video-sources/installation.video.js.map +0 -1
  139. package/dist/docs/video-sources/landing.video.d.ts +0 -2
  140. package/dist/docs/video-sources/landing.video.d.ts.map +0 -1
  141. package/dist/docs/video-sources/landing.video.js.map +0 -1
  142. package/dist/docs/video-sources/studio.video.d.ts +0 -2
  143. package/dist/docs/video-sources/studio.video.d.ts.map +0 -1
  144. 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 { rasterizeHtmlOverlay, rasterizeAnimatedHtmlOverlay, } from './htmlRasterizer.js';
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
- * x: 0, y: 0, width: 1`); override any field independently.
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
- * logo: { path: 'logo.png', x: 0.05, y: 0.05, width: 0.2 },
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 `.video.tsx` files with its own automatic
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
- if (hasPath && hasElement) {
129
- throw new Error(`[screenci] Overlay "${name}" must provide only one of "path" or "element".`);
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 (!hasPath && !hasElement) {
132
- throw new Error(`[screenci] Overlay "${name}" must provide a "path" or an "element".`);
152
+ if (hasHtml) {
153
+ validateInlineHtmlFragment(name, config.html);
133
154
  }
134
- const placement = resolveOverlayPlacement(name, config);
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
- // React element: rendered to markup lazily at recording time.
156
- if (hasElement) {
157
- const element = config.element;
158
- const getMarkup = () => renderElementToMarkup(name, element);
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, placement, fullScreen, config.fps, config.durationMs, renderOpts);
194
+ return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
161
195
  }
162
- return createRenderedOverlayController(name, getMarkup, placement, fullScreen, config.durationMs, renderOpts);
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, placement, fullScreen, config.fps, config.durationMs, renderOpts);
218
+ return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
179
219
  }
180
- return createRenderedOverlayController(name, getMarkup, placement, fullScreen, config.durationMs, renderOpts);
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, placement, fullScreen, durationMs, renderOpts = {}) {
406
- let generated;
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, async () => {
409
- if (generated !== undefined || skipped)
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
- // Generating an overlay needs an active recording page and output dir.
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 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));
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 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).
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, placement, fullScreen, fps, configDurationMs, renderOpts = {}) {
445
- let generated;
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 (generated === undefined)
559
+ if (skipped || resolved === undefined)
463
560
  return;
464
- recorder.addAssetStart(name, {
561
+ recorder.addPendingAssetStart(name, {
465
562
  kind: 'animation',
466
- path: generated.path,
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 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
- };
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 entries = [
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
- 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)}`);
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}, 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.
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
- ? { relativeTo, x, y, height: config.height }
585
- : { relativeTo, x, y, width: config.width ?? 1 };
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
- 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);
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
- kind: 'image',
600
- path: generated.path,
601
- fileHash: generated.fileHash,
602
- ...(durationMs !== undefined && { durationMs }),
603
- fullScreen: resolved.fullScreen,
604
- placement: resolved.placement,
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
- placement: resolved.placement,
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