screenci 0.0.75 → 0.0.78

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 (226) hide show
  1. package/dist/cli.d.ts +62 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +699 -229
  4. package/dist/cli.js.map +1 -1
  5. package/dist/docs/manifest.d.ts +277 -91
  6. package/dist/docs/manifest.d.ts.map +1 -1
  7. package/dist/docs/manifest.js +101 -30
  8. package/dist/docs/manifest.js.map +1 -1
  9. package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts +2 -0
  10. package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts.map +1 -0
  11. package/dist/docs/video-sources/camera-and-zooming.screenci.js +57 -0
  12. package/dist/docs/video-sources/camera-and-zooming.screenci.js.map +1 -0
  13. package/dist/docs/video-sources/installation.screenci.js +19 -10
  14. package/dist/docs/video-sources/installation.screenci.js.map +1 -1
  15. package/dist/docs/video-sources/landing.screenci.js +6 -8
  16. package/dist/docs/video-sources/landing.screenci.js.map +1 -1
  17. package/dist/docs/video-sources/languages.screenci.d.ts +2 -0
  18. package/dist/docs/video-sources/languages.screenci.d.ts.map +1 -0
  19. package/dist/docs/video-sources/languages.screenci.js +68 -0
  20. package/dist/docs/video-sources/languages.screenci.js.map +1 -0
  21. package/dist/docs/video-sources/locator-highlight-animated.screenci.js +4 -5
  22. package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
  23. package/dist/docs/video-sources/locator-highlight-still.screenci.js +3 -4
  24. package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -1
  25. package/dist/docs/video-sources/narration.screenci.d.ts +2 -0
  26. package/dist/docs/video-sources/narration.screenci.d.ts.map +1 -0
  27. package/dist/docs/video-sources/narration.screenci.js +71 -0
  28. package/dist/docs/video-sources/narration.screenci.js.map +1 -0
  29. package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts +2 -0
  30. package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts.map +1 -0
  31. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js +60 -0
  32. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js.map +1 -0
  33. package/dist/docs/video-sources/speaking-style.screenci.d.ts +2 -0
  34. package/dist/docs/video-sources/speaking-style.screenci.d.ts.map +1 -0
  35. package/dist/docs/video-sources/speaking-style.screenci.js +76 -0
  36. package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -0
  37. package/dist/docs/video-sources/studio.screenci.js +8 -10
  38. package/dist/docs/video-sources/studio.screenci.js.map +1 -1
  39. package/dist/docs/video-sources/vertical-video.screenci.d.ts +2 -0
  40. package/dist/docs/video-sources/vertical-video.screenci.d.ts.map +1 -0
  41. package/dist/docs/video-sources/vertical-video.screenci.js +58 -0
  42. package/dist/docs/video-sources/vertical-video.screenci.js.map +1 -0
  43. package/dist/docs/videos.d.ts +20 -0
  44. package/dist/docs/videos.d.ts.map +1 -1
  45. package/dist/docs/videos.js +25 -0
  46. package/dist/docs/videos.js.map +1 -1
  47. package/dist/e2e/instrument.e2e.js +60 -1
  48. package/dist/e2e/instrument.e2e.js.map +1 -1
  49. package/dist/e2e/redact.e2e.d.ts +2 -0
  50. package/dist/e2e/redact.e2e.d.ts.map +1 -0
  51. package/dist/e2e/redact.e2e.js +161 -0
  52. package/dist/e2e/redact.e2e.js.map +1 -0
  53. package/dist/index.d.ts +12 -6
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +8 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/src/asset.d.ts +174 -14
  58. package/dist/src/asset.d.ts.map +1 -1
  59. package/dist/src/asset.js +320 -39
  60. package/dist/src/asset.js.map +1 -1
  61. package/dist/src/assetHash.d.ts +27 -0
  62. package/dist/src/assetHash.d.ts.map +1 -0
  63. package/dist/src/assetHash.js +0 -0
  64. package/dist/src/assetHash.js.map +1 -0
  65. package/dist/src/audio.d.ts +20 -1
  66. package/dist/src/audio.d.ts.map +1 -1
  67. package/dist/src/audio.js +126 -24
  68. package/dist/src/audio.js.map +1 -1
  69. package/dist/src/autoZoom.d.ts.map +1 -1
  70. package/dist/src/autoZoom.js +3 -1
  71. package/dist/src/autoZoom.js.map +1 -1
  72. package/dist/src/browserLaunchOptions.d.ts +24 -2
  73. package/dist/src/browserLaunchOptions.d.ts.map +1 -1
  74. package/dist/src/browserLaunchOptions.js +57 -3
  75. package/dist/src/browserLaunchOptions.js.map +1 -1
  76. package/dist/src/builder.d.ts +229 -136
  77. package/dist/src/builder.d.ts.map +1 -1
  78. package/dist/src/builder.js +264 -77
  79. package/dist/src/builder.js.map +1 -1
  80. package/dist/src/callerFile.d.ts +12 -0
  81. package/dist/src/callerFile.d.ts.map +1 -0
  82. package/dist/src/callerFile.js +46 -0
  83. package/dist/src/callerFile.js.map +1 -0
  84. package/dist/src/changeFocus.d.ts.map +1 -1
  85. package/dist/src/changeFocus.js +109 -36
  86. package/dist/src/changeFocus.js.map +1 -1
  87. package/dist/src/config.d.ts.map +1 -1
  88. package/dist/src/config.js +15 -0
  89. package/dist/src/config.js.map +1 -1
  90. package/dist/src/cue.d.ts +29 -17
  91. package/dist/src/cue.d.ts.map +1 -1
  92. package/dist/src/cue.js +138 -61
  93. package/dist/src/cue.js.map +1 -1
  94. package/dist/src/declare.d.ts +63 -0
  95. package/dist/src/declare.d.ts.map +1 -0
  96. package/dist/src/declare.js +111 -0
  97. package/dist/src/declare.js.map +1 -0
  98. package/dist/src/defaults.d.ts +23 -0
  99. package/dist/src/defaults.d.ts.map +1 -1
  100. package/dist/src/defaults.js +27 -3
  101. package/dist/src/defaults.js.map +1 -1
  102. package/dist/src/events.d.ts +339 -25
  103. package/dist/src/events.d.ts.map +1 -1
  104. package/dist/src/events.js +250 -26
  105. package/dist/src/events.js.map +1 -1
  106. package/dist/src/htmlRasterizer.d.ts +10 -0
  107. package/dist/src/htmlRasterizer.d.ts.map +1 -1
  108. package/dist/src/htmlRasterizer.js +12 -2
  109. package/dist/src/htmlRasterizer.js.map +1 -1
  110. package/dist/src/init.d.ts +2 -0
  111. package/dist/src/init.d.ts.map +1 -1
  112. package/dist/src/init.js +81 -33
  113. package/dist/src/init.js.map +1 -1
  114. package/dist/src/instrument.d.ts.map +1 -1
  115. package/dist/src/instrument.js +164 -8
  116. package/dist/src/instrument.js.map +1 -1
  117. package/dist/src/locales.d.ts.map +1 -1
  118. package/dist/src/locales.js +1 -0
  119. package/dist/src/locales.js.map +1 -1
  120. package/dist/src/localize.d.ts +26 -17
  121. package/dist/src/localize.d.ts.map +1 -1
  122. package/dist/src/localize.js +19 -11
  123. package/dist/src/localize.js.map +1 -1
  124. package/dist/src/localizeRuntime.d.ts +29 -33
  125. package/dist/src/localizeRuntime.d.ts.map +1 -1
  126. package/dist/src/localizeRuntime.js +74 -49
  127. package/dist/src/localizeRuntime.js.map +1 -1
  128. package/dist/src/logger.d.ts +5 -0
  129. package/dist/src/logger.d.ts.map +1 -1
  130. package/dist/src/logger.js +7 -0
  131. package/dist/src/logger.js.map +1 -1
  132. package/dist/src/manualZoom.d.ts.map +1 -1
  133. package/dist/src/manualZoom.js +8 -5
  134. package/dist/src/manualZoom.js.map +1 -1
  135. package/dist/src/narrationVisibility.d.ts +19 -0
  136. package/dist/src/narrationVisibility.d.ts.map +1 -0
  137. package/dist/src/narrationVisibility.js +26 -0
  138. package/dist/src/narrationVisibility.js.map +1 -0
  139. package/dist/src/openBrowser.d.ts +34 -0
  140. package/dist/src/openBrowser.d.ts.map +1 -0
  141. package/dist/src/openBrowser.js +62 -0
  142. package/dist/src/openBrowser.js.map +1 -0
  143. package/dist/src/recordingData.d.ts +27 -0
  144. package/dist/src/recordingData.d.ts.map +1 -1
  145. package/dist/src/recordingSize.d.ts +32 -0
  146. package/dist/src/recordingSize.d.ts.map +1 -0
  147. package/dist/src/recordingSize.js +54 -0
  148. package/dist/src/recordingSize.js.map +1 -0
  149. package/dist/src/redact.d.ts +68 -0
  150. package/dist/src/redact.d.ts.map +1 -0
  151. package/dist/src/redact.js +138 -0
  152. package/dist/src/redact.js.map +1 -0
  153. package/dist/src/redactController.d.ts +61 -0
  154. package/dist/src/redactController.d.ts.map +1 -0
  155. package/dist/src/redactController.js +232 -0
  156. package/dist/src/redactController.js.map +1 -0
  157. package/dist/src/runtimeContext.d.ts +32 -0
  158. package/dist/src/runtimeContext.d.ts.map +1 -1
  159. package/dist/src/runtimeContext.js +21 -0
  160. package/dist/src/runtimeContext.js.map +1 -1
  161. package/dist/src/runtimeMode.d.ts +15 -7
  162. package/dist/src/runtimeMode.d.ts.map +1 -1
  163. package/dist/src/runtimeMode.js +17 -6
  164. package/dist/src/runtimeMode.js.map +1 -1
  165. package/dist/src/screenAudio.d.ts +88 -0
  166. package/dist/src/screenAudio.d.ts.map +1 -0
  167. package/dist/src/screenAudio.js +0 -0
  168. package/dist/src/screenAudio.js.map +1 -0
  169. package/dist/src/screenAudioSink.d.ts +46 -0
  170. package/dist/src/screenAudioSink.d.ts.map +1 -0
  171. package/dist/src/screenAudioSink.js +77 -0
  172. package/dist/src/screenAudioSink.js.map +1 -0
  173. package/dist/src/screenshot.d.ts +27 -29
  174. package/dist/src/screenshot.d.ts.map +1 -1
  175. package/dist/src/screenshot.js +39 -24
  176. package/dist/src/screenshot.js.map +1 -1
  177. package/dist/src/sourceTrim.d.ts +29 -0
  178. package/dist/src/sourceTrim.d.ts.map +1 -0
  179. package/dist/src/sourceTrim.js +76 -0
  180. package/dist/src/sourceTrim.js.map +1 -0
  181. package/dist/src/stillCapture.d.ts +5 -0
  182. package/dist/src/stillCapture.d.ts.map +1 -1
  183. package/dist/src/stillCapture.js +11 -1
  184. package/dist/src/stillCapture.js.map +1 -1
  185. package/dist/src/studio.d.ts +69 -57
  186. package/dist/src/studio.d.ts.map +1 -1
  187. package/dist/src/studio.js +16 -38
  188. package/dist/src/studio.js.map +1 -1
  189. package/dist/src/timelineOffset.d.ts +46 -0
  190. package/dist/src/timelineOffset.d.ts.map +1 -0
  191. package/dist/src/timelineOffset.js +66 -0
  192. package/dist/src/timelineOffset.js.map +1 -0
  193. package/dist/src/titleValidation.d.ts +38 -0
  194. package/dist/src/titleValidation.d.ts.map +1 -1
  195. package/dist/src/titleValidation.js +42 -0
  196. package/dist/src/titleValidation.js.map +1 -1
  197. package/dist/src/types.d.ts +218 -2
  198. package/dist/src/types.d.ts.map +1 -1
  199. package/dist/src/types.js +1 -1
  200. package/dist/src/types.js.map +1 -1
  201. package/dist/src/video.d.ts +74 -60
  202. package/dist/src/video.d.ts.map +1 -1
  203. package/dist/src/video.js +201 -39
  204. package/dist/src/video.js.map +1 -1
  205. package/dist/src/voiceSampleAudio.d.ts +34 -0
  206. package/dist/src/voiceSampleAudio.d.ts.map +1 -0
  207. package/dist/src/voiceSampleAudio.js +105 -0
  208. package/dist/src/voiceSampleAudio.js.map +1 -0
  209. package/dist/src/voices.d.ts +2 -2
  210. package/dist/src/voices.d.ts.map +1 -1
  211. package/dist/src/voices.js +8 -3
  212. package/dist/src/voices.js.map +1 -1
  213. package/dist/src/zoom.d.ts +1 -0
  214. package/dist/src/zoom.d.ts.map +1 -1
  215. package/dist/src/zoom.js +3 -0
  216. package/dist/src/zoom.js.map +1 -1
  217. package/dist/tsconfig.tsbuildinfo +1 -1
  218. package/package.json +3 -2
  219. package/skills/screenci/SKILL.md +2 -2
  220. package/skills/screenci/references/init.md +4 -3
  221. package/skills/screenci/references/record.md +1 -1
  222. package/templates/recordings/assets/logo.png +0 -0
  223. package/dist/docs/video-sources/public-urls.screenci.d.ts +0 -2
  224. package/dist/docs/video-sources/public-urls.screenci.d.ts.map +0 -1
  225. package/dist/docs/video-sources/public-urls.screenci.js +0 -41
  226. package/dist/docs/video-sources/public-urls.screenci.js.map +0 -1
