@push.rocks/smartbrowser 4.0.2 → 4.2.0

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.
@@ -6,6 +6,7 @@ import type {
6
6
  ILiveBrowserMouseInput,
7
7
  ILiveBrowserState,
8
8
  ILiveBrowserViewport,
9
+ ILiveBrowserWheelInput,
9
10
  TLiveBrowserEvent,
10
11
  } from '@push.rocks/smartpuppeteer';
11
12
 
@@ -13,9 +14,15 @@ import type {
13
14
  ILiveBrowserCanvasError,
14
15
  ILiveBrowserCanvasOperationOptions,
15
16
  ILiveBrowserCanvasRendererOptions,
17
+ ILiveBrowserCanvasRendererStatistics,
16
18
  } from './interfaces.livebrowsercanvas.js';
17
19
 
18
20
  const maxInputQueueLength = 128;
21
+ const maxInFlightInputCommands = 4;
22
+ const maxQueuedCoalescableCommands = 32;
23
+ const maxWheelDelta = 1000000;
24
+ const animationFrameFallbackMs = 16;
25
+ const resizeDebounceMs = 100;
19
26
  const maxPendingAcknowledgements = 16;
20
27
  const maxInputReleaseAttempts = 2;
21
28
  const maxFrameDimension = 12288;
@@ -45,20 +52,67 @@ interface IDisplayedFrame extends ILiveBrowserInputBase {
45
52
  }
46
53
 
47
54
  type TInputCommandKind = 'key' | 'mouse' | 'mouseMove' | 'text' | 'wheel';
55
+ type TCoalescableInputCommandKind = 'mouseMove' | 'wheel';
48
56
 
49
57
  interface IInputCommand {
50
58
  kind: TInputCommandKind;
51
59
  runEpoch: number;
52
60
  execute: (optionsArg: ILiveBrowserCanvasOperationOptions) => Promise<void>;
61
+ /** Mutable merged wheel payload that execute() reads at dispatch time. */
62
+ wheelInput?: ILiveBrowserWheelInput;
53
63
  onAttempt?: () => void;
54
64
  onSuccess?: () => void;
55
65
  resolve: () => void;
56
66
  reject: (errorArg: unknown) => void;
57
67
  }
58
68
 
69
+ interface IPendingWheel extends ILiveBrowserWheelInput {
70
+ runEpoch: number;
71
+ }
72
+
73
+ interface IPendingDraw {
74
+ bitmap: ImageBitmap;
75
+ frame: ILiveBrowserFrame;
76
+ runEpoch: number;
77
+ }
78
+
79
+ interface IScheduledFrame {
80
+ animationFrameId?: number;
81
+ timeoutId?: ReturnType<typeof setTimeout>;
82
+ }
83
+
84
+ const isCoalescableInputCommand = (
85
+ kindArg: TInputCommandKind,
86
+ ): kindArg is TCoalescableInputCommandKind => kindArg === 'mouseMove' || kindArg === 'wheel';
87
+
88
+ const clampWheelDelta = (valueArg: number): number => (
89
+ Math.max(-maxWheelDelta, Math.min(maxWheelDelta, valueArg))
90
+ );
91
+
92
+ const mergeWheelInput = (
93
+ targetArg: ILiveBrowserWheelInput,
94
+ sourceArg: ILiveBrowserWheelInput,
95
+ ): void => {
96
+ targetArg.x = sourceArg.x;
97
+ targetArg.y = sourceArg.y;
98
+ targetArg.deltaX = clampWheelDelta(targetArg.deltaX + sourceArg.deltaX);
99
+ targetArg.deltaY = clampWheelDelta(targetArg.deltaY + sourceArg.deltaY);
100
+ targetArg.modifiers = sourceArg.modifiers;
101
+ };
102
+
103
+ const inputIdentityEquals = (
104
+ firstArg: ILiveBrowserInputBase,
105
+ secondArg: ILiveBrowserInputBase,
106
+ ): boolean => (
107
+ firstArg.tabId === secondArg.tabId
108
+ && firstArg.generation === secondArg.generation
109
+ && firstArg.viewportRevision === secondArg.viewportRevision
110
+ );
111
+
59
112
  interface IResizeFence {
60
113
  minimumViewportRevision: number;
61
114
  target: ILiveBrowserViewport;
115
+ negotiated?: boolean;
62
116
  }
63
117
 
