effect-browser 0.2.0-beta.9 → 0.3.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +705 -1184
- package/dist/Browser-CM65Uo7f.mjs +437 -0
- package/dist/Browser-CM65Uo7f.mjs.map +1 -0
- package/dist/Browser.d.mts +115 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +202 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +295 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-DVP2IJWm.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-BERmf_gM.d.mts +117 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +123 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-D4281dIi.mjs +618 -0
- package/dist/Page-D4281dIi.mjs.map +1 -0
- package/dist/Page-DYTdVhYa.d.mts +593 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-CNNOU1Tx.d.mts +112 -0
- package/dist/Plan-Dtrb-8im.mjs +311 -0
- package/dist/Plan-Dtrb-8im.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +54 -0
- package/dist/Stage.mjs +133 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-BtEG93gR.mjs +420 -0
- package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
- package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-DAYjATgC.mjs +137 -0
- package/dist/context-DAYjATgC.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-Banfm_zc.mjs +5097 -0
- package/dist/page-Banfm_zc.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-CcL0K7S0.mjs +262 -0
- package/dist/url-CcL0K7S0.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +625 -0
- package/src/BrowserError.ts +225 -0
- package/src/BrowserEvent.ts +294 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +130 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +520 -0
- package/src/Plan.ts +350 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +223 -0
- package/src/Supervisor.ts +353 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +607 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +183 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +322 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +159 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +386 -0
- package/src/internal/page/context.ts +302 -0
- package/src/internal/page/lane.ts +301 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +137 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +778 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/pictures.ts +520 -0
- package/src/internal/pictures/transport.ts +242 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +154 -0
- package/src/internal/reading/ready.ts +105 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +265 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +93 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-BF9_sORP.d.mts +0 -388
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
- package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
- package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-yCG9BmRU.mjs +0 -76
- package/dist/Images-yCG9BmRU.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-Dyxu2q8C.mjs +0 -543
- package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
- package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's clock read on the owner's: estimates of their offset from timed probes, how an
|
|
3
|
+
* estimate ages, and the one mapping a browser's pages share. `pictures.ts` takes the probes.
|
|
4
|
+
*/
|
|
5
|
+
import { Duration, Effect, Option } from "effect";
|
|
6
|
+
|
|
7
|
+
export interface Probe {
|
|
8
|
+
readonly hostStart: number;
|
|
9
|
+
readonly hostEnd: number;
|
|
10
|
+
readonly browserTime: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Estimate {
|
|
14
|
+
/** Browser epoch milliseconds minus the owner's host monotonic milliseconds. */
|
|
15
|
+
readonly offsetMillis: number;
|
|
16
|
+
/** Either transport direction can own the entire round trip; symmetry is not assumed. */
|
|
17
|
+
readonly uncertaintyMillis: number;
|
|
18
|
+
readonly roundTripMillis: number;
|
|
19
|
+
readonly sampledAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The fastest complete probe limits uncertainty without mistaking transit delay for clock offset. */
|
|
23
|
+
export const estimate = (probes: ReadonlyArray<Probe>): Option.Option<Estimate> => {
|
|
24
|
+
let best: Estimate | undefined;
|
|
25
|
+
|
|
26
|
+
for (const probe of probes) {
|
|
27
|
+
const roundTripMillis = probe.hostEnd - probe.hostStart;
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
!Number.isFinite(probe.hostStart) ||
|
|
31
|
+
!Number.isFinite(probe.hostEnd) ||
|
|
32
|
+
!Number.isFinite(probe.browserTime) ||
|
|
33
|
+
!Number.isFinite(roundTripMillis) ||
|
|
34
|
+
roundTripMillis < 0
|
|
35
|
+
)
|
|
36
|
+
continue;
|
|
37
|
+
const sampledAt = probe.hostStart + roundTripMillis / 2;
|
|
38
|
+
const offsetMillis = probe.browserTime - sampledAt;
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
!Number.isFinite(offsetMillis) ||
|
|
42
|
+
!Number.isFinite(probe.browserTime - probe.hostStart) ||
|
|
43
|
+
!Number.isFinite(probe.browserTime - probe.hostEnd)
|
|
44
|
+
)
|
|
45
|
+
continue;
|
|
46
|
+
if (best === undefined || roundTripMillis < best.roundTripMillis)
|
|
47
|
+
best = {
|
|
48
|
+
offsetMillis,
|
|
49
|
+
uncertaintyMillis: roundTripMillis / 2,
|
|
50
|
+
roundTripMillis,
|
|
51
|
+
sampledAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Option.fromUndefinedOr(best);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Two machines' clocks drift apart by up to about 100 parts per million, so an estimate says this
|
|
59
|
+
// much less about the offset for each millisecond of its age.
|
|
60
|
+
const driftPerMillis = 1e-4;
|
|
61
|
+
|
|
62
|
+
/** An estimate's uncertainty at `hostTime`, wider the further that is from when it was sampled. */
|
|
63
|
+
export const uncertaintyAt = (estimate: Estimate, hostTime: number): number =>
|
|
64
|
+
estimate.uncertaintyMillis + Math.abs(hostTime - estimate.sampledAt) * driftPerMillis;
|
|
65
|
+
|
|
66
|
+
// Ten seconds widen an estimate by a millisecond, far less than a remote round trip, so it is
|
|
67
|
+
// measured again no more often than this.
|
|
68
|
+
const renewAfter = Duration.seconds(10);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The browser's one epoch mapping, shared by its pages, input runs and capture generations. It is
|
|
72
|
+
* measured on first need, by the first capture, never ahead of it.
|
|
73
|
+
*/
|
|
74
|
+
export interface Mapping {
|
|
75
|
+
/** The latest estimate, without measuring: input uses it, and never waits for one. */
|
|
76
|
+
readonly latest: () => Estimate | undefined;
|
|
77
|
+
/** The latest estimate, measured through `measure` only while the browser has none. */
|
|
78
|
+
readonly current: <E>(measure: Effect.Effect<Estimate, E>) => Effect.Effect<Estimate, E>;
|
|
79
|
+
/**
|
|
80
|
+
* Measure again once ten seconds have passed since the latest measurement began. A failed
|
|
81
|
+
* measurement keeps the estimate, which keeps widening with its age.
|
|
82
|
+
*/
|
|
83
|
+
readonly renew: <E>(measure: Effect.Effect<Estimate, E>) => Effect.Effect<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a new measurement should replace the current estimate. Each interval contains the true
|
|
88
|
+
* offset, so overlapping intervals agree and the narrower one is kept, the current one widened by
|
|
89
|
+
* its age: a probe delayed behind a busy page cannot widen every tab's stamps. A disjoint
|
|
90
|
+
* measurement means the clocks moved (a wall clock step, or drift between a remote browser and
|
|
91
|
+
* this host), so the newer evidence wins.
|
|
92
|
+
*/
|
|
93
|
+
export const supersedes = (next: Estimate, current: Estimate): boolean => {
|
|
94
|
+
const aged = uncertaintyAt(current, next.sampledAt);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
next.uncertaintyMillis <= aged ||
|
|
98
|
+
Math.abs(next.offsetMillis - current.offsetMillis) > next.uncertaintyMillis + aged
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every renderer of a browser reads the same host wall clock, so the offset belongs to the
|
|
104
|
+
* browser rather than a page. A busy page cannot fail work that an earlier estimate can serve.
|
|
105
|
+
*/
|
|
106
|
+
export const mapping = (now: () => number): Mapping => {
|
|
107
|
+
let latest: Estimate | undefined;
|
|
108
|
+
let measuredAt = Number.NEGATIVE_INFINITY;
|
|
109
|
+
|
|
110
|
+
// Called where a measurement is decided, so no other can be decided before this one begins.
|
|
111
|
+
const adopt = <E>(measure: Effect.Effect<Estimate, E>) => {
|
|
112
|
+
measuredAt = now();
|
|
113
|
+
|
|
114
|
+
return measure.pipe(
|
|
115
|
+
Effect.map((estimate) => {
|
|
116
|
+
if (latest === undefined || supersedes(estimate, latest)) latest = estimate;
|
|
117
|
+
|
|
118
|
+
return latest;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
latest: () => latest,
|
|
125
|
+
current: (measure) =>
|
|
126
|
+
Effect.suspend(() => (latest === undefined ? adopt(measure) : Effect.succeed(latest))),
|
|
127
|
+
renew: (measure) =>
|
|
128
|
+
Effect.suspend(() =>
|
|
129
|
+
latest === undefined || now() - measuredAt < Duration.toMillis(renewAfter)
|
|
130
|
+
? Effect.void
|
|
131
|
+
: Effect.ignore(adopt(measure)),
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const toHostTime = (calibration: Estimate, browserEpochMillis: number): number =>
|
|
137
|
+
browserEpochMillis - calibration.offsetMillis;
|
|
138
|
+
|
|
139
|
+
/** CDP input timestamps use seconds, unlike browser frame timestamps and the owner's clock. */
|
|
140
|
+
export const toBrowserSeconds = (calibration: Estimate, hostMillis: number): number =>
|
|
141
|
+
(hostMillis + calibration.offsetMillis) / 1000;
|
|
@@ -0,0 +1,520 @@
|
|
|
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, 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
|
+
page.now() - earliest <= maxAge &&
|
|
346
|
+
earliest > activity.documentAt &&
|
|
347
|
+
(options.after !== "input" || earliest > activity.inputAt) &&
|
|
348
|
+
frame.width === known?.width &&
|
|
349
|
+
frame.height === known.height
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/** Where a picture came from: a frame the screencast had delivered, or a new screenshot. */
|
|
355
|
+
const sourceOf = (reused: Option.Option<Frame>) => (Option.isSome(reused) ? "frame" : "screenshot");
|
|
356
|
+
|
|
357
|
+
export const make = Effect.fnUntraced(function* (
|
|
358
|
+
page: PageContext,
|
|
359
|
+
bridge: Bridge,
|
|
360
|
+
viewport: Viewport,
|
|
361
|
+
) {
|
|
362
|
+
const { id, clock, settings, mapping, now, span, owned, lane, within } = page;
|
|
363
|
+
const calibrateClock = calibrator(page, bridge);
|
|
364
|
+
|
|
365
|
+
// A failure from a capture connection is already the capture's own.
|
|
366
|
+
const error = (cause: unknown) =>
|
|
367
|
+
Schema.is(BrowserError)(cause)
|
|
368
|
+
? cause
|
|
369
|
+
: undispatched("screencast", reasonOf(cause, undefined, page.closedBy()));
|
|
370
|
+
|
|
371
|
+
// Input and capture share the owner's monotonic clock; caller-provided clocks cannot move it.
|
|
372
|
+
// Registration measures nothing: a page that is busy while it opens, such as a popup running
|
|
373
|
+
// its first script, must still be tracked. The browser's mapping serves every other page, so
|
|
374
|
+
// only a browser with no estimate yet needs this page's renderer to answer.
|
|
375
|
+
const capture = yield* Capture.make({
|
|
376
|
+
id,
|
|
377
|
+
transport:
|
|
378
|
+
page.capture === undefined
|
|
379
|
+
? Effect.succeed(Transport.own(page, bridge))
|
|
380
|
+
: Transport.apart(page, bridge, page.capture, error, yield* Effect.scope),
|
|
381
|
+
clock,
|
|
382
|
+
// A capture starts once the page's own session keeps it painting behind other tabs, which a
|
|
383
|
+
// renderer stuck in a script never confirms, and with the browser's clock mapped. Only a
|
|
384
|
+
// browser's first capture waits for a measurement; every frame takes the newest estimate.
|
|
385
|
+
calibrate: page.focused.pipe(within("focus"), Effect.andThen(mapping.current(calibrateClock))),
|
|
386
|
+
latest: mapping.latest,
|
|
387
|
+
// Once the estimate is ten seconds old, the page measures again while its frames flow. A
|
|
388
|
+
// failure there shows in its `Page.calibrateClock` span, and the estimate stays.
|
|
389
|
+
renew: mapping.renew(calibrateClock),
|
|
390
|
+
// Frames are reused only after the current document began, which the page's own session sees
|
|
391
|
+
// only with the Page domain on: a read turns it on, and so does a capture as it starts.
|
|
392
|
+
watch: bridge.currentDocument("screencast"),
|
|
393
|
+
frameHistory: settings.frameHistory,
|
|
394
|
+
viewport: Effect.suspend(() => viewport.viewportFor("screencast")),
|
|
395
|
+
imageSize: jpegSize,
|
|
396
|
+
error,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const picture = camera(page, bridge, viewport, capture);
|
|
400
|
+
const reusable = reuse(page, viewport, capture);
|
|
401
|
+
// Pictures in flight are shared, never kept: their callers say how old one may be.
|
|
402
|
+
const screenshots = lane.shared<Image>("screenshot", false);
|
|
403
|
+
const frames = lane.shared<Frame>("frame", false);
|
|
404
|
+
|
|
405
|
+
// A caller that acts and then looks must see what its action did, so a screenshot waits for the
|
|
406
|
+
// action in flight and reuses only a frame painted after the page's latest input.
|
|
407
|
+
const screenshot = (options: ScreenshotOptions = {}) =>
|
|
408
|
+
screenshots(
|
|
409
|
+
JSON.stringify([
|
|
410
|
+
millisOf(options.maxAge ?? defaultMaxAge),
|
|
411
|
+
options.clip,
|
|
412
|
+
options.quality,
|
|
413
|
+
options.format,
|
|
414
|
+
]),
|
|
415
|
+
Effect.gen(function* () {
|
|
416
|
+
// Screencast frames are JPEGs, so a PNG is always new.
|
|
417
|
+
const reused =
|
|
418
|
+
options.clip === undefined && options.format !== "png"
|
|
419
|
+
? yield* reusable("screenshot", { maxAge: options.maxAge, after: "input" })
|
|
420
|
+
: Option.none<Frame>();
|
|
421
|
+
|
|
422
|
+
const image = Option.isSome(reused)
|
|
423
|
+
? reused.value.image
|
|
424
|
+
: yield* picture("screenshot", options.quality ?? 80, options.clip, options.format);
|
|
425
|
+
|
|
426
|
+
yield* Effect.annotateCurrentSpan({ source: sourceOf(reused), bytes: image.data.length });
|
|
427
|
+
|
|
428
|
+
return image;
|
|
429
|
+
}),
|
|
430
|
+
).pipe(span("Page.screenshot"), owned);
|
|
431
|
+
|
|
432
|
+
// A new screenshot has no paint time, only the host interval in which it was taken.
|
|
433
|
+
const taken = (options: FrameOptions) =>
|
|
434
|
+
Effect.gen(function* () {
|
|
435
|
+
const reused = yield* reusable("frame", options);
|
|
436
|
+
|
|
437
|
+
yield* Effect.annotateCurrentSpan("source", sourceOf(reused));
|
|
438
|
+
if (Option.isSome(reused)) return reused.value;
|
|
439
|
+
const startedAt = now();
|
|
440
|
+
const image = yield* picture("frame", 80);
|
|
441
|
+
const finishedAt = now();
|
|
442
|
+
|
|
443
|
+
return new Frame({
|
|
444
|
+
page: id,
|
|
445
|
+
data: image.data,
|
|
446
|
+
timing: new Screenshot({
|
|
447
|
+
hostTime: startedAt + (finishedAt - startedAt) / 2,
|
|
448
|
+
uncertaintyMillis: (finishedAt - startedAt) / 2,
|
|
449
|
+
}),
|
|
450
|
+
receivedAt: finishedAt,
|
|
451
|
+
width: image.width,
|
|
452
|
+
height: image.height,
|
|
453
|
+
...bridge.frameTag(),
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// A frame of a stated age that the screencast has costs nothing and waits for no action; one
|
|
458
|
+
// that must follow the latest input, or a new screenshot, waits for the action in flight.
|
|
459
|
+
const frame = (options: FrameOptions = {}) =>
|
|
460
|
+
(options.after === "input"
|
|
461
|
+
? Effect.succeed(Option.none<Frame>())
|
|
462
|
+
: reusable("frame", options)
|
|
463
|
+
).pipe(
|
|
464
|
+
Effect.flatMap(
|
|
465
|
+
Option.match({
|
|
466
|
+
onSome: (reused) => Effect.as(Effect.annotateCurrentSpan("source", "frame"), reused),
|
|
467
|
+
onNone: () =>
|
|
468
|
+
frames(
|
|
469
|
+
JSON.stringify([millisOf(options.maxAge ?? defaultMaxAge), options.after]),
|
|
470
|
+
taken(options),
|
|
471
|
+
),
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
span("Page.frame"),
|
|
475
|
+
owned,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const zoom = (requested: Region) =>
|
|
479
|
+
lane
|
|
480
|
+
.read("zoom")(
|
|
481
|
+
Effect.gen(function* () {
|
|
482
|
+
const region = yield* Schema.decodeEffect(Region)(requested).pipe(
|
|
483
|
+
Effect.mapError((error) =>
|
|
484
|
+
undispatched("zoom", new InvalidRequest({ detail: error.message })),
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// The viewport's latest known size, as the pictures the region was chosen from had it.
|
|
489
|
+
const size = viewport.knownViewport() ?? (yield* viewport.viewportFor("zoom"));
|
|
490
|
+
|
|
491
|
+
if (region.x + region.width > size.width || region.y + region.height > size.height)
|
|
492
|
+
return yield* failWith(
|
|
493
|
+
"zoom",
|
|
494
|
+
new InvalidRequest({ detail: "the crop must fit entirely within the viewport" }),
|
|
495
|
+
);
|
|
496
|
+
const image = yield* picture("zoom", 80, region);
|
|
497
|
+
|
|
498
|
+
return new Zoom({ page: id, region, image });
|
|
499
|
+
}).pipe(within("zoom")),
|
|
500
|
+
)
|
|
501
|
+
.pipe(span("Page.zoom"), owned);
|
|
502
|
+
|
|
503
|
+
const captureStats = (options: { readonly window?: Duration.Input | undefined } = {}) => {
|
|
504
|
+
if (options.window === undefined) return capture.stats();
|
|
505
|
+
const millis = millisOf(options.window);
|
|
506
|
+
|
|
507
|
+
return millis > 0 && millis <= Duration.toMillis(Capture.countsKept)
|
|
508
|
+
? capture.stats(millis)
|
|
509
|
+
: failWith(
|
|
510
|
+
"captureStats",
|
|
511
|
+
new InvalidRequest({ detail: "window must be over zero and at most a minute" }),
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// The browser's clock mapping, measured on this page only while the browser has none, as the
|
|
516
|
+
// change record's times need it.
|
|
517
|
+
const estimate = mapping.current(calibrateClock);
|
|
518
|
+
|
|
519
|
+
return { capture, screenshot, frame, zoom, captureStats, estimate };
|
|
520
|
+
});
|