package/dist/src/asset.js CHANGED
@@ -1,4 +1,9 @@
1
+ import { timelineAnchorFields, } from './events.js';
2
+ import { parseTimelineOffset } from './timelineOffset.js';
3
+ import { validateCrop, resolveSourceTrim } from './sourceTrim.js';
1
4
  import { overlayRect } from './overlayRect.js';
5
+ import { captureCallerFile } from './callerFile.js';
6
+ import { logger } from './logger.js';
2
7
  import { access, readFile } from 'fs/promises';
3
8
  import { dirname, resolve } from 'path';
4
9
  import { resolveRecordingTimingDuration } from './runtimeMode.js';
@@ -10,7 +15,66 @@ import { getScreenCIRuntimeContext, getRuntimeAssetRecorder, getRuntimePage, get
10
15
  * headroom for a boost while guarding against accidental extreme distortion.
11
16
  */
12
17
  export const MAX_AUDIO_LEVEL = 4;
13
- const registeredAssetPaths = new Set();
18
+ /** Brand identifying a {@link selected} render-dependency overlay input. */
19
+ const DEPENDENCY_INPUT_BRAND = '__screenciSelectedDependency';
20
+ /**
21
+ * Embed another render's output as an overlay (a "render dependency"). Pass the
22
+ * project-unique `name` of a video or screenshot; screenci embeds that target's
23
+ * selected render for the matching language (falling back to its latest finished
24
+ * render before anything is selected). When the target's selection changes, this
25
+ * recording's dependents automatically re-render to embed the new output.
26
+ *
27
+ * No local file is read for a `selected(...)` overlay: the medium and concrete
28
+ * output are looked up by the backend at render time. Screenshots may only embed
29
+ * other screenshots; videos may embed either.
30
+ *
31
+ * The embed plays the target's audio. Pass `{ inheritSubtitles: true }` to also
32
+ * carry the target's narration subtitles up into the surrounding video's subtitle
33
+ * track for the window it plays, wherever the surrounding video has no competing
34
+ * narration of its own (off by default).
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * video.overlays({ intro: selected('Intro Clip') })(
39
+ * 'Full Demo',
40
+ * async ({ page, overlays }) => {
41
+ * await overlays.intro()
42
+ * await page.goto('/dashboard')
43
+ * }
44
+ * )
45
+ * ```
46
+ */
47
+ export function selected(name, options) {
48
+ if (typeof name !== 'string' || name.trim().length === 0) {
49
+ throw new Error('[screenci] selected(name) requires the non-empty name of a video or screenshot to embed.');
50
+ }
51
+ return {
52
+ [DEPENDENCY_INPUT_BRAND]: true,
53
+ name,
54
+ config: options ?? {},
55
+ };
56
+ }
57
+ function isDependencyOverlayInput(value) {
58
+ return (typeof value === 'object' &&
59
+ value !== null &&
60
+ value[DEPENDENCY_INPUT_BRAND] === true);
61
+ }
62
+ /**
63
+ * Overlay file paths registered by {@link createOverlays}, each attributed to
64
+ * the `.screenci` script that declared it (or `null` when the caller could not
65
+ * be determined). Attribution lets {@link validateRegisteredAssetPaths} check
66
+ * only the assets a given recording's script declared, so unrelated assets
67
+ * registered by other test files sharing the worker are not validated against
68
+ * the wrong test file.
69
+ */
70
+ const registeredAssets = [];
71
+ function registerAssetPath(path) {
72
+ const ownerFile = captureCallerFile(import.meta.url);
73
+ if (registeredAssets.some((entry) => entry.path === path && entry.ownerFile === ownerFile)) {
74
+ return;
75
+ }
76
+ registeredAssets.push({ ownerFile, path });
77
+ }
14
78
  // One frame at 24fps — ensures at least one rendered frame captures each asset
15
79
  // state when an overlay is started and ended back-to-back.
16
80
  const ONE_FRAME_MS = 1000 / 24;
@@ -35,14 +99,26 @@ export function setActiveAssetRecorder(recorder) {
35
99
  resetAssetRuntimeState();
36
100
  }
37
101
  export function resetRegisteredAssetPaths() {
38
- registeredAssetPaths.clear();
102
+ registeredAssets.length = 0;
39
103
  }
40
104
  export function resetAssetChain() {
41
105
  resetAssetRuntimeState();
42
106
  }
107
+ /**
108
+ * Validates the overlay files declared by the `.screenci` script at
109
+ * {@link testFilePath} (plus any unattributed registrations), resolving each
110
+ * as-is and relative to that file. Throws "Asset file not found" on the first
111
+ * missing file. Wired into the record flow so a missing overlay fails fast
112
+ * before the test body runs, rather than only when the overlay is first shown.
113
+ *
114
+ * Assets attributed to a different test file are skipped: another script sharing
115
+ * the worker may legitimately reference a file that does not resolve here.
116
+ */
43
117
  export async function validateRegisteredAssetPaths(testFilePath) {
44
- for (const assetPath of registeredAssetPaths) {
45
- await validateAssetPath(assetPath, testFilePath);
118
+ for (const { ownerFile, path } of registeredAssets) {
119
+ if (ownerFile !== null && ownerFile !== testFilePath)
120
+ continue;
121
+ await validateAssetPath(path, testFilePath);
46
122
  }
47
123
  }
48
124
  /**
@@ -65,8 +141,27 @@ async function resolveExistingAssetPath(assetPath, testFilePath) {
65
141
  }
66
142
  throw new Error(`Asset file not found: ${assetPath}`);
67
143
  }
144
+ const warnedMissingOverlayPaths = new Set();
145
+ export function resetMissingOverlayWarnings() {
146
+ warnedMissingOverlayPaths.clear();
147
+ }
148
+ /**
149
+ * Checks that an overlay file exists. A missing file is not fatal: the overlay
150
+ * is recovered from a previous upload of this video (matched by name/path) at
151
+ * upload time, so a gitignored overlay file does not have to be committed. The
152
+ * overlay is composited by the renderer, not into the local recording, so a
153
+ * missing file does not change what is captured locally.
154
+ */
68
155
  async function validateAssetPath(assetPath, testFilePath) {
69
- await resolveExistingAssetPath(assetPath, testFilePath);
156
+ try {
157
+ await resolveExistingAssetPath(assetPath, testFilePath);
158
+ }
159
+ catch {
160
+ if (warnedMissingOverlayPaths.has(assetPath))
161
+ return;
162
+ warnedMissingOverlayPaths.add(assetPath);
163
+ logger.warn(`Locally missing overlay: ${assetPath}. It will be reused from a previous upload of this video if available, otherwise the upload fails.`);
164
+ }
70
165
  }
71
166
  /**
72
167
  * Creates a set of typed overlay controllers, one per key in the map. Each value
@@ -124,6 +219,12 @@ function isReactElementLike(value) {
124
219
  return value.__pw_type === 'jsx';
125
220
  }
126
221
  function buildOverlayController(name, input) {
222
+ // A render dependency (selected(...)) embeds another render's output. It is a
223
+ // plain (branded) object, so it is matched before the generic config path,
224
+ // which would otherwise reject it for having no path/element/html.
225
+ if (isDependencyOverlayInput(input)) {
226
+ return createDependencyOverlayController(name, input);
227
+ }
127
228
  // A factory is the only callable input. React elements and config objects are
128
229
  // plain objects, so this branch never captures them. The config (and its
129
230
  // validation) is built per call so props can vary placement and content.
@@ -158,7 +259,10 @@ function buildOverlayFromConfig(name, config) {
158
259
  hasPath,
159
260
  });
160
261
  const fullScreen = config.fill === 'screen';
262
+ const pinToScreen = config.pinToScreen === true;
161
263
  const animate = config.animate === true;
264
+ // Parse the relative `duration` string into ms once; reused by every branch.
265
+ const configDurationMs = resolveConfigDuration(name, config.duration);
162
266
  if (config.fps !== undefined && !animate) {
163
267
  throw new Error(`[screenci] Overlay "${name}" sets "fps" without "animate: true". "fps" only applies to animated overlays.`);
164
268
  }
@@ -191,9 +295,9 @@ function buildOverlayFromConfig(name, config) {
191
295
  ? () => renderElementToMarkup(name, config.element)
192
296
  : () => Promise.resolve(config.html);
193
297
  if (animate) {
194
- return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
298
+ return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, config.fps, configDurationMs, renderOpts);
195
299
  }
196
- return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
300
+ return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, configDurationMs, renderOpts);
197
301
  }
198
302
  const path = config.path;
199
303
  const extension = getAssetExtension(path);
@@ -210,35 +314,46 @@ function buildOverlayFromConfig(name, config) {
210
314
  extension !== '.html') {
211
315
  throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "css" or "capturePadding": they are only supported for HTML files and React elements.`);
212
316
  }
317
+ // crop applies to image and video files (not rasterized .html); source trim
318
+ // (start/end) re-times a moving picture, so it applies to .mp4 videos only.
319
+ if (config.crop !== undefined && extension === '.html') {
320
+ throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "crop": crop is only supported for image (.svg/.png) and video (.mp4) file overlays.`);
321
+ }
322
+ if ((config.start !== undefined || config.end !== undefined) &&
323
+ extension !== '.mp4') {
324
+ throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "start"/"end": source trim is only supported for .mp4 video overlays.`);
325
+ }
213
326
  // HTML file: read + rasterize to a transparent PNG (or an animated clip).
214
327
  if (extension === '.html') {
215
- registeredAssetPaths.add(path);
328
+ registerAssetPath(path);
216
329
  const getMarkup = () => readHtmlOverlayFile(path);
217
330
  if (animate) {
218
- return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, config.fps, config.durationMs, renderOpts);
331
+ return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, config.fps, configDurationMs, renderOpts);
219
332
  }
220
- return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, config.durationMs, renderOpts);
333
+ return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, configDurationMs, renderOpts);
221
334
  }
222
335
  // File-backed image / video overlays.
223
336
  if (extension === '.svg' || extension === '.png') {
224
337
  if (config.volume !== undefined) {
225
- throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide volume. Use durationMs instead.`);
338
+ throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide volume. Use duration instead.`);
226
339
  }
227
- if (config.durationMs !== undefined) {
228
- validateDurationMs(name, path, config.durationMs);
340
+ if (config.crop !== undefined) {
341
+ validateCrop(`Overlay "${name}" (${path})`, config.crop);
229
342
  }
230
- registeredAssetPaths.add(path);
343
+ registerAssetPath(path);
231
344
  return createFileOverlayController(name, {
232
345
  kind: 'image',
233
346
  path,
234
347
  ...(placement !== undefined && { placement }),
235
348
  fullScreen,
236
- ...(config.durationMs !== undefined && { durationMs: config.durationMs }),
349
+ ...(pinToScreen && { pinToScreen: true }),
350
+ ...(configDurationMs !== undefined && { durationMs: configDurationMs }),
351
+ ...(config.crop !== undefined && { crop: config.crop }),
237
352
  });
238
353
  }
239
354
  if (extension === '.mp4') {
240
- if (config.durationMs !== undefined) {
241
- throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide durationMs. Its natural media duration is used instead.`);
355
+ if (config.duration !== undefined) {
356
+ throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide duration. Its natural media duration is used instead.`);
242
357
  }
243
358
  if (config.volume !== undefined &&
244
359
  (!Number.isFinite(config.volume) ||
@@ -247,15 +362,23 @@ function buildOverlayFromConfig(name, config) {
247
362
  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.`);
248
363
  }
249
364
  validateSpeedTime(`Overlay "${name}" (${path})`, config.speed, config.time);
250
- registeredAssetPaths.add(path);
365
+ if (config.crop !== undefined) {
366
+ validateCrop(`Overlay "${name}" (${path})`, config.crop);
367
+ }
368
+ const { sourceStart, sourceEnd } = resolveSourceTrim(`Overlay "${name}" (${path})`, config.start, config.end);
369
+ registerAssetPath(path);
251
370
  return createFileOverlayController(name, {
252
371
  kind: 'video',
253
372
  path,
254
373
  ...(placement !== undefined && { placement }),
255
374
  fullScreen,
375
+ ...(pinToScreen && { pinToScreen: true }),
256
376
  ...(config.volume !== undefined && { audio: config.volume }),
257
377
  ...(config.speed !== undefined && { speed: config.speed }),
258
378
  ...(config.time !== undefined && { time: config.time }),
379
+ ...(config.crop !== undefined && { crop: config.crop }),
380
+ ...(sourceStart !== undefined && { sourceStart }),
381
+ ...(sourceEnd !== undefined && { sourceEnd }),
259
382
  });
260
383
  }
261
384
  throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
@@ -428,6 +551,65 @@ export function buildStudioOverlays(names) {
428
551
  }
429
552
  return result;
430
553
  }
554
+ /**
555
+ * Build overlay controllers for a `video.overlays(...)` declaration. Studio
556
+ * (array) names become Studio-managed controllers; code (object) names resolve
557
+ * their config for the active language (`byLang[language] ?? shared`) and become
558
+ * regular overlay controllers. Per-language mode realizes one language per pass,
559
+ * so the active-language config is the one captured.
560
+ */
561
+ export function buildOverlays(feature, language) {
562
+ const result = {};
563
+ if (!feature)
564
+ return result;
565
+ for (const name of feature.studioNames) {
566
+ result[name] = createStudioAssetController(name);
567
+ }
568
+ for (const name of feature.codeNames) {
569
+ const input = (language !== undefined ? feature.byLang[language]?.[name] : undefined) ??
570
+ feature.shared[name];
571
+ if (input === undefined)
572
+ continue;
573
+ result[name] = buildOverlayController(name, input);
574
+ }
575
+ return result;
576
+ }
577
+ /**
578
+ * Resolves a string overlay position into the anchor recorded on the asset start.
579
+ * Throws on a non-string value so callers that meant a numeric duration are not
580
+ * silently misrouted into the parser.
581
+ */
582
+ function resolveOverlayAnchor(until) {
583
+ if (typeof until !== 'string') {
584
+ throw new Error(`overlay positions must be a string such as '0:10' or '56%', got ${typeof until}`);
585
+ }
586
+ const parsed = parseTimelineOffset(until);
587
+ return parsed.kind === 'percent'
588
+ ? { percent: parsed.fraction }
589
+ : { outputMs: parsed.ms };
590
+ }
591
+ /**
592
+ * Resolves a relative length string (`.for('2s')` or a config `duration`) into
593
+ * milliseconds. Seconds and timecodes only: a percentage is rejected because a
594
+ * relative length has nothing to take a percentage of (use `.until('<n>%')` for
595
+ * an absolute position instead).
596
+ */
597
+ function resolveRelativeDuration(value, label) {
598
+ if (typeof value !== 'string') {
599
+ throw new Error(`[screenci] ${label} must be a time string such as '2s' or '0:02', got ${typeof value}.`);
600
+ }
601
+ const parsed = parseTimelineOffset(value);
602
+ if (parsed.kind === 'percent') {
603
+ throw new Error(`[screenci] ${label} cannot be a percentage ('${value}'); a relative length needs a concrete time like '2s' or '0:02'. Use .until('${value}') for an absolute position.`);
604
+ }
605
+ return parsed.ms;
606
+ }
607
+ /** Parses an optional config `duration` (a relative time string) into ms. */
608
+ function resolveConfigDuration(name, duration) {
609
+ if (duration === undefined)
610
+ return undefined;
611
+ return resolveRelativeDuration(duration, `Overlay "${name}" duration`);
612
+ }
431
613
  function createActiveAssetRun(startedWithExplicitStart) {
432
614
  let resolve;
433
615
  const finished = new Promise((resolveFn) => {
@@ -478,19 +660,27 @@ prepare) {
478
660
  endLiveAsset(name, 'wait');
479
661
  await run.finished;
480
662
  };
481
- const controller = (async (durationMs) => {
482
- // A blocking overlay holds a frozen frame for a fixed duration. It never
483
- // registers a live run and never ends overlays that are already live, so it
484
- // can run while other overlays stay composited across the frozen frame.
485
- const mode = {
486
- type: 'blocking',
487
- ...(durationMs !== undefined && { durationMs }),
488
- };
663
+ // A blocking overlay holds a frozen frame. It never registers a live run and
664
+ // never ends overlays that are already live, so it can run while other overlays
665
+ // stay composited across the frame.
666
+ const runBlocking = async (mode) => {
489
667
  await validate();
490
668
  await prepare?.(mode);
491
669
  const recorder = getRuntimeAssetRecorder();
492
670
  emitStart(recorder, mode);
671
+ };
672
+ const controller = (async () => {
673
+ // Bare call: hold for the source's natural length. Length-less overlays
674
+ // (image/html/element) reject this downstream and must use .for()/.until().
675
+ await runBlocking({ type: 'blocking' });
676
+ });
677
+ controller.for = (duration) => runBlocking({
678
+ type: 'blocking',
679
+ durationMs: resolveRelativeDuration(duration, `Overlay "${name}" .for(duration)`),
493
680
  });
681
+ // A string position sets an absolute point to stay visible until (resolved at
682
+ // render time, so a percentage is kept symbolic).
683
+ controller.until = (position) => runBlocking({ type: 'blocking', until: resolveOverlayAnchor(position) });
494
684
  controller.start = () => start(true);
495
685
  controller.end = end;
496
686
  return controller;
@@ -498,6 +688,60 @@ prepare) {
498
688
  function createStudioAssetController(name) {
499
689
  return createAssetControllerCore(name, () => Promise.resolve(), (recorder) => recorder.addStudioAssetStart(name));
500
690
  }
691
+ /**
692
+ * Builds the controller for a {@link selected} render-dependency overlay. No
693
+ * local file is read: the controller emits an `assetStart` carrying the target's
694
+ * name (an {@link OverlayDependencyRef}); the backend resolves it to a concrete
695
+ * output at render time. Placement is fixed (no `over`), so it is resolved up
696
+ * front. A blocking call needs a duration (from the call or config); a
697
+ * `start()`/`end()` window needs none.
698
+ */
699
+ function createDependencyOverlayController(name, input) {
700
+ // `over`/`margin` are not in DependencyOverlayOptions, so placement is always
701
+ // fixed here (an embedded render has no live element to size against).
702
+ const placement = resolveOverlayPlacement(name, input.config);
703
+ const fullScreen = input.config.fill === 'screen';
704
+ const pinToScreen = input.config.pinToScreen === true;
705
+ const configDurationMs = resolveConfigDuration(name, input.config.duration);
706
+ if (input.config.crop !== undefined) {
707
+ validateCrop(`Dependency overlay "${name}"`, input.config.crop);
708
+ }
709
+ // start/end are valid only when the target resolves to a VIDEO; the backend
710
+ // rejects them for a screenshot dependency (the medium is unknown until then).
711
+ const { sourceStart, sourceEnd } = resolveSourceTrim(`Dependency overlay "${name}"`, input.config.start, input.config.end);
712
+ return createAssetControllerCore(name, () => Promise.resolve(), (recorder, mode) => {
713
+ let durationMs;
714
+ let until;
715
+ if (mode.type === 'blocking') {
716
+ if (mode.until !== undefined) {
717
+ until = mode.until;
718
+ }
719
+ else {
720
+ // No length => natural duration. Valid for a video dependency; the
721
+ // backend rejects a screenshot dependency with no length when it
722
+ // resolves the concrete medium.
723
+ durationMs = mode.durationMs ?? configDurationMs;
724
+ }
725
+ }
726
+ recorder.addAssetStart(name, {
727
+ kind: 'dependency',
728
+ dependency: {
729
+ name: input.name,
730
+ ...(input.config.inheritSubtitles === true && {
731
+ inheritSubtitles: true,
732
+ }),
733
+ },
734
+ ...(durationMs !== undefined && { durationMs }),
735
+ ...timelineAnchorFields(until),
736
+ fullScreen,
737
+ ...(pinToScreen && { pinToScreen: true }),
738
+ ...(placement !== undefined && { placement }),
739
+ ...(input.config.crop !== undefined && { crop: input.config.crop }),
740
+ ...(sourceStart !== undefined && { sourceStart }),
741
+ ...(sourceEnd !== undefined && { sourceEnd }),
742
+ });
743
+ });
744
+ }
501
745
  function createFileOverlayController(name, resolved) {
502
746
  return createAssetControllerCore(name, async () => {
503
747
  const testFilePath = getScreenCIRuntimeContext().testFilePath;
@@ -512,7 +756,7 @@ function createFileOverlayController(name, resolved) {
512
756
  * An overlay rendered to a transparent PNG at recording time, from either an
513
757
  * HTML file or a React element. `getMarkup` produces the HTML to rasterize.
514
758
  */
515
- function createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, durationMs, renderOpts = {}) {
759
+ function createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, durationMs, renderOpts = {}) {
516
760
  // The markup and placement are resolved during the test (cheap:
517
761
  // renderToStaticMarkup / a string, plus a boundingBox read for `over`), but
518
762
  // rasterization (a browser screenshot) is deferred to after the test so
@@ -527,19 +771,27 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
527
771
  if (skipped || resolvedHtml === undefined)
528
772
  return;
529
773
  let durationMsForEvent;
774
+ let until;
530
775
  if (mode.type === 'blocking') {
531
- durationMsForEvent = mode.durationMs ?? durationMs;
532
- if (durationMsForEvent === undefined) {
533
- 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().`);
776
+ if (mode.until !== undefined) {
777
+ until = mode.until;
778
+ }
779
+ else {
780
+ durationMsForEvent = mode.durationMs ?? durationMs;
781
+ if (durationMsForEvent === undefined) {
782
+ throw new Error(`[screenci] Overlay "${name}" needs a length: use .for('2s'), .until('0:05'), set "duration" in the config, or drive it with .start()/.end().`);
783
+ }
784
+ validateDurationMs(name, `overlay "${name}"`, durationMsForEvent);
534
785
  }
535
- validateDurationMs(name, `overlay "${name}"`, durationMsForEvent);
536
786
  }
537
787
  recorder.addPendingAssetStart(name, {
538
788
  kind: 'image',
539
789
  ...(durationMsForEvent !== undefined && {
540
790
  durationMs: durationMsForEvent,
541
791
  }),
792
+ ...timelineAnchorFields(until),
542
793
  fullScreen,
794
+ ...(pinToScreen && { pinToScreen: true }),
543
795
  ...(resolvedPlacement !== undefined && {
544
796
  placement: resolvedPlacement,
545
797
  }),
@@ -580,20 +832,23 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
580
832
  * the recording, the old "hide block" that cut capture wall-clock from the
581
833
  * timeline is no longer needed.
582
834
  */
583
- function createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, fps, configDurationMs, renderOpts = {}) {
835
+ function createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, fps, configDurationMs, renderOpts = {}) {
584
836
  let resolved;
585
837
  let skipped = false;
586
838
  const resolveDurationMs = (mode) => {
587
839
  if (mode.type === 'blocking') {
840
+ if (mode.until !== undefined) {
841
+ throw new Error(`[screenci] Animated overlay "${name}" cannot use .until('0:10'); its capture length must be fixed. Use .for('2s'), set "duration" in the config, or drive it with .start()/.end() (with "duration" in the config).`);
842
+ }
588
843
  const durationMs = mode.durationMs ?? configDurationMs;
589
844
  if (durationMs === undefined) {
590
- 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).`);
845
+ throw new Error(`[screenci] Animated overlay "${name}" needs a length: use .for('2s'), set "duration" in the config, or drive it with .start()/.end() (with "duration" in the config).`);
591
846
  }
592
847
  validateDurationMs(name, `overlay "${name}"`, durationMs);
593
848
  return durationMs;
594
849
  }
595
850
  if (configDurationMs === undefined) {
596
- throw new Error(`[screenci] Animated overlay "${name}" driven with .start()/.end() needs durationMs in its config (the capture length is otherwise unknown).`);
851
+ throw new Error(`[screenci] Animated overlay "${name}" driven with .start()/.end() needs "duration" in its config (the capture length is otherwise unknown).`);
597
852
  }
598
853
  validateDurationMs(name, `overlay "${name}"`, configDurationMs);
599
854
  return configDurationMs;
@@ -603,8 +858,13 @@ function createAnimatedOverlayController(name, getMarkup, placementSource, fullS
603
858
  return;
604
859
  recorder.addPendingAssetStart(name, {
605
860
  kind: 'animation',
606
- ...(mode.type === 'blocking' && { durationMs: resolved.durationMs }),
861
+ // Always carry the capture length, for blocking and live overlays alike.
862
+ // A live animated overlay plays out to this length (the renderer holds a
863
+ // frozen-frame tail when it is longer than the start()/end() window), so
864
+ // it needs the duration even when an assetEnd also bounds the window.
865
+ durationMs: resolved.durationMs,
607
866
  fullScreen,
867
+ ...(pinToScreen && { pinToScreen: true }),
608
868
  ...(resolved.placement !== undefined && {
609
869
  placement: resolved.placement,
610
870
  }),
@@ -836,31 +1096,52 @@ function sizeWrapMarkup(html, size) {
836
1096
  function toRecordedFileStart(name, resolved, mode) {
837
1097
  if (resolved.kind === 'image') {
838
1098
  let durationMs;
1099
+ let until;
839
1100
  if (mode.type === 'blocking') {
840
- durationMs = mode.durationMs ?? resolved.durationMs;
841
- if (durationMs === undefined) {
842
- 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().`);
1101
+ if (mode.until !== undefined) {
1102
+ until = mode.until;
1103
+ }
1104
+ else {
1105
+ durationMs = mode.durationMs ?? resolved.durationMs;
1106
+ if (durationMs === undefined) {
1107
+ throw new Error(`[screenci] Overlay "${name}" (${resolved.path}) needs a length: use .for('2s'), .until('0:05'), set "duration" in the config, or drive it with .start()/.end().`);
1108
+ }
1109
+ validateDurationMs(name, resolved.path, durationMs);
843
1110
  }
844
- validateDurationMs(name, resolved.path, durationMs);
845
1111
  }
846
1112
  return {
847
1113
  kind: 'image',
848
1114
  path: resolved.path,
849
1115
  ...(durationMs !== undefined && { durationMs }),
1116
+ ...timelineAnchorFields(until),
850
1117
  fullScreen: resolved.fullScreen,
1118
+ ...(resolved.pinToScreen && { pinToScreen: true }),
851
1119
  ...(resolved.placement !== undefined && {
852
1120
  placement: resolved.placement,
853
1121
  }),
1122
+ ...(resolved.crop !== undefined && { crop: resolved.crop }),
854
1123
  };
855
1124
  }
1125
+ if (mode.type === 'blocking' && mode.until !== undefined) {
1126
+ throw new Error(`[screenci] Overlay "${name}" (${resolved.path}) is a video and cannot use .until('0:10'); a video overlay plays for its natural length. Drive it with .start()/.end() to control its window.`);
1127
+ }
1128
+ if (mode.type === 'blocking' && mode.durationMs !== undefined) {
1129
+ throw new Error(`[screenci] Overlay "${name}" (${resolved.path}) is a video and cannot use .for('2s'); a video overlay plays for its natural length. Use a bare call (overlays.${name}()), speed/time to re-time it, or .start()/.end() to control its window.`);
1130
+ }
856
1131
  return {
857
1132
  kind: 'video',
858
1133
  path: resolved.path,
859
1134
  audio: resolved.audio ?? 1,
860
1135
  fullScreen: resolved.fullScreen,
1136
+ ...(resolved.pinToScreen && { pinToScreen: true }),
861
1137
  ...(resolved.placement !== undefined && { placement: resolved.placement }),
862
1138
  ...(resolved.speed !== undefined && { speed: resolved.speed }),
863
1139
  ...(resolved.time !== undefined && { time: resolved.time }),
1140
+ ...(resolved.crop !== undefined && { crop: resolved.crop }),
1141
+ ...(resolved.sourceStart !== undefined && {
1142
+ sourceStart: resolved.sourceStart,
1143
+ }),
1144
+ ...(resolved.sourceEnd !== undefined && { sourceEnd: resolved.sourceEnd }),
864
1145
  };
865
1146
  }
866
1147
  //# sourceMappingURL=asset.js.map