64
118
  const viewportEquals = (
@@ -102,7 +156,8 @@ const getErrorMessage = (errorArg: unknown): string => (
102
156
  */
103
157
  export class LiveBrowserCanvasRenderer {
104
158
  private readonly canvas: HTMLCanvasElement;
105
- private readonly canvasContext: CanvasRenderingContext2D;
159
+ private readonly canvasContext?: CanvasRenderingContext2D;
160
+ private readonly bitmapContext?: ImageBitmapRenderingContext;
106
161
  private readonly client: ILiveBrowserCanvasRendererOptions['client'];
107
162
  private readonly focusTarget: HTMLElement;
108
163
  private readonly resizeTarget?: Element;
@@ -135,17 +190,22 @@ export class LiveBrowserCanvasRenderer {
135
190
  private highestFrameSequence = -1;
136
191
  private queuedFrame?: IFrameWork;
137
192
  private frameProcessingPromise?: Promise<void>;
193
+ private pendingDraw?: IPendingDraw;
194
+ private drawSchedule?: IScheduledFrame;
138
195
  private acknowledgementPromises = new Set<Promise<void>>();
139
196
  private rawDecodeSettlementPromises = new Set<Promise<void>>();
140
197
 
141
198
  private inputBlocked = true;
142
199
  private inputCommands: IInputCommand[] = [];
143
- private inputProcessing = false;
200
+ private inFlightInputCommands = 0;
201
+ private inFlightWheelCommands = 0;
144
202
  private inputIdleResolvers: Array<() => void> = [];
145
203
  private inputResetTail: Promise<void> = Promise.resolve();
146
204
  private inputResetPromise?: Promise<void>;
147
205
  private inputResetCount = 0;
148
- private inputOverflowRecoveryPending = false;
206
+ private pendingWheel?: IPendingWheel;
207
+ private wheelFlushSchedule?: IScheduledFrame;
208
+ private wheelFrameElapsed = true;
149
209
  private pressedKeys = new Map<string, ILiveBrowserKeyInput>();
150
210
  private pressedMouseButtons = new Map<string, ILiveBrowserMouseInput>();
151
211
  private capturedPointerIds = new Set<number>();
@@ -155,6 +215,18 @@ export class LiveBrowserCanvasRenderer {
155
215
  private viewportProcessing = false;
156
216
  private viewportProcessingPromise?: Promise<void>;
157
217
  private resizeFence?: IResizeFence;
218
+ private lastRequestedViewport?: ILiveBrowserViewport;
219
+ private resizeDebounceTimer?: ReturnType<typeof setTimeout>;
220
+ private pendingResizeSize?: { width: number; height: number };
221
+
222
+ private readonly statistics: Omit<ILiveBrowserCanvasRendererStatistics, 'inputCommandsInFlight'> = {
223
+ framesReceived: 0,
224
+ framesDecoded: 0,
225
+ framesSkipped: 0,
226
+ inputCommandsEnqueued: 0,
227
+ inputCommandsCoalesced: 0,
228
+ lastInputRoundTripMs: 0,
229
+ };
158
230
 
159
231
  constructor(optionsArg: ILiveBrowserCanvasRendererOptions) {
160
232
  this.canvas = optionsArg.canvas;
@@ -176,11 +248,17 @@ export class LiveBrowserCanvasRenderer {
176
248
  this.initialCanvasWidth = this.canvas.width;
177
249
  this.initialCanvasHeight = this.canvas.height;
178
250
 
179
- const canvasContext = this.canvas.getContext('2d');
180
- if (!canvasContext) {
181
- throw new Error('LiveBrowserCanvasRenderer requires a 2D canvas context');
251
+ const bitmapContext = this.canvas.getContext('bitmaprenderer');
252
+ if (bitmapContext) {
253
+ this.bitmapContext = bitmapContext;
254
+ } else {
255
+ // A canvas that already owns a 2D context keeps the 2D drawing path.
256
+ const canvasContext = this.canvas.getContext('2d');
257
+ if (!canvasContext) {
258
+ throw new Error('LiveBrowserCanvasRenderer requires a bitmaprenderer or 2D canvas context');
259
+ }
260
+ this.canvasContext = canvasContext;
182
261
  }
183
- this.canvasContext = canvasContext;
184
262
  }
185
263
 
186
264
  public get isRunning(): boolean {
@@ -191,6 +269,16 @@ export class LiveBrowserCanvasRenderer {
191
269
  return this.suspended;
192
270
  }
193
271
 
272
+ /**
273
+ * Cumulative frame and input counters for this renderer instance across runs.
274
+ */
275
+ public getStatistics(): ILiveBrowserCanvasRendererStatistics {
276
+ return {
277
+ ...this.statistics,
278
+ inputCommandsInFlight: this.inFlightInputCommands,
279
+ };
280
+ }
281
+
194
282
  public start(): Promise<void> {
195
283
  return this.enqueueLifecycle(async () => {
196
284
  if (this.running) {
@@ -299,6 +387,7 @@ export class LiveBrowserCanvasRenderer {
299
387
  this.interruptionRequested = false;
300
388
  this.acceptingFrames = true;
301
389
  this.inputBlocked = true;
390
+ this.wheelFrameElapsed = true;
302
391
  this.state = undefined;
303
392
  this.displayedFrame = undefined;
304
393
  this.highestFrameSequence = -1;
@@ -335,6 +424,8 @@ export class LiveBrowserCanvasRenderer {
335
424
  this.queuedFrame = undefined;
336
425
  this.displayedFrame = undefined;
337
426
  this.resizeFence = undefined;
427
+ this.discardPendingDraw();
428
+ this.discardPendingWheel();
338
429
  this.releasePointerCaptures();
339
430
  this.clearCanvas();
340
431
 
@@ -352,7 +443,6 @@ export class LiveBrowserCanvasRenderer {
352
443
  }
353
444
  this.pressedKeys.clear();
354
445
  this.pressedMouseButtons.clear();
355
- this.inputOverflowRecoveryPending = false;
356
446
  }
357
447
 
358
448
  private async finishRun(runEpochArg: number, suspendArg: boolean): Promise<void> {
@@ -363,6 +453,7 @@ export class LiveBrowserCanvasRenderer {
363
453
  try {
364
454
  this.resizeObserver?.disconnect();
365
455
  this.resizeObserver = undefined;
456
+ this.clearResizeDebounce();
366
457
  this.listenerController?.abort();
367
458
  this.listenerController = undefined;
368
459
  const unsubscribe = this.unsubscribe;
@@ -402,8 +493,12 @@ export class LiveBrowserCanvasRenderer {
402
493
  this.highestFrameSequence = -1;
403
494
  this.queuedFrame = undefined;
404
495
  this.frameProcessingPromise = undefined;
496
+ this.discardPendingDraw();
497
+ this.discardPendingWheel();
498
+ this.clearResizeDebounce();
405
499
  this.acknowledgementPromises.clear();
406
500
  this.resizeFence = undefined;
501
+ this.lastRequestedViewport = undefined;
407
502
  this.pendingViewport = undefined;
408
503
  this.inFlightViewport = undefined;
409
504
  this.viewportProcessing = false;
@@ -498,19 +593,37 @@ export class LiveBrowserCanvasRenderer {
498
593
  }
499
594
  this.resizeObserver = new ResizeObserver((entriesArg) => {
500
595
  const matchingEntry = entriesArg.find((entryArg) => entryArg.target === this.resizeTarget);
501
- if (!matchingEntry) {
596
+ if (!matchingEntry || runEpochArg !== this.runEpoch) {
502
597
  return;
503
598
  }
504
- this.queueViewport(
505
- matchingEntry.contentRect.width,
506
- matchingEntry.contentRect.height,
507
- runEpochArg,
508
- );
599
+ this.pendingResizeSize = {
600
+ width: matchingEntry.contentRect.width,
601
+ height: matchingEntry.contentRect.height,
602
+ };
603
+ if (this.resizeDebounceTimer !== undefined) {
604
+ globalThis.clearTimeout(this.resizeDebounceTimer);
605
+ }
606
+ this.resizeDebounceTimer = globalThis.setTimeout(() => {
607
+ this.resizeDebounceTimer = undefined;
608
+ const pendingResizeSize = this.pendingResizeSize;
609
+ this.pendingResizeSize = undefined;
610
+ if (pendingResizeSize) {
611
+ this.queueViewport(pendingResizeSize.width, pendingResizeSize.height, runEpochArg);
612
+ }
613
+ }, resizeDebounceMs);
509
614
  });
510
615
  this.resizeObserver.observe(this.resizeTarget);
511
616
  this.syncViewport();
512
617
  }
513
618
 
619
+ private clearResizeDebounce(): void {
620
+ if (this.resizeDebounceTimer !== undefined) {
621
+ globalThis.clearTimeout(this.resizeDebounceTimer);
622
+ this.resizeDebounceTimer = undefined;
623
+ }
624
+ this.pendingResizeSize = undefined;
625
+ }
626
+
514
627
  private handleEvent(eventArg: TLiveBrowserEvent, runEpochArg: number): void {
515
628
  if (eventArg.type === 'frame') {
516
629
  this.queueFrame(eventArg.frame, runEpochArg);
@@ -535,6 +648,7 @@ export class LiveBrowserCanvasRenderer {
535
648
  return;
536
649
  }
537
650
  this.state = stateArg;
651
+ this.followNegotiatedViewport();
538
652
 
539
653
  if (this.displayedFrame && this.isStateAheadOfDisplayedFrame(stateArg, this.displayedFrame)) {
540
654
  this.displayedFrame = undefined;
@@ -564,6 +678,7 @@ export class LiveBrowserCanvasRenderer {
564
678
  if (!this.running || !this.acceptingFrames || runEpochArg !== this.runEpoch) {
565
679
  return;
566
680
  }
681
+ this.statistics.framesReceived++;
567
682
 
568
683
  try {
569
684
  this.validateFrame(frameArg);
@@ -590,11 +705,12 @@ export class LiveBrowserCanvasRenderer {
590
705
  acknowledgementAttempted: false,
591
706
  };
592
707
  if (this.queuedFrame) {
593
- const supersededFrame = this.queuedFrame;
708
+ // The superseded frame was already acknowledged at receipt.
594
709
  this.queuedFrame = undefined;
595
- void this.acknowledgeFrame(supersededFrame);
710
+ this.statistics.framesSkipped++;
596
711
  }
597
712
  this.queuedFrame = frameWork;
713
+ void this.acknowledgeFrame(frameWork);
598
714
 
599
715
  this.ensureFrameProcessor();
600
716
  }
@@ -643,32 +759,14 @@ export class LiveBrowserCanvasRenderer {
643
759
  `decoded frame dimensions ${imageBitmap.width}x${imageBitmap.height} do not match ${frame.width}x${frame.height}`,
644
760
  );
645
761
  }
646
- if (!this.canRenderFrame(frame, runEpoch) || frame.sequence !== this.highestFrameSequence) {
762
+ this.statistics.framesDecoded++;
763
+ if (!this.canRenderFrame(frame, runEpoch)) {
764
+ this.statistics.framesSkipped++;
647
765
  return;
648
766
  }
649
767
 
650
- this.canvas.width = frame.width;
651
- this.canvas.height = frame.height;
652
- this.canvasContext.clearRect(0, 0, frame.width, frame.height);
653
- this.canvasContext.drawImage(imageBitmap, 0, 0, frame.width, frame.height);
654
- this.displayedFrame = {
655
- tabId: frame.tabId,
656
- sequence: frame.sequence,
657
- generation: frame.generation,
658
- viewportRevision: frame.viewportRevision,
659
- viewport: { ...frame.viewport },
660
- };
661
-
662
- if (
663
- this.resizeFence
664
- && !this.viewportProcessing
665
- && frame.viewportRevision >= this.resizeFence.minimumViewportRevision
666
- && viewportEquals(frame.viewport, this.resizeFence.target)
667
- ) {
668
- this.resizeFence = undefined;
669
- }
670
- this.updateInputAvailability();
671
- this.callFrameRendered(frame, runEpoch);
768
+ this.setPendingDraw({ bitmap: imageBitmap, frame, runEpoch });
769
+ imageBitmap = undefined;
672
770
  } catch (error) {
673
771
  if (error instanceof LiveBrowserRunAbortedError) {
674
772
  return;
@@ -677,7 +775,6 @@ export class LiveBrowserCanvasRenderer {
677
775
  terminalError = error;
678
776
  } finally {
679
777
  imageBitmap?.close();
680
- await this.acknowledgeFrame(frameWorkArg);
681
778
  if (terminalFailure) {
682
779
  this.requestSuspend(runEpoch);
683
780
  this.reportError({
@@ -690,12 +787,109 @@ export class LiveBrowserCanvasRenderer {
690
787
  }
691
788
  }
692
789
 
790
+ /**
791
+ * Keeps only the newest decoded bitmap and presents it on the next animation frame.
792
+ */
793
+ private setPendingDraw(pendingDrawArg: IPendingDraw): void {
794
+ if (this.pendingDraw) {
795
+ this.pendingDraw.bitmap.close();
796
+ this.statistics.framesSkipped++;
797
+ }
798
+ this.pendingDraw = pendingDrawArg;
799
+ if (!this.drawSchedule) {
800
+ this.drawSchedule = this.scheduleAnimationFrame(() => {
801
+ this.drawSchedule = undefined;
802
+ this.drawPendingFrame();
803
+ });
804
+ }
805
+ }
806
+
807
+ private discardPendingDraw(): void {
808
+ if (this.drawSchedule) {
809
+ this.cancelScheduledFrame(this.drawSchedule);
810
+ this.drawSchedule = undefined;
811
+ }
812
+ const pendingDraw = this.pendingDraw;
813
+ this.pendingDraw = undefined;
814
+ if (pendingDraw) {
815
+ pendingDraw.bitmap.close();
816
+ this.statistics.framesSkipped++;
817
+ }
818
+ }
819
+
820
+ private drawPendingFrame(): void {
821
+ const pendingDraw = this.pendingDraw;
822
+ this.pendingDraw = undefined;
823
+ if (!pendingDraw) {
824
+ return;
825
+ }
826
+ const { bitmap, frame, runEpoch } = pendingDraw;
827
+ if (!this.canRenderFrame(frame, runEpoch)) {
828
+ bitmap.close();
829
+ this.statistics.framesSkipped++;
830
+ return;
831
+ }
832
+ try {
833
+ this.paintBitmap(bitmap, frame.width, frame.height);
834
+ } catch (error) {
835
+ bitmap.close();
836
+ this.requestSuspend(runEpoch);
837
+ this.reportError({
838
+ code: 'frame_render_failed',
839
+ message: `Could not render live browser frame: ${getErrorMessage(error)}`,
840
+ cause: error,
841
+ frame,
842
+ }, runEpoch);
843
+ return;
844
+ }
845
+ this.displayedFrame = {
846
+ tabId: frame.tabId,
847
+ sequence: frame.sequence,
848
+ generation: frame.generation,
849
+ viewportRevision: frame.viewportRevision,
850
+ viewport: { ...frame.viewport },
851
+ };
852
+
853
+ if (
854
+ this.resizeFence
855
+ && !this.viewportProcessing
856
+ && frame.viewportRevision >= this.resizeFence.minimumViewportRevision
857
+ && viewportEquals(frame.viewport, this.resizeFence.target)
858
+ ) {
859
+ this.resizeFence = undefined;
860
+ }
861
+ this.updateInputAvailability();
862
+ this.callFrameRendered(frame, runEpoch);
863
+ }
864
+
865
+ /**
866
+ * Presents one decoded bitmap. The canvas backing store is reallocated only
867
+ * when the frame dimensions differ from the current intrinsic dimensions.
868
+ */
869
+ private paintBitmap(bitmapArg: ImageBitmap, widthArg: number, heightArg: number): void {
870
+ if (this.canvas.width !== widthArg) {
871
+ this.canvas.width = widthArg;
872
+ }
873
+ if (this.canvas.height !== heightArg) {
874
+ this.canvas.height = heightArg;
875
+ }
876
+ if (this.bitmapContext) {
877
+ this.bitmapContext.transferFromImageBitmap(bitmapArg);
878
+ return;
879
+ }
880
+ if (!this.canvasContext) {
881
+ throw new Error('LiveBrowserCanvasRenderer has no rendering context');
882
+ }
883
+ this.canvasContext.clearRect(0, 0, widthArg, heightArg);
884
+ this.canvasContext.drawImage(bitmapArg, 0, 0, widthArg, heightArg);
885
+ bitmapArg.close();
886
+ }
887
+
693
888
  private canRenderFrame(frameArg: ILiveBrowserFrame, runEpochArg: number): boolean {
694
889
  if (
695
890
  !this.running
696
891
  || !this.acceptingFrames
697
892
  || runEpochArg !== this.runEpoch
698
- || this.viewportProcessing
699
893
  ) {
700
894
  return false;
701
895
  }
@@ -713,7 +907,7 @@ export class LiveBrowserCanvasRenderer {
713
907
  ) {
714
908
  return false;
715
909
  }
716
- if (this.resizeFence) {
910
+ if (this.resizeFence && !this.viewportProcessing) {
717
911
  return frameArg.viewportRevision >= this.resizeFence.minimumViewportRevision
718
912
  && viewportEquals(frameArg.viewport, this.resizeFence.target);
719
913
  }
@@ -889,20 +1083,110 @@ export class LiveBrowserCanvasRenderer {
889
1083
  : eventArg.deltaMode === WheelEvent.DOM_DELTA_PAGE
890
1084
  ? displayedFrame.viewport.height
891
1085
  : 1;
892
- const clampDelta = (valueArg: number) => Math.max(-1000000, Math.min(1000000, valueArg));
1086
+ const inputIdentity = createInputIdentity(displayedFrame);
1087
+ const modifiers = createModifiers(eventArg);
1088
+ this.statistics.inputCommandsEnqueued++;
1089
+ const pendingWheel = this.pendingWheel;
1090
+ if (
1091
+ pendingWheel
1092
+ && pendingWheel.runEpoch === runEpochArg
1093
+ && inputIdentityEquals(pendingWheel, inputIdentity)
1094
+ ) {
1095
+ mergeWheelInput(pendingWheel, {
1096
+ ...inputIdentity,
1097
+ ...coordinates,
1098
+ deltaX: eventArg.deltaX * multiplier,
1099
+ deltaY: eventArg.deltaY * multiplier,
1100
+ modifiers,
1101
+ });
1102
+ this.statistics.inputCommandsCoalesced++;
1103
+ } else {
1104
+ if (pendingWheel) {
1105
+ this.flushPendingWheel();
1106
+ }
1107
+ this.pendingWheel = {
1108
+ ...inputIdentity,
1109
+ ...coordinates,
1110
+ deltaX: clampWheelDelta(eventArg.deltaX * multiplier),
1111
+ deltaY: clampWheelDelta(eventArg.deltaY * multiplier),
1112
+ modifiers,
1113
+ runEpoch: runEpochArg,
1114
+ };
1115
+ }
1116
+ this.tryFlushPendingWheel();
1117
+ }
1118
+
1119
+ /**
1120
+ * Flushes accumulated wheel input at most once per animation frame and only
1121
+ * while no wheel dispatch is in flight; the settling dispatch flushes the rest.
1122
+ */
1123
+ private tryFlushPendingWheel(): void {
1124
+ if (!this.pendingWheel) {
1125
+ return;
1126
+ }
1127
+ if (!this.wheelFrameElapsed) {
1128
+ this.scheduleWheelFlush();
1129
+ return;
1130
+ }
1131
+ if (this.inFlightWheelCommands > 0) {
1132
+ return;
1133
+ }
1134
+ this.flushPendingWheel();
1135
+ }
1136
+
1137
+ private scheduleWheelFlush(): void {
1138
+ if (this.wheelFlushSchedule) {
1139
+ return;
1140
+ }
1141
+ this.wheelFlushSchedule = this.scheduleAnimationFrame(() => {
1142
+ this.wheelFlushSchedule = undefined;
1143
+ this.wheelFrameElapsed = true;
1144
+ this.tryFlushPendingWheel();
1145
+ });
1146
+ }
1147
+
1148
+ /**
1149
+ * Moves the accumulated wheel input into the command queue regardless of the
1150
+ * frame gate so that later discrete commands stay behind it.
1151
+ */
1152
+ private flushPendingWheel(): void {
1153
+ const pendingWheel = this.pendingWheel;
1154
+ if (!pendingWheel) {
1155
+ return;
1156
+ }
1157
+ this.pendingWheel = undefined;
1158
+ this.wheelFrameElapsed = false;
1159
+ this.scheduleWheelFlush();
1160
+ const displayedFrame = this.getInputFrame();
1161
+ if (
1162
+ !displayedFrame
1163
+ || pendingWheel.runEpoch !== this.runEpoch
1164
+ || !inputIdentityEquals(pendingWheel, createInputIdentity(displayedFrame))
1165
+ ) {
1166
+ return;
1167
+ }
1168
+ const { runEpoch, ...wheelInput } = pendingWheel;
893
1169
  void this.enqueueInputCommand({
894
1170
  kind: 'wheel',
895
- runEpoch: runEpochArg,
1171
+ runEpoch,
1172
+ wheelInput,
896
1173
  execute: async (optionsArg) => this.client.dispatchWheel({
897
- ...createInputIdentity(displayedFrame),
898
- ...coordinates,
899
- deltaX: clampDelta(eventArg.deltaX * multiplier),
900
- deltaY: clampDelta(eventArg.deltaY * multiplier),
901
- modifiers: createModifiers(eventArg),
1174
+ ...wheelInput,
1175
+ deltaX: clampWheelDelta(wheelInput.deltaX),
1176
+ deltaY: clampWheelDelta(wheelInput.deltaY),
902
1177
  }, optionsArg),
903
1178
  }).catch(() => undefined);
904
1179
  }
905
1180
 
1181
+ private discardPendingWheel(): void {
1182
+ this.pendingWheel = undefined;
1183
+ if (this.wheelFlushSchedule) {
1184
+ this.cancelScheduledFrame(this.wheelFlushSchedule);
1185
+ this.wheelFlushSchedule = undefined;
1186
+ }
1187
+ this.wheelFrameElapsed = true;
1188
+ }
1189
+
906
1190
  private handleKey(
907
1191
  eventArg: KeyboardEvent,
908
1192
  typeArg: 'down' | 'up',
@@ -1028,97 +1312,175 @@ export class LiveBrowserCanvasRenderer {
1028
1312
  resolve,
1029
1313
  reject,
1030
1314
  };
1031
- const lastCommand = this.inputCommands.at(-1);
1032
- if (command.kind === 'mouseMove' && lastCommand?.kind === 'mouseMove') {
1033
- lastCommand.resolve();
1034
- this.inputCommands[this.inputCommands.length - 1] = command;
1035
- } else if (this.inputCommands.length >= maxInputQueueLength) {
1036
- if (command.kind === 'mouseMove') {
1037
- resolve();
1315
+ if (isCoalescableInputCommand(command.kind)) {
1316
+ this.enqueueCoalescableCommand(command);
1317
+ } else {
1318
+ const queuedDiscreteCommands = this.inputCommands.filter((queuedCommand) => (
1319
+ !isCoalescableInputCommand(queuedCommand.kind)
1320
+ )).length;
1321
+ if (queuedDiscreteCommands >= maxInputQueueLength) {
1322
+ const error = new Error('Live browser input queue reached its capacity');
1323
+ this.reportError({
1324
+ code: 'input_queue_capacity_exceeded',
1325
+ message: error.message,
1326
+ cause: error,
1327
+ }, command.runEpoch);
1328
+ reject(error);
1038
1329
  return;
1039
1330
  }
1040
- const error = new Error('Live browser input queue reached its capacity');
1041
- this.reportError({
1042
- code: 'input_queue_capacity_exceeded',
1043
- message: error.message,
1044
- cause: error,
1045
- }, command.runEpoch);
1046
- reject(error);
1047
- this.recoverFromInputOverflow(command.runEpoch);
1048
- return;
1049
- } else {
1331
+ // Accumulated wheel input precedes this command in event order.
1332
+ this.flushPendingWheel();
1333
+ this.statistics.inputCommandsEnqueued++;
1050
1334
  this.inputCommands.push(command);
1051
1335
  }
1052
1336
  this.processInputCommands();
1053
1337
  });
1054
1338
  }
1055
1339
 
1056
- private processInputCommands(): void {
1057
- if (this.inputProcessing) {
1340
+ /**
1341
+ * Coalescable commands never exhaust capacity: they merge into a same-kind
1342
+ * queue tail or evict the oldest queued coalescable command under pressure.
1343
+ */
1344
+ private enqueueCoalescableCommand(commandArg: IInputCommand): void {
1345
+ // Wheel submissions are counted per DOM event before accumulation.
1346
+ if (commandArg.kind === 'mouseMove') {
1347
+ this.statistics.inputCommandsEnqueued++;
1348
+ }
1349
+ const lastCommand = this.inputCommands.at(-1);
1350
+ if (lastCommand && lastCommand.kind === commandArg.kind) {
1351
+ this.statistics.inputCommandsCoalesced++;
1352
+ if (commandArg.kind === 'wheel' && lastCommand.wheelInput && commandArg.wheelInput) {
1353
+ mergeWheelInput(lastCommand.wheelInput, commandArg.wheelInput);
1354
+ commandArg.resolve();
1355
+ } else {
1356
+ lastCommand.resolve();
1357
+ this.inputCommands[this.inputCommands.length - 1] = commandArg;
1358
+ }
1058
1359
  return;
1059
1360
  }
1060
- this.inputProcessing = true;
1061
- void (async () => {
1062
- try {
1063
- while (this.inputCommands.length > 0) {
1064
- const command = this.inputCommands.shift()!;
1065
- if (!this.isRunActive(command.runEpoch)) {
1066
- command.reject(new LiveBrowserRunAbortedError(
1067
- `Live browser input belongs to inactive run ${command.runEpoch}`,
1068
- ));
1069
- continue;
1070
- }
1071
- try {
1072
- command.onAttempt?.();
1073
- await this.runClientOperation(command.execute, 'input dispatch', command.runEpoch);
1074
- if (!this.isRunActive(command.runEpoch)) {
1075
- throw new LiveBrowserRunAbortedError(
1076
- `Live browser input belongs to inactive run ${command.runEpoch}`,
1077
- );
1078
- }
1079
- command.onSuccess?.();
1080
- command.resolve();
1081
- } catch (error) {
1082
- command.reject(error);
1083
- if (error instanceof LiveBrowserRunAbortedError) {
1084
- continue;
1085
- }
1086
- if (error instanceof LiveBrowserOperationTimeoutError) {
1087
- this.requestSuspend(command.runEpoch);
1088
- }
1089
- this.reportError({
1090
- code: 'input_dispatch_failed',
1091
- message: `Could not dispatch live browser input: ${getErrorMessage(error)}`,
1092
- cause: error,
1093
- }, command.runEpoch);
1094
- const abandonedCommands = this.inputCommands.splice(0);
1095
- for (const abandonedCommand of abandonedCommands) {
1096
- abandonedCommand.reject(
1097
- new Error('Live browser input was abandoned after a dispatch failure'),
1098
- );
1099
- }
1100
- if (!(error instanceof LiveBrowserOperationTimeoutError) && !this.stopping) {
1101
- void this.scheduleInputReset(true, command.runEpoch);
1102
- }
1103
- }
1104
- }
1105
- } finally {
1106
- this.inputProcessing = false;
1107
- if (this.inputCommands.length > 0) {
1108
- this.processInputCommands();
1109
- } else {
1110
- const resolvers = this.inputIdleResolvers;
1111
- this.inputIdleResolvers = [];
1112
- for (const resolver of resolvers) {
1113
- resolver();
1114
- }
1361
+ const queuedCoalescableCommands = this.inputCommands.filter((queuedCommand) => (
1362
+ isCoalescableInputCommand(queuedCommand.kind)
1363
+ ));
1364
+ if (queuedCoalescableCommands.length >= maxQueuedCoalescableCommands) {
1365
+ this.dropOldestCoalescableCommand(queuedCoalescableCommands[0]);
1366
+ }
1367
+ this.inputCommands.push(commandArg);
1368
+ }
1369
+
1370
+ private dropOldestCoalescableCommand(oldestCommandArg: IInputCommand): void {
1371
+ const index = this.inputCommands.indexOf(oldestCommandArg);
1372
+ if (index < 0) {
1373
+ return;
1374
+ }
1375
+ this.inputCommands.splice(index, 1);
1376
+ this.statistics.inputCommandsCoalesced++;
1377
+ if (oldestCommandArg.kind === 'wheel' && oldestCommandArg.wheelInput) {
1378
+ const nextWheelCommand = this.inputCommands.find((queuedCommand) => (
1379
+ queuedCommand.kind === 'wheel' && queuedCommand.wheelInput
1380
+ ));
1381
+ if (nextWheelCommand?.wheelInput) {
1382
+ const nextWheelInput = nextWheelCommand.wheelInput;
1383
+ nextWheelInput.deltaX = clampWheelDelta(
1384
+ nextWheelInput.deltaX + oldestCommandArg.wheelInput.deltaX,
1385
+ );
1386
+ nextWheelInput.deltaY = clampWheelDelta(
1387
+ nextWheelInput.deltaY + oldestCommandArg.wheelInput.deltaY,
1388
+ );
1389
+ }
1390
+ }
1391
+ oldestCommandArg.resolve();
1392
+ }
1393
+
1394
+ /**
1395
+ * Dispatches queued commands in order with a bounded number in flight. The
1396
+ * remote session processes operations sequentially, so order is preserved.
1397
+ */
1398
+ private processInputCommands(): void {
1399
+ while (
1400
+ this.inputCommands.length > 0
1401
+ && this.inFlightInputCommands < maxInFlightInputCommands
1402
+ ) {
1403
+ const command = this.inputCommands.shift()!;
1404
+ if (!this.isRunActive(command.runEpoch)) {
1405
+ command.reject(new LiveBrowserRunAbortedError(
1406
+ `Live browser input belongs to inactive run ${command.runEpoch}`,
1407
+ ));
1408
+ continue;
1409
+ }
1410
+ this.dispatchInputCommand(command);
1411
+ }
1412
+ if (this.inputCommands.length === 0 && this.inFlightInputCommands === 0) {
1413
+ const resolvers = this.inputIdleResolvers;
1414
+ this.inputIdleResolvers = [];
1415
+ for (const resolver of resolvers) {
1416
+ resolver();
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ private dispatchInputCommand(commandArg: IInputCommand): void {
1422
+ this.inFlightInputCommands++;
1423
+ if (commandArg.kind === 'wheel') {
1424
+ this.inFlightWheelCommands++;
1425
+ }
1426
+ const startedAt = performance.now();
1427
+ let dispatch: Promise<void>;
1428
+ try {
1429
+ commandArg.onAttempt?.();
1430
+ dispatch = this.runClientOperation(commandArg.execute, 'input dispatch', commandArg.runEpoch);
1431
+ } catch (error) {
1432
+ dispatch = Promise.reject(error);
1433
+ }
1434
+ void dispatch.then(() => {
1435
+ if (!this.isRunActive(commandArg.runEpoch)) {
1436
+ throw new LiveBrowserRunAbortedError(
1437
+ `Live browser input belongs to inactive run ${commandArg.runEpoch}`,
1438
+ );
1439
+ }
1440
+ this.statistics.lastInputRoundTripMs = performance.now() - startedAt;
1441
+ commandArg.onSuccess?.();
1442
+ commandArg.resolve();
1443
+ }).catch((error) => {
1444
+ commandArg.reject(error);
1445
+ this.handleInputDispatchFailure(error, commandArg.runEpoch);
1446
+ }).finally(() => {
1447
+ this.inFlightInputCommands--;
1448
+ if (commandArg.kind === 'wheel') {
1449
+ this.inFlightWheelCommands--;
1450
+ if (this.inFlightWheelCommands === 0) {
1451
+ this.tryFlushPendingWheel();
1115
1452
  }
1116
1453
  }
1117
- })();
1454
+ this.processInputCommands();
1455
+ });
1456
+ }
1457
+
1458
+ private handleInputDispatchFailure(errorArg: unknown, runEpochArg: number): void {
1459
+ if (errorArg instanceof LiveBrowserRunAbortedError) {
1460
+ return;
1461
+ }
1462
+ if (errorArg instanceof LiveBrowserOperationTimeoutError) {
1463
+ this.requestSuspend(runEpochArg);
1464
+ }
1465
+ this.reportError({
1466
+ code: 'input_dispatch_failed',
1467
+ message: `Could not dispatch live browser input: ${getErrorMessage(errorArg)}`,
1468
+ cause: errorArg,
1469
+ }, runEpochArg);
1470
+ this.discardPendingWheel();
1471
+ const abandonedCommands = this.inputCommands.splice(0);
1472
+ for (const abandonedCommand of abandonedCommands) {
1473
+ abandonedCommand.reject(
1474
+ new Error('Live browser input was abandoned after a dispatch failure'),
1475
+ );
1476
+ }
1477
+ if (!(errorArg instanceof LiveBrowserOperationTimeoutError) && !this.stopping) {
1478
+ void this.scheduleInputReset(true, runEpochArg);
1479
+ }
1118
1480
  }
1119
1481
 
1120
1482
  private waitForInputIdle(): Promise<void> {
1121
- if (!this.inputProcessing && this.inputCommands.length === 0) {
1483
+ if (this.inFlightInputCommands === 0 && this.inputCommands.length === 0) {
1122
1484
  return Promise.resolve();
1123
1485
  }
1124
1486
  return new Promise<void>((resolve) => {
@@ -1159,17 +1521,6 @@ export class LiveBrowserCanvasRenderer {
1159
1521
  return resetOperation;
1160
1522
  }
1161
1523
 
1162
- private recoverFromInputOverflow(runEpochArg: number): void {
1163
- if (this.inputOverflowRecoveryPending) {
1164
- return;
1165
- }
1166
- this.inputOverflowRecoveryPending = true;
1167
- this.inputBlocked = true;
1168
- void this.scheduleInputReset(false, runEpochArg).finally(() => {
1169
- this.inputOverflowRecoveryPending = false;
1170
- });
1171
- }
1172
-
1173
1524
  private async releasePressedInput(runEpochArg: number): Promise<void> {
1174
1525
  for (const [keyIdentity, pressedKey] of [...this.pressedKeys.entries()]) {
1175
1526
  const released = await this.tryInputRelease(
@@ -1254,9 +1605,14 @@ export class LiveBrowserCanvasRenderer {
1254
1605
  this.pendingViewport = undefined;
1255
1606
  return;
1256
1607
  }
1608
+ if (!this.inFlightViewport && this.lastRequestedViewport && viewportEquals(viewport, this.lastRequestedViewport)) {
1609
+ this.pendingViewport = undefined;
1610
+ return;
1611
+ }
1257
1612
  if (
1258
1613
  !this.inFlightViewport
1259
1614
  && this.resizeFence
1615
+ && !this.resizeFence.negotiated
1260
1616
  && viewportEquals(viewport, this.resizeFence.target)
1261
1617
  ) {
1262
1618
  this.pendingViewport = undefined;
@@ -1265,10 +1621,12 @@ export class LiveBrowserCanvasRenderer {
1265
1621
  if (
1266
1622
  !this.inFlightViewport
1267
1623
  && !this.resizeFence
1624
+ && !this.lastRequestedViewport
1268
1625
  && this.state
1269
1626
  && viewportEquals(viewport, this.state.viewport)
1270
1627
  ) {
1271
1628
  this.pendingViewport = undefined;
1629
+ this.lastRequestedViewport = { ...viewport };
1272
1630
  this.updateInputAvailability();
1273
1631
  return;
1274
1632
  }
@@ -1364,19 +1722,20 @@ export class LiveBrowserCanvasRenderer {
1364
1722
  const viewport = this.pendingViewport;
1365
1723
  this.pendingViewport = undefined;
1366
1724
  this.inFlightViewport = viewport;
1367
- if (!this.resizeFence && this.state && viewportEquals(viewport, this.state.viewport)) {
1725
+ if (!this.resizeFence && !this.lastRequestedViewport && this.state && viewportEquals(viewport, this.state.viewport)) {
1368
1726
  this.inFlightViewport = undefined;
1727
+ this.lastRequestedViewport = { ...viewport };
1369
1728
  continue;
1370
1729
  }
1371
1730
 
1372
- await this.scheduleInputReset(true, runEpochArg);
1731
+ await this.scheduleInputReset(false, runEpochArg);
1373
1732
  if (!this.running || this.stopping || runEpochArg !== this.runEpoch) {
1374
1733
  return;
1375
1734
  }
1376
1735
  const currentRevision = this.state?.viewportRevision ?? 0;
1377
1736
  const previousMinimumRevision = this.resizeFence?.minimumViewportRevision ?? currentRevision;
1378
1737
  try {
1379
- await this.runClientOperation(
1738
+ const result = await this.runClientOperation(
1380
1739
  (optionsArg) => this.client.setViewport(viewport, optionsArg),
1381
1740
  'viewport update',
1382
1741
  runEpochArg,
@@ -1384,10 +1743,15 @@ export class LiveBrowserCanvasRenderer {
1384
1743
  if (!this.running || this.stopping || runEpochArg !== this.runEpoch) {
1385
1744
  return;
1386
1745
  }
1387
- this.resizeFence = {
1388
- target: viewport,
1389
- minimumViewportRevision: Math.max(currentRevision, previousMinimumRevision) + 1,
1390
- };
1746
+ if (result !== undefined) {
1747
+ this.validateViewport(result.viewport, 'viewport result');
1748
+ this.validateProtocolInteger(result.viewportRevision, 'viewport result revision', Math.max(1, currentRevision));
1749
+ this.resizeFence = { target: { ...result.viewport }, minimumViewportRevision: result.viewportRevision, negotiated: true };
1750
+ this.followNegotiatedViewport();
1751
+ } else {
1752
+ this.resizeFence = { target: viewport, minimumViewportRevision: Math.max(currentRevision, previousMinimumRevision) + 1 };
1753
+ }
1754
+ this.lastRequestedViewport = { ...viewport };
1391
1755
  } catch (error) {
1392
1756
  if (runEpochArg === this.runEpoch && !(error instanceof LiveBrowserRunAbortedError)) {
1393
1757
  this.resizeFence = undefined;
@@ -1413,6 +1777,11 @@ export class LiveBrowserCanvasRenderer {
1413
1777
  }
1414
1778
 
1415
1779
  private updateInputAvailability(): void {
1780
+ if (this.resizeFence && !this.viewportProcessing && this.displayedFrame && this.isDisplayedFrameCurrent()
1781
+ && this.displayedFrame.viewportRevision >= this.resizeFence.minimumViewportRevision
1782
+ && viewportEquals(this.displayedFrame.viewport, this.resizeFence.target)) {
1783
+ this.resizeFence = undefined;
1784
+ }
1416
1785
  this.inputBlocked = !(
1417
1786
  this.running
1418
1787
  && this.acceptingFrames
@@ -1423,6 +1792,12 @@ export class LiveBrowserCanvasRenderer {
1423
1792
  );
1424
1793
  }
1425
1794
 
1795
+ private followNegotiatedViewport(): void {
1796
+ if (this.resizeFence?.negotiated && this.state && this.state.viewportRevision > this.resizeFence.minimumViewportRevision) {
1797
+ this.resizeFence = { target: { ...this.state.viewport }, minimumViewportRevision: this.state.viewportRevision, negotiated: true };
1798
+ }
1799
+ }
1800
+
1426
1801
  private makeFocusTargetFocusable(): void {
1427
1802
  this.focusWasAdjusted = false;
1428
1803
  if (this.focusTarget.tabIndex >= 0) {
@@ -1447,7 +1822,46 @@ export class LiveBrowserCanvasRenderer {
1447
1822
  }
1448
1823
 
1449
1824
  private clearCanvas(): void {
1450
- this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
1825
+ if (this.bitmapContext) {
1826
+ this.bitmapContext.transferFromImageBitmap(null);
1827
+ return;
1828
+ }
1829
+ this.canvasContext?.clearRect(0, 0, this.canvas.width, this.canvas.height);
1830
+ }
1831
+
1832
+ /**
1833
+ * Runs work on the next animation frame, or after one nominal frame period
1834
+ * when the document is not visible and animation frames would not fire.
1835
+ */
1836
+ private scheduleAnimationFrame(callbackArg: () => void): IScheduledFrame {
1837
+ const schedule: IScheduledFrame = {};
1838
+ if (
1839
+ typeof document !== 'undefined'
1840
+ && document.visibilityState === 'visible'
1841
+ && typeof globalThis.requestAnimationFrame === 'function'
1842
+ ) {
1843
+ schedule.animationFrameId = globalThis.requestAnimationFrame(() => {
1844
+ schedule.animationFrameId = undefined;
1845
+ callbackArg();
1846
+ });
1847
+ } else {
1848
+ schedule.timeoutId = globalThis.setTimeout(() => {
1849
+ schedule.timeoutId = undefined;
1850
+ callbackArg();
1851
+ }, animationFrameFallbackMs);
1852
+ }
1853
+ return schedule;
1854
+ }
1855
+
1856
+ private cancelScheduledFrame(scheduleArg: IScheduledFrame): void {
1857
+ if (scheduleArg.animationFrameId !== undefined) {
1858
+ globalThis.cancelAnimationFrame(scheduleArg.animationFrameId);
1859
+ scheduleArg.animationFrameId = undefined;
1860
+ }
1861
+ if (scheduleArg.timeoutId !== undefined) {
1862
+ globalThis.clearTimeout(scheduleArg.timeoutId);
1863
+ scheduleArg.timeoutId = undefined;
1864
+ }
1451
1865
  }
1452
1866
 
1453
1867
  private callFrameRendered(frameArg: ILiveBrowserFrame, runEpochArg: number): void {