effect-browser 0.2.0-beta.9 → 0.3.0-beta.11

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 (205) hide show
  1. package/README.md +861 -1161
  2. package/dist/Browser-B4qncxe7.mjs +628 -0
  3. package/dist/Browser-B4qncxe7.mjs.map +1 -0
  4. package/dist/Browser.d.mts +129 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-Di2PxyF7.d.mts +119 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +125 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-B7X6DQUn.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-Akcac63g.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-BAf__WKX.mjs +5075 -0
  73. package/dist/page-BAf__WKX.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +672 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +132 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +391 -0
  113. package/src/internal/page/context.ts +314 -0
  114. package/src/internal/page/identity.ts +35 -0
  115. package/src/internal/page/lane.ts +311 -0
  116. package/src/internal/page/navigation.ts +183 -0
  117. package/src/internal/page/openings.ts +87 -0
  118. package/src/internal/page/page.ts +165 -0
  119. package/src/internal/page/url.ts +100 -0
  120. package/src/internal/page/viewport.ts +43 -0
  121. package/src/internal/pictures/capture.ts +776 -0
  122. package/src/internal/pictures/clock.ts +141 -0
  123. package/src/internal/pictures/counts.ts +92 -0
  124. package/src/internal/pictures/pictures.ts +521 -0
  125. package/src/internal/pictures/transport.ts +243 -0
  126. package/src/internal/reading/choose.ts +100 -0
  127. package/src/internal/reading/context.inpage.ts +201 -0
  128. package/src/internal/reading/controls.inpage.ts +158 -0
  129. package/src/internal/reading/expect.inpage.ts +70 -0
  130. package/src/internal/reading/match.inpage.ts +92 -0
  131. package/src/internal/reading/names.inpage.ts +306 -0
  132. package/src/internal/reading/outline.inpage.ts +260 -0
  133. package/src/internal/reading/reading.ts +223 -0
  134. package/src/internal/reading/ready.inpage.ts +164 -0
  135. package/src/internal/reading/ready.ts +111 -0
  136. package/src/internal/reading/subjects.inpage.ts +188 -0
  137. package/src/internal/reading/text.inpage.ts +135 -0
  138. package/src/internal/reading/walk.inpage.ts +220 -0
  139. package/src/internal/supervisor/lifecycle.ts +416 -0
  140. package/src/internal/timeline/changes.inpage.ts +216 -0
  141. package/src/internal/timeline/changes.ts +100 -0
  142. package/src/internal/timeline/events.ts +94 -0
  143. package/src/internal/timeline/fold.inpage.ts +123 -0
  144. package/src/internal/timeline/history.inpage.ts +229 -0
  145. package/src/internal/timeline/marks.inpage.ts +207 -0
  146. package/src/internal/timeline/record.inpage.ts +133 -0
  147. package/src/internal/timeline/sight.inpage.ts +122 -0
  148. package/src/internal/timeline/window.ts +96 -0
  149. package/src/internal/timeline/words.ts +65 -0
  150. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  151. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  152. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  153. package/dist/Bootstrap.d.mts +0 -3
  154. package/dist/Bootstrap.mjs +0 -2
  155. package/dist/Browser-BF9_sORP.d.mts +0 -388
  156. package/dist/Browser.mjs.map +0 -1
  157. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  158. package/dist/BrowserData.d.mts +0 -3
  159. package/dist/BrowserData.mjs +0 -695
  160. package/dist/BrowserData.mjs.map +0 -1
  161. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  163. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  164. package/dist/BrowserRuntime.d.mts +0 -3
  165. package/dist/BrowserRuntime.mjs +0 -3
  166. package/dist/Capture.d.mts +0 -56
  167. package/dist/Capture.mjs +0 -678
  168. package/dist/Capture.mjs.map +0 -1
  169. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  170. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  171. package/dist/CaptureData.d.mts +0 -219
  172. package/dist/CaptureData.mjs +0 -2
  173. package/dist/Errors.d.mts +0 -158
  174. package/dist/Errors.mjs +0 -207
  175. package/dist/Errors.mjs.map +0 -1
  176. package/dist/Images-yCG9BmRU.mjs +0 -76
  177. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  178. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  179. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  180. package/dist/PageControl.d.mts +0 -15
  181. package/dist/PageControl.mjs +0 -34
  182. package/dist/PageControl.mjs.map +0 -1
  183. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  184. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  185. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  186. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  187. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  188. package/dist/PlanData.d.mts +0 -3
  189. package/dist/PlanData.mjs +0 -268
  190. package/dist/PlanData.mjs.map +0 -1
  191. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  192. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  193. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  194. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  195. package/dist/Testing.d.mts +0 -479
  196. package/dist/Testing.mjs +0 -2147
  197. package/dist/Testing.mjs.map +0 -1
  198. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  199. package/dist/Timeline.d.mts +0 -4
  200. package/dist/Timeline.mjs +0 -20
  201. package/dist/Timeline.mjs.map +0 -1
  202. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  203. package/dist/TimelineData.d.mts +0 -3
  204. package/dist/TimelineData.mjs +0 -482
  205. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Pictures of a page: screenshots, frames of a stated age, zooms, and its screencast with the clock
3
+ * that times its frames. A new picture is taken on the page's own protocol session, one at a time:
4
+ * one call where a device pixel is a CSS pixel and nothing is cropped, and two otherwise, unless
5
+ * Playwright emulates the viewport. The page's first clipped picture also holds the screen it reads.
6
+ */
7
+ import { Duration, Effect, Option, Schedule, Schema, Semaphore } from "effect";
8
+
9
+ import { BrowserError, Failed, InvalidRequest } from "../../BrowserError.ts";
10
+ import { Frame, Image, Screenshot } from "../../Frame.ts";
11
+ import { type FrameOptions, Region, type ScreenshotOptions, Zoom } from "../../Page.ts";
12
+ import { type Bridge, scriptCall } from "../page/bridge.ts";
13
+ import { decodeWith, failWith, type PageContext, reasonOf, undispatched } from "../page/context.ts";
14
+ import type { Viewport } from "../page/viewport.ts";
15
+ import * as Capture from "./capture.ts";
16
+ import * as BrowserClock from "./clock.ts";
17
+ import * as Transport from "./transport.ts";
18
+
19
+ /** Read a JPEG's dimensions from its start-of-frame marker. */
20
+ export const jpegSize = (bytes: Uint8Array): Capture.Size | undefined => {
21
+ let offset = 2;
22
+
23
+ while (offset + 9 < bytes.length) {
24
+ if (bytes[offset] !== 0xff) return undefined;
25
+ const marker = bytes[offset + 1] ?? 0;
26
+ const length = ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0);
27
+
28
+ if (marker >= 0xc0 && marker <= 0xc3)
29
+ return {
30
+ height: ((bytes[offset + 5] ?? 0) << 8) | (bytes[offset + 6] ?? 0),
31
+ width: ((bytes[offset + 7] ?? 0) << 8) | (bytes[offset + 8] ?? 0),
32
+ };
33
+ offset += 2 + length;
34
+ }
35
+
36
+ return undefined;
37
+ };
38
+
39
+ /** Read a PNG's dimensions from its header chunk. */
40
+ export const pngSize = (bytes: Uint8Array): Capture.Size | undefined => {
41
+ if (bytes.length < 24 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47)
42
+ return undefined;
43
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
44
+
45
+ return { width: view.getUint32(16), height: view.getUint32(20) };
46
+ };
47
+
48
+ /** The format a new picture is taken in. */
49
+ export type Format = "jpeg" | "png";
50
+
51
+ // The part of `Page.getLayoutMetrics` a clip needs: the visual viewport's place in the document and
52
+ // its pinch scale, and the content's size in device and CSS pixels, whose ratio is the device's.
53
+ const LayoutMetrics = Schema.Struct({
54
+ cssVisualViewport: Schema.Struct({
55
+ pageX: Schema.Finite,
56
+ pageY: Schema.Finite,
57
+ scale: Schema.Finite.check(Schema.isGreaterThan(0)),
58
+ }),
59
+ contentSize: Schema.Struct({ width: Schema.Finite }),
60
+ cssContentSize: Schema.Struct({ width: Schema.Finite }),
61
+ });
62
+
63
+ // A screencast frame shows the page as it was when it was painted, and a new picture costs a round
64
+ // trip, so by default a frame painted this recently is reused.
65
+ const defaultMaxAge = Duration.millis(250);
66
+
67
+ // Chromium answers this while the page has no surface to copy yet, as just after a navigation. The
68
+ // page has one from its first frame, so the picture is taken again a frame later, within the
69
+ // picture's own deadline.
70
+ const surfaceless = (error: BrowserError) =>
71
+ error.reason._tag === "Failed" && error.reason.detail.includes("Unable to capture screenshot");
72
+
73
+ const untilPainted = <A, R>(capture: Effect.Effect<A, BrowserError, R>) =>
74
+ capture.pipe(Effect.retry({ while: surfaceless, schedule: Schedule.spaced("16 millis") }));
75
+
76
+ /**
77
+ * Measure this page's clock against the browser's, in a world without the page script: no DOM
78
+ * markers, input, navigation or extra tabs. Each of three probes reads the browser's time between
79
+ * two stamps of the owner's clock, and the fastest bounds the offset best. A busy page answers each
80
+ * probe late, behind its own work, so the probes that answered within two seconds serve, and the
81
+ * measurement fails only if none did: a wide estimate keeps a capture going, and a renewal while
82
+ * its frames flow narrows it.
83
+ */
84
+ const calibrator = (page: PageContext, bridge: Bridge) => {
85
+ const { native, now, within } = page;
86
+ const { send } = page.protocol;
87
+
88
+ // A round trip to the page like any other read, so traced as one.
89
+ const probe = (contextId: number) =>
90
+ Effect.gen(function* () {
91
+ const hostStart = now();
92
+
93
+ const { result, exceptionDetails } = yield* native("calibrate", () =>
94
+ send("Runtime.evaluate", {
95
+ contextId,
96
+ expression: "performance.timeOrigin + performance.now()",
97
+ returnByValue: true,
98
+ }),
99
+ ).pipe(page.span("Page.evaluate", { function: "clock" }, "Trace"));
100
+
101
+ const hostEnd = now();
102
+ const browserTime: unknown = result.value;
103
+
104
+ if (exceptionDetails !== undefined || typeof browserTime !== "number")
105
+ return yield* failWith(
106
+ "calibrate",
107
+ new Failed({ detail: "the browser clock probe did not return a timestamp" }),
108
+ );
109
+
110
+ return { hostStart, hostEnd, browserTime };
111
+ });
112
+
113
+ const answered = (contextId: number) =>
114
+ Effect.gen(function* () {
115
+ const probes: Array<BrowserClock.Probe> = [];
116
+
117
+ yield* Effect.forEach(
118
+ [1, 2, 3],
119
+ () => Effect.map(probe(contextId), (one) => probes.push(one)),
120
+ {
121
+ discard: true,
122
+ },
123
+ ).pipe(
124
+ within("calibrate", Duration.seconds(2)),
125
+ Effect.catch((error) => (probes.length === 0 ? Effect.fail(error) : Effect.void)),
126
+ );
127
+
128
+ return probes;
129
+ });
130
+
131
+ return bridge.bareWorld("calibrate").pipe(
132
+ Effect.flatMap(answered),
133
+ Effect.flatMap((probes) =>
134
+ Option.match(BrowserClock.estimate(probes), {
135
+ onNone: () =>
136
+ failWith(
137
+ "calibrate",
138
+ new Failed({ detail: "the browser clock probes did not define a finite interval" }),
139
+ ),
140
+ onSome: Effect.succeed,
141
+ }),
142
+ ),
143
+ within("calibrate", Duration.seconds(4)),
144
+ // The fastest probe's round trip: the transport's, plus a trivial script.
145
+ Effect.tap((estimate) =>
146
+ Effect.annotateCurrentSpan("roundTripMillis", Math.round(estimate.roundTripMillis * 10) / 10),
147
+ ),
148
+ page.span("Page.calibrateClock", {}, "Debug"),
149
+ page.owned,
150
+ );
151
+ };
152
+
153
+ // The screen as the page reads it.
154
+ const ScreenSize = Schema.Struct({ width: Schema.Int, height: Schema.Int });
155
+
156
+ // A new picture in CSS pixels. Where a device pixel is not a CSS pixel, or for a crop, it is
157
+ // Playwright's clip over fresh layout metrics: document coordinates from the visual viewport's
158
+ // place, and its pinch scale divided by the device's. Chromium draws such a capture into a
159
+ // running screencast, so the capture leaves out what is painted meanwhile.
160
+ //
161
+ // Chromium takes a clipped picture through the device emulation of the session that asks for it,
162
+ // then restores the view's size and what that session emulated. The page's own session emulated
163
+ // nothing, so its crops cleared a screen another session emulates, as Browserbase's own does: so
164
+ // before its first clipped picture, it holds a copy of the screen the page reads, which every later
165
+ // picture restores. And a picture taken during another would restore the other's clip, so they go
166
+ // one at a time.
167
+ const camera = (
168
+ page: PageContext,
169
+ bridge: Bridge,
170
+ viewport: Viewport,
171
+ capture: Capture.Controller,
172
+ ) => {
173
+ const { playwright, settings, native, within } = page;
174
+ const { send } = page.protocol;
175
+ const actionMillis = Duration.toMillis(settings.actionTimeout);
176
+ // Device pixels per CSS pixel, as the page's layout metrics last gave it. A picture taken in one
177
+ // call is checked by its size, so a page whose ratio changes is measured again.
178
+ let ratio = 1;
179
+ const alone = Semaphore.makeUnsafe(1).withPermits(1);
180
+ let screenHeld = false;
181
+
182
+ // The copy has the screen alone: the view keeps its own size, and the device its pixel ratio.
183
+ const holdScreen = (operation: string) =>
184
+ Effect.suspend(() =>
185
+ screenHeld
186
+ ? Effect.void
187
+ : bridge.evaluate(operation, scriptCall("screenSize")).pipe(
188
+ Effect.flatMap(decodeWith(operation, ScreenSize)),
189
+ Effect.flatMap(({ width, height }) =>
190
+ native(operation, () =>
191
+ send("Emulation.setDeviceMetricsOverride", {
192
+ width: 0,
193
+ height: 0,
194
+ deviceScaleFactor: 0,
195
+ mobile: false,
196
+ screenWidth: width,
197
+ screenHeight: height,
198
+ }),
199
+ ).pipe(alone),
200
+ ),
201
+ Effect.andThen(
202
+ Effect.sync(() => {
203
+ screenHeld = true;
204
+ }),
205
+ ),
206
+ ),
207
+ );
208
+
209
+ const decoded = (operation: string, data: Uint8Array, format: Format) => {
210
+ const size = format === "png" ? pngSize(data) : jpegSize(data);
211
+
212
+ return size === undefined
213
+ ? failWith(
214
+ operation,
215
+ new Failed({ detail: `the browser's picture is not a ${format.toUpperCase()}` }),
216
+ )
217
+ : Effect.succeed(new Image({ data, mediaType: `image/${format}`, ...size }));
218
+ };
219
+
220
+ const shot = (
221
+ operation: string,
222
+ format: Format,
223
+ quality: number,
224
+ clip?: Capture.Size & { readonly x: number; readonly y: number; readonly scale: number },
225
+ ) =>
226
+ native(operation, () =>
227
+ send("Page.captureScreenshot", {
228
+ format,
229
+ ...(format === "jpeg" ? { quality } : {}),
230
+ ...(clip === undefined ? {} : { clip }),
231
+ }),
232
+ ).pipe(
233
+ alone,
234
+ untilPainted,
235
+ Effect.flatMap(({ data }) => decoded(operation, Buffer.from(data, "base64"), format)),
236
+ );
237
+
238
+ // Playwright's own picture, taken on the session where it emulates the viewport.
239
+ const emulatedShot = (operation: string, format: Format, quality: number, crop?: Region) =>
240
+ native(
241
+ operation,
242
+ () =>
243
+ playwright.screenshot({
244
+ type: format,
245
+ ...(format === "jpeg" ? { quality } : {}),
246
+ scale: "css",
247
+ timeout: actionMillis,
248
+ ...(crop === undefined ? {} : { clip: crop }),
249
+ }),
250
+ actionMillis,
251
+ ).pipe(
252
+ alone,
253
+ untilPainted,
254
+ Effect.flatMap((data) => decoded(operation, data, format)),
255
+ );
256
+
257
+ return (operation: string, quality: number, crop?: Region, format: Format = "jpeg") =>
258
+ Effect.gen(function* () {
259
+ const known = viewport.knownViewport();
260
+ let whole: Image | undefined;
261
+
262
+ if (crop === undefined && ratio === 1) {
263
+ whole = yield* shot(operation, format, quality);
264
+ if (whole.width === known?.width && whole.height === known.height) return whole;
265
+ }
266
+ // Where Playwright emulates the viewport, on its own session, its own picture restores it.
267
+ const emulated = playwright.viewportSize();
268
+
269
+ if (emulated !== null) {
270
+ if (whole !== undefined) ratio = whole.width / emulated.width;
271
+
272
+ return yield* capture.excluding(emulatedShot(operation, format, quality, crop));
273
+ }
274
+
275
+ const [metrics] = yield* Effect.all(
276
+ [
277
+ native(operation, () => send("Page.getLayoutMetrics")).pipe(
278
+ Effect.flatMap(decodeWith(operation, LayoutMetrics)),
279
+ ),
280
+ crop === undefined ? Effect.void : holdScreen(operation),
281
+ ],
282
+ { concurrency: 2 },
283
+ );
284
+
285
+ const measured = metrics.contentSize.width / metrics.cssContentSize.width;
286
+
287
+ ratio = Number.isFinite(measured) && measured > 0 ? measured : 1;
288
+ if (crop === undefined && ratio === 1) {
289
+ const image = whole ?? (yield* shot(operation, format, quality));
290
+
291
+ viewport.remember(image);
292
+
293
+ return image;
294
+ }
295
+ const rect = crop ?? { x: 0, y: 0, ...(known ?? (yield* viewport.viewportFor(operation))) };
296
+ const { pageX, pageY, scale } = metrics.cssVisualViewport;
297
+
298
+ yield* holdScreen(operation);
299
+
300
+ // A crop on this session draws its frames at the crop's own size, so only those leave the
301
+ // capture, and the page's own keep flowing.
302
+ return yield* capture.excluding(
303
+ shot(operation, format, quality, {
304
+ x: pageX + rect.x,
305
+ y: pageY + rect.y,
306
+ width: Math.floor(rect.width / scale + 1e-3),
307
+ height: Math.floor(rect.height / scale + 1e-3),
308
+ scale: scale / ratio,
309
+ }),
310
+ crop !== undefined,
311
+ );
312
+ }).pipe(within(operation));
313
+ };
314
+
315
+ // A duration in milliseconds, or NaN for an input that is no duration.
316
+ const millisOf = (input: Duration.Input) =>
317
+ Option.match(Duration.fromInput(input), { onNone: () => Number.NaN, onSome: Duration.toMillis });
318
+
319
+ // The newest screencast frame, if it shows the page as the caller asked: painted at most `maxAge`
320
+ // ago at the earliest its timing allows, after the current document began, and with
321
+ // `after: "input"` after the page's latest input, which a read asks in its turn, once the action
322
+ // in flight has ended. It must have the viewport's size, as a new picture would. A screencast sends
323
+ // only changes and can miss a final paint, so a frame is only ever as current as its age, and a
324
+ // page that keeps painting while the next document loads leaves its own frames newest when the
325
+ // navigation returns.
326
+ const reuse =
327
+ (page: PageContext, bridge: Bridge, viewport: Viewport, capture: Capture.Controller) =>
328
+ (operation: string, options: FrameOptions) =>
329
+ Effect.gen(function* () {
330
+ const maxAge = millisOf(options.maxAge ?? defaultMaxAge);
331
+
332
+ if (!(maxAge >= 0))
333
+ return yield* failWith(
334
+ operation,
335
+ new InvalidRequest({ detail: "maxAge must be a duration of zero or more" }),
336
+ );
337
+ const latest = yield* capture.latest;
338
+ const known = viewport.knownViewport();
339
+ const { activity } = page;
340
+
341
+ return Option.filter(latest, (frame) => {
342
+ const earliest = frame.hostTime - frame.timing.uncertaintyMillis;
343
+
344
+ return (
345
+ frame.document === bridge.frameTag().document &&
346
+ page.now() - earliest <= maxAge &&
347
+ earliest > activity.documentAt &&
348
+ (options.after !== "input" || earliest > activity.inputAt) &&
349
+ frame.width === known?.width &&
350
+ frame.height === known.height
351
+ );
352
+ });
353
+ });
354
+
355
+ /** Where a picture came from: a frame the screencast had delivered, or a new screenshot. */
356
+ const sourceOf = (reused: Option.Option<Frame>) => (Option.isSome(reused) ? "frame" : "screenshot");
357
+
358
+ export const make = Effect.fnUntraced(function* (
359
+ page: PageContext,
360
+ bridge: Bridge,
361
+ viewport: Viewport,
362
+ ) {
363
+ const { id, clock, settings, mapping, now, span, owned, lane, within } = page;
364
+ const calibrateClock = calibrator(page, bridge);
365
+
366
+ // A failure from a capture connection is already the capture's own.
367
+ const error = (cause: unknown) =>
368
+ Schema.is(BrowserError)(cause)
369
+ ? cause
370
+ : undispatched("screencast", reasonOf(cause, undefined, page.closedBy()));
371
+
372
+ // Input and capture share the owner's monotonic clock; caller-provided clocks cannot move it.
373
+ // Registration measures nothing: a page that is busy while it opens, such as a popup running
374
+ // its first script, must still be tracked. The browser's mapping serves every other page, so
375
+ // only a browser with no estimate yet needs this page's renderer to answer.
376
+ const capture = yield* Capture.make({
377
+ id,
378
+ transport:
379
+ page.capture === undefined
380
+ ? Effect.succeed(Transport.own(page, bridge))
381
+ : Transport.apart(page, bridge, page.capture, error, yield* Effect.scope),
382
+ clock,
383
+ // A capture starts once the page's own session keeps it painting behind other tabs, which a
384
+ // renderer stuck in a script never confirms, and with the browser's clock mapped. Only a
385
+ // browser's first capture waits for a measurement; every frame takes the newest estimate.
386
+ calibrate: page.focused.pipe(within("focus"), Effect.andThen(mapping.current(calibrateClock))),
387
+ latest: mapping.latest,
388
+ // Once the estimate is ten seconds old, the page measures again while its frames flow. A
389
+ // failure there shows in its `Page.calibrateClock` span, and the estimate stays.
390
+ renew: mapping.renew(calibrateClock),
391
+ // Frames are reused only after the current document began, which the page's own session sees
392
+ // only with the Page domain on: a read turns it on, and so does a capture as it starts.
393
+ watch: bridge.currentDocument("screencast"),
394
+ frameHistory: settings.frameHistory,
395
+ viewport: Effect.suspend(() => viewport.viewportFor("screencast")),
396
+ imageSize: jpegSize,
397
+ error,
398
+ });
399
+
400
+ const picture = camera(page, bridge, viewport, capture);
401
+ const reusable = reuse(page, bridge, viewport, capture);
402
+ // Pictures in flight are shared, never kept: their callers say how old one may be.
403
+ const screenshots = lane.shared<Image>("screenshot", false);
404
+ const frames = lane.shared<Frame>("frame", false);
405
+
406
+ // A caller that acts and then looks must see what its action did, so a screenshot waits for the
407
+ // action in flight and reuses only a frame painted after the page's latest input.
408
+ const screenshot = (options: ScreenshotOptions = {}) =>
409
+ screenshots(
410
+ JSON.stringify([
411
+ millisOf(options.maxAge ?? defaultMaxAge),
412
+ options.clip,
413
+ options.quality,
414
+ options.format,
415
+ ]),
416
+ Effect.gen(function* () {
417
+ // Screencast frames are JPEGs, so a PNG is always new.
418
+ const reused =
419
+ options.clip === undefined && options.format !== "png"
420
+ ? yield* reusable("screenshot", { maxAge: options.maxAge, after: "input" })
421
+ : Option.none<Frame>();
422
+
423
+ const image = Option.isSome(reused)
424
+ ? reused.value.image
425
+ : yield* picture("screenshot", options.quality ?? 80, options.clip, options.format);
426
+
427
+ yield* Effect.annotateCurrentSpan({ source: sourceOf(reused), bytes: image.data.length });
428
+
429
+ return image;
430
+ }),
431
+ ).pipe(span("Page.screenshot"), owned);
432
+
433
+ // A new screenshot has no paint time, only the host interval in which it was taken.
434
+ const taken = (options: FrameOptions) =>
435
+ Effect.gen(function* () {
436
+ const reused = yield* reusable("frame", options);
437
+
438
+ yield* Effect.annotateCurrentSpan("source", sourceOf(reused));
439
+ if (Option.isSome(reused)) return reused.value;
440
+ const startedAt = now();
441
+ const image = yield* picture("frame", 80);
442
+ const finishedAt = now();
443
+
444
+ return new Frame({
445
+ page: id,
446
+ data: image.data,
447
+ timing: new Screenshot({
448
+ hostTime: startedAt + (finishedAt - startedAt) / 2,
449
+ uncertaintyMillis: (finishedAt - startedAt) / 2,
450
+ }),
451
+ receivedAt: finishedAt,
452
+ width: image.width,
453
+ height: image.height,
454
+ ...bridge.frameTag(),
455
+ });
456
+ });
457
+
458
+ // A frame of a stated age that the screencast has costs nothing and waits for no action; one
459
+ // that must follow the latest input, or a new screenshot, waits for the action in flight.
460
+ const frame = (options: FrameOptions = {}) =>
461
+ (options.after === "input"
462
+ ? Effect.succeed(Option.none<Frame>())
463
+ : reusable("frame", options)
464
+ ).pipe(
465
+ Effect.flatMap(
466
+ Option.match({
467
+ onSome: (reused) => Effect.as(Effect.annotateCurrentSpan("source", "frame"), reused),
468
+ onNone: () =>
469
+ frames(
470
+ JSON.stringify([millisOf(options.maxAge ?? defaultMaxAge), options.after]),
471
+ taken(options),
472
+ ),
473
+ }),
474
+ ),
475
+ span("Page.frame"),
476
+ owned,
477
+ );
478
+
479
+ const zoom = (requested: Region) =>
480
+ lane
481
+ .read("zoom")(
482
+ Effect.gen(function* () {
483
+ const region = yield* Schema.decodeEffect(Region)(requested).pipe(
484
+ Effect.mapError((error) =>
485
+ undispatched("zoom", new InvalidRequest({ detail: error.message })),
486
+ ),
487
+ );
488
+
489
+ // The viewport's latest known size, as the pictures the region was chosen from had it.
490
+ const size = viewport.knownViewport() ?? (yield* viewport.viewportFor("zoom"));
491
+
492
+ if (region.x + region.width > size.width || region.y + region.height > size.height)
493
+ return yield* failWith(
494
+ "zoom",
495
+ new InvalidRequest({ detail: "the crop must fit entirely within the viewport" }),
496
+ );
497
+ const image = yield* picture("zoom", 80, region);
498
+
499
+ return new Zoom({ page: id, region, image });
500
+ }).pipe(within("zoom")),
501
+ )
502
+ .pipe(span("Page.zoom"), owned);
503
+
504
+ const captureStats = (options: { readonly window?: Duration.Input | undefined } = {}) => {
505
+ if (options.window === undefined) return capture.stats();
506
+ const millis = millisOf(options.window);
507
+
508
+ return millis > 0 && millis <= Duration.toMillis(Capture.countsKept)
509
+ ? capture.stats(millis)
510
+ : failWith(
511
+ "captureStats",
512
+ new InvalidRequest({ detail: "window must be over zero and at most a minute" }),
513
+ );
514
+ };
515
+
516
+ // The browser's clock mapping, measured on this page only while the browser has none, as the
517
+ // change record's times need it.
518
+ const estimate = mapping.current(calibrateClock);
519
+
520
+ return { capture, screenshot, frame, zoom, captureStats, estimate };
521
+ });