pokertools-arena 0.7.0 → 0.8.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.
@@ -3166,7 +3166,7 @@ input:focus,select:focus{border-color:rgba(157,220,177,.44)!important;box-shadow
3166
3166
  <div id="tournamentMeta" class="mono muted">Seat 2–10 models, then press ▶</div>
3167
3167
  <div class="arena-head-actions">
3168
3168
  <button id="soundBtn" class="arena-icon-button sound-button" type="button" aria-pressed="false" title="Enable table sounds" aria-label="Enable table sounds"><span class="action-icon">♪</span></button>
3169
- <button id="recordBtn" class="arena-icon-button record-button" type="button" aria-pressed="false" title="Record only the poker table" aria-label="Record only the poker table"><span class="action-icon">●</span></button>
3169
+ <button id="recordBtn" class="arena-icon-button record-button" type="button" aria-pressed="false" title="Record poker table with current-tab audio" aria-label="Record poker table with current-tab audio"><span class="action-icon">●</span></button>
3170
3170
  </div>
3171
3171
  </div>
3172
3172
 
@@ -119138,13 +119138,6 @@ function soundMaster() {
119138
119138
  }
119139
119139
  return soundMasterGain;
119140
119140
  }
119141
- function recordingAudioTrack() {
119142
- const ctx = getAudioContext();
119143
- if (!ctx || typeof ctx.createMediaStreamDestination !== "function") return null;
119144
- const destination = ctx.createMediaStreamDestination();
119145
- soundMaster()?.connect(destination);
119146
- return { destination, track: destination.stream.getAudioTracks()[0] || null };
119147
- }
119148
119141
  function tone(freq, duration = 0.05, gain = 0.035, offset = 0, type = "sine") {
119149
119142
  if (!soundEnabled) return;
119150
119143
  const ctx = getAudioContext();
@@ -121436,26 +121429,26 @@ function openTests() {
121436
121429
  }
121437
121430
  var TABLE_RECORDING_CONFIG = Object.freeze({
121438
121431
  frameRate: 30,
121439
- maxWidth: 1920,
121440
- maxHeight: 1080,
121441
- minBitrate: 6e6,
121442
- maxBitrate: 16e6,
121443
- bitsPerPixelPerFrame: 0.14,
121444
- audioBitsPerSecond: 128e3,
121432
+ maxWidth: 2560,
121433
+ maxHeight: 1440,
121434
+ minBitrate: 1e7,
121435
+ maxBitrate: 28e6,
121436
+ bitsPerPixelPerFrame: 0.2,
121437
+ audioBitsPerSecond: 192e3,
121445
121438
  background: "#07090b"
121446
121439
  });
121447
- function recorderMimeType() {
121440
+ function recorderMimeType(hasAudio = true) {
121448
121441
  if (!globalThis.MediaRecorder) return "";
121449
- for (const type of [
121442
+ const candidates = hasAudio ? [
121450
121443
  "video/webm;codecs=vp8,opus",
121451
121444
  "video/webm;codecs=vp9,opus",
121445
+ "video/webm"
121446
+ ] : [
121452
121447
  "video/webm;codecs=vp8",
121453
121448
  "video/webm;codecs=vp9",
121454
121449
  "video/webm"
121455
- ]) {
121456
- if (MediaRecorder.isTypeSupported?.(type)) return type;
121457
- }
121458
- return "";
121450
+ ];
121451
+ return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || "";
121459
121452
  }
121460
121453
  function recordingVideoBitrate({ width, height, frameRate = TABLE_RECORDING_CONFIG.frameRate }) {
121461
121454
  const pixelsPerSecond = Math.max(1, width) * Math.max(1, height) * Math.max(1, frameRate);
@@ -121473,7 +121466,7 @@ function setRecordButton(active, label = null) {
121473
121466
  const text = $(".action-label", els.recordBtn);
121474
121467
  if (icon) icon.textContent = active ? "\u25A0" : "\u25CF";
121475
121468
  if (text) text.textContent = label || (active ? "Stop rec" : "Record");
121476
- els.recordBtn.title = active ? "Stop table recording and save video" : "Record only the poker table";
121469
+ els.recordBtn.title = active ? "Stop table recording and save video" : "Record poker table with current-tab audio";
121477
121470
  els.recordBtn.setAttribute("aria-label", els.recordBtn.title);
121478
121471
  }
121479
121472
  function saveRecordingBlob(blob) {
@@ -121504,72 +121497,26 @@ function fitRecordingSize(sourceWidth, sourceHeight) {
121504
121497
  height: evenRecordingDimension(height * scale)
121505
121498
  };
121506
121499
  }
121507
- function waitForCaptureVideo(video, track) {
121508
- return new Promise((resolve, reject) => {
121509
- let settled = false;
121510
- const finish = (callback, value) => {
121511
- if (settled) return;
121512
- settled = true;
121513
- clearTimeout(timeoutId);
121514
- track?.removeEventListener?.("ended", onEnded);
121515
- video.removeEventListener("loadedmetadata", onReady);
121516
- video.removeEventListener("resize", onReady);
121517
- callback(value);
121518
- };
121519
- const onReady = () => {
121520
- if (video.videoWidth > 0 && video.videoHeight > 0) finish(resolve);
121521
- };
121522
- const onEnded = () => finish(reject, new Error("Screen sharing ended before recording started"));
121523
- const timeoutId = setTimeout(
121524
- () => finish(reject, new Error("Timed out waiting for the shared tab video")),
121525
- 8e3
121526
- );
121527
- track?.addEventListener?.("ended", onEnded, { once: true });
121528
- video.addEventListener("loadedmetadata", onReady);
121529
- video.addEventListener("resize", onReady);
121530
- onReady();
121531
- });
121532
- }
121533
- function createCaptureVideo(stream) {
121534
- const video = document.createElement("video");
121535
- video.muted = true;
121536
- video.playsInline = true;
121537
- video.autoplay = true;
121538
- video.setAttribute("aria-hidden", "true");
121539
- Object.assign(video.style, {
121540
- position: "fixed",
121541
- left: "0",
121542
- top: "0",
121543
- width: "2px",
121544
- height: "2px",
121545
- opacity: "0.001",
121546
- pointerEvents: "none",
121547
- zIndex: "2147483647"
121548
- });
121549
- video.srcObject = stream;
121550
- document.body.append(video);
121551
- return video;
121552
- }
121553
- function captureViewportMetrics(video) {
121500
+ function captureViewportMetrics(sourceWidth, sourceHeight) {
121554
121501
  const viewport = globalThis.visualViewport;
121555
121502
  const cssWidth = Math.max(1, viewport?.width || document.documentElement.clientWidth || innerWidth || 1);
121556
121503
  const cssHeight = Math.max(1, viewport?.height || document.documentElement.clientHeight || innerHeight || 1);
121557
- const sourceWidth = Math.max(2, video.videoWidth || cssWidth);
121558
- const sourceHeight = Math.max(2, video.videoHeight || cssHeight);
121504
+ const width = Math.max(2, Number(sourceWidth) || cssWidth);
121505
+ const height = Math.max(2, Number(sourceHeight) || cssHeight);
121559
121506
  return {
121560
121507
  cssWidth,
121561
121508
  cssHeight,
121562
- sourceWidth,
121563
- sourceHeight,
121564
- scaleX: sourceWidth / cssWidth,
121565
- scaleY: sourceHeight / cssHeight,
121509
+ sourceWidth: width,
121510
+ sourceHeight: height,
121511
+ scaleX: width / cssWidth,
121512
+ scaleY: height / cssHeight,
121566
121513
  offsetX: viewport?.offsetLeft || 0,
121567
121514
  offsetY: viewport?.offsetTop || 0
121568
121515
  };
121569
121516
  }
121570
- function tableCropSourceRect(video) {
121517
+ function tableCropSourceRect(sourceWidth, sourceHeight) {
121571
121518
  const rect = els.pokerTable.getBoundingClientRect();
121572
- const metrics = captureViewportMetrics(video);
121519
+ const metrics = captureViewportMetrics(sourceWidth, sourceHeight);
121573
121520
  const leftCss = rect.left - metrics.offsetX;
121574
121521
  const topCss = rect.top - metrics.offsetY;
121575
121522
  const sx = clamp(Math.round(leftCss * metrics.scaleX), 0, Math.max(0, metrics.sourceWidth - 2));
@@ -121578,73 +121525,197 @@ function tableCropSourceRect(video) {
121578
121525
  const sh = clamp(Math.round(rect.height * metrics.scaleY), 2, metrics.sourceHeight - sy);
121579
121526
  return { sx, sy, sw, sh };
121580
121527
  }
121581
- function createTableRecordingCanvas(video) {
121582
- const source = tableCropSourceRect(video);
121528
+ function createTableRecordingCanvas(sourceWidth, sourceHeight) {
121529
+ const source = tableCropSourceRect(sourceWidth, sourceHeight);
121583
121530
  const output = fitRecordingSize(source.sw, source.sh);
121584
121531
  const canvas = document.createElement("canvas");
121585
121532
  canvas.width = output.width;
121586
121533
  canvas.height = output.height;
121587
- const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true });
121534
+ const ctx = canvas.getContext("2d", { alpha: false });
121588
121535
  if (!ctx) throw new Error("Canvas video recording is not available in this browser");
121589
121536
  ctx.imageSmoothingEnabled = true;
121590
121537
  ctx.imageSmoothingQuality = "high";
121591
121538
  return { canvas, ctx };
121592
121539
  }
121593
- function createCanvasRecordingStream(canvas) {
121594
- const stream = canvas.captureStream(TABLE_RECORDING_CONFIG.frameRate);
121595
- const track = stream.getVideoTracks()[0];
121596
- if (!track) throw new Error("Could not create the table recording video track");
121597
- return { stream, track };
121598
- }
121599
- function paintTableRecordingFrame(recording) {
121600
- const { ctx, canvas, captureVideo } = recording;
121601
- if (!ctx || !canvas || !captureVideo || captureVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return false;
121602
- const source = tableCropSourceRect(captureVideo);
121603
- ctx.save();
121540
+ function paintTableRecordingFrame(recording, source, sourceWidth, sourceHeight) {
121541
+ const { ctx, canvas } = recording;
121542
+ if (!ctx || !canvas || !source) return false;
121543
+ const crop = tableCropSourceRect(sourceWidth, sourceHeight);
121604
121544
  ctx.globalCompositeOperation = "copy";
121605
121545
  ctx.fillStyle = TABLE_RECORDING_CONFIG.background;
121606
121546
  ctx.fillRect(0, 0, canvas.width, canvas.height);
121607
121547
  try {
121608
121548
  ctx.drawImage(
121609
- captureVideo,
121610
- source.sx,
121611
- source.sy,
121612
- source.sw,
121613
- source.sh,
121549
+ source,
121550
+ crop.sx,
121551
+ crop.sy,
121552
+ crop.sw,
121553
+ crop.sh,
121614
121554
  0,
121615
121555
  0,
121616
121556
  canvas.width,
121617
121557
  canvas.height
121618
121558
  );
121559
+ return true;
121619
121560
  } catch {
121620
- ctx.restore();
121621
121561
  return false;
121562
+ } finally {
121563
+ ctx.globalCompositeOperation = "source-over";
121622
121564
  }
121623
- ctx.restore();
121624
- return true;
121625
121565
  }
121626
- function startTableRecordingPainter(recording) {
121566
+ function createCaptureVideo(stream) {
121567
+ const video = document.createElement("video");
121568
+ video.muted = true;
121569
+ video.playsInline = true;
121570
+ video.autoplay = true;
121571
+ video.setAttribute("aria-hidden", "true");
121572
+ Object.assign(video.style, {
121573
+ position: "fixed",
121574
+ inset: "0 auto auto 0",
121575
+ width: "2px",
121576
+ height: "2px",
121577
+ opacity: "0.001",
121578
+ pointerEvents: "none",
121579
+ zIndex: "-1"
121580
+ });
121581
+ video.srcObject = stream;
121582
+ document.body.append(video);
121583
+ return video;
121584
+ }
121585
+ function waitForCaptureVideo(video, track) {
121586
+ return new Promise((resolve, reject) => {
121587
+ let settled = false;
121588
+ const finish = (callback, value) => {
121589
+ if (settled) return;
121590
+ settled = true;
121591
+ clearTimeout(timeoutId);
121592
+ track?.removeEventListener?.("ended", onEnded);
121593
+ video.removeEventListener("loadedmetadata", onReady);
121594
+ video.removeEventListener("resize", onReady);
121595
+ callback(value);
121596
+ };
121597
+ const onReady = () => {
121598
+ if (video.videoWidth > 0 && video.videoHeight > 0) finish(resolve);
121599
+ };
121600
+ const onEnded = () => finish(reject, new Error("Screen sharing ended before recording started"));
121601
+ const timeoutId = setTimeout(
121602
+ () => finish(reject, new Error("Timed out waiting for the shared tab video")),
121603
+ 8e3
121604
+ );
121605
+ track?.addEventListener?.("ended", onEnded, { once: true });
121606
+ video.addEventListener("loadedmetadata", onReady);
121607
+ video.addEventListener("resize", onReady);
121608
+ onReady();
121609
+ });
121610
+ }
121611
+ function supportsTimestampedVideoPipeline() {
121612
+ return typeof globalThis.MediaStreamTrackProcessor === "function" && typeof globalThis.MediaStreamTrackGenerator === "function" && typeof globalThis.VideoFrame === "function";
121613
+ }
121614
+ function createTimestampedVideoPipeline(recording) {
121615
+ if (!supportsTimestampedVideoPipeline()) return null;
121616
+ try {
121617
+ const processor = new MediaStreamTrackProcessor({ track: recording.captureTrack });
121618
+ const generator = new MediaStreamTrackGenerator({ kind: "video" });
121619
+ const reader = processor.readable.getReader();
121620
+ const writer = generator.writable.getWriter();
121621
+ generator.contentHint = "detail";
121622
+ recording.processorReader = reader;
121623
+ recording.generatorWriter = writer;
121624
+ recording.outputVideoTrack = generator;
121625
+ return generator;
121626
+ } catch {
121627
+ recording.processorReader = null;
121628
+ recording.generatorWriter = null;
121629
+ recording.outputVideoTrack = null;
121630
+ return null;
121631
+ }
121632
+ }
121633
+ function startTimestampedVideoPump(recording) {
121634
+ const reader = recording.processorReader;
121635
+ const writer = recording.generatorWriter;
121636
+ if (!reader || !writer) return;
121637
+ const fallbackDuration = Math.round(1e6 / TABLE_RECORDING_CONFIG.frameRate);
121638
+ recording.videoPumpPromise = (async () => {
121639
+ try {
121640
+ while (!recording.stopping) {
121641
+ const { value: frame, done } = await reader.read();
121642
+ if (done || !frame) break;
121643
+ try {
121644
+ const width = frame.displayWidth || frame.codedWidth || recording.sourceWidth;
121645
+ const height = frame.displayHeight || frame.codedHeight || recording.sourceHeight;
121646
+ if (!paintTableRecordingFrame(recording, frame, width, height)) continue;
121647
+ if (writer.desiredSize != null && writer.desiredSize <= 0) continue;
121648
+ const timestamp = Number.isFinite(frame.timestamp) ? frame.timestamp : Math.round(performance.now() * 1e3);
121649
+ const duration = Number.isFinite(frame.duration) && frame.duration > 0 ? frame.duration : fallbackDuration;
121650
+ const outputFrame = new VideoFrame(recording.canvas, { timestamp, duration });
121651
+ try {
121652
+ await writer.write(outputFrame);
121653
+ } finally {
121654
+ outputFrame.close();
121655
+ }
121656
+ } finally {
121657
+ frame.close();
121658
+ }
121659
+ }
121660
+ } catch (error) {
121661
+ if (!recording.stopping && tableRecording === recording) {
121662
+ recording.pipelineError = error;
121663
+ if (recording.recorder?.state !== "inactive") recording.recorder.stop();
121664
+ }
121665
+ }
121666
+ })();
121667
+ }
121668
+ function createFallbackCanvasPipeline(recording) {
121669
+ const stream = recording.canvas.captureStream(TABLE_RECORDING_CONFIG.frameRate);
121670
+ const track = stream.getVideoTracks()[0];
121671
+ if (!track) throw new Error("Could not create the table recording video track");
121672
+ track.contentHint = "detail";
121673
+ recording.canvasStream = stream;
121674
+ recording.outputVideoTrack = track;
121675
+ return track;
121676
+ }
121677
+ function startFallbackVideoPump(recording) {
121627
121678
  const frameInterval = 1e3 / TABLE_RECORDING_CONFIG.frameRate;
121628
121679
  let previousPaint = -frameInterval;
121629
121680
  const loop = (timestamp) => {
121630
121681
  if (recording.stopping || tableRecording !== recording) return;
121631
121682
  if (timestamp - previousPaint >= frameInterval - 1) {
121632
- paintTableRecordingFrame(recording);
121683
+ const video2 = recording.captureVideo;
121684
+ if (video2?.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
121685
+ paintTableRecordingFrame(recording, video2, video2.videoWidth, video2.videoHeight);
121686
+ }
121633
121687
  previousPaint = timestamp;
121634
121688
  }
121635
121689
  recording.animationFrameId = requestAnimationFrame(loop);
121636
121690
  };
121637
- paintTableRecordingFrame(recording);
121691
+ const video = recording.captureVideo;
121692
+ if (video?.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
121693
+ paintTableRecordingFrame(recording, video, video.videoWidth, video.videoHeight);
121694
+ }
121638
121695
  recording.animationFrameId = requestAnimationFrame(loop);
121639
121696
  }
121640
- function stopTableRecordingPainter(recording) {
121697
+ function startTableRecordingVideo(recording) {
121698
+ if (recording.processorReader) startTimestampedVideoPump(recording);
121699
+ else startFallbackVideoPump(recording);
121700
+ }
121701
+ function stopTableRecordingVideo(recording) {
121641
121702
  if (!recording) return;
121642
121703
  if (recording.animationFrameId != null) cancelAnimationFrame(recording.animationFrameId);
121643
121704
  recording.animationFrameId = null;
121705
+ try {
121706
+ recording.processorReader?.cancel?.();
121707
+ } catch {
121708
+ }
121709
+ try {
121710
+ recording.generatorWriter?.close?.();
121711
+ } catch {
121712
+ }
121713
+ recording.processorReader = null;
121714
+ recording.generatorWriter = null;
121644
121715
  }
121645
- function disposeTableRecording(recording, { stopCapture = true, stopCanvas = true } = {}) {
121716
+ function disposeTableRecording(recording) {
121646
121717
  if (!recording) return;
121647
- stopTableRecordingPainter(recording);
121718
+ stopTableRecordingVideo(recording);
121648
121719
  try {
121649
121720
  recording.captureVideo?.pause?.();
121650
121721
  } catch {
@@ -121653,28 +121724,28 @@ function disposeTableRecording(recording, { stopCapture = true, stopCanvas = tru
121653
121724
  recording.captureVideo.srcObject = null;
121654
121725
  recording.captureVideo.remove();
121655
121726
  }
121656
- if (stopCanvas) recording.canvasStream?.getTracks?.().forEach((track) => track.stop());
121657
- if (stopCapture) recording.captureStream?.getTracks?.().forEach((track) => track.stop());
121727
+ recording.canvasStream?.getTracks?.().forEach((track) => track.stop());
121658
121728
  try {
121659
- if (soundMasterGain && recording.audioDestination) soundMasterGain.disconnect(recording.audioDestination);
121660
- } catch {
121661
- }
121662
- try {
121663
- recording.audioTrack?.stop?.();
121729
+ recording.outputVideoTrack?.stop?.();
121664
121730
  } catch {
121665
121731
  }
121732
+ recording.captureStream?.getTracks?.().forEach((track) => track.stop());
121666
121733
  recording.canvas?.remove?.();
121667
121734
  }
121668
121735
  function finalizeTableRecording(recording, mimeType) {
121669
121736
  const blob = new Blob(recording.chunks, { type: recording.recorder.mimeType || mimeType || "video/webm" });
121737
+ const error = recording.pipelineError;
121670
121738
  disposeTableRecording(recording);
121671
121739
  if (tableRecording === recording) tableRecording = null;
121672
121740
  setRecordButton(false);
121673
121741
  if (blob.size) saveRecordingBlob(blob);
121742
+ if (error) showActionToast(`Recording stopped: ${summarizeError(error)}`, "fold");
121674
121743
  }
121675
121744
  async function startTableRecording() {
121676
121745
  if (tableRecording || !els.pokerTable) return;
121677
- if (!navigator.mediaDevices?.getDisplayMedia || !globalThis.MediaRecorder || !HTMLCanvasElement.prototype.captureStream) {
121746
+ const canTimestampVideo = supportsTimestampedVideoPipeline();
121747
+ const canCanvasVideo = typeof HTMLCanvasElement.prototype.captureStream === "function";
121748
+ if (!navigator.mediaDevices?.getDisplayMedia || !globalThis.MediaRecorder || !canTimestampVideo && !canCanvasVideo) {
121678
121749
  showActionToast("Table recording is not supported in this browser", "fold");
121679
121750
  return;
121680
121751
  }
@@ -121685,12 +121756,15 @@ async function startTableRecording() {
121685
121756
  captureStream = await navigator.mediaDevices.getDisplayMedia({
121686
121757
  video: {
121687
121758
  displaySurface: "browser",
121688
- frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate }
121759
+ frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate },
121760
+ width: { ideal: TABLE_RECORDING_CONFIG.maxWidth },
121761
+ height: { ideal: TABLE_RECORDING_CONFIG.maxHeight }
121689
121762
  },
121690
- audio: false,
121763
+ audio: true,
121691
121764
  preferCurrentTab: true,
121692
121765
  selfBrowserSurface: "include",
121693
- surfaceSwitching: "exclude"
121766
+ surfaceSwitching: "exclude",
121767
+ systemAudio: "exclude"
121694
121768
  });
121695
121769
  const captureTrack = captureStream.getVideoTracks()[0];
121696
121770
  if (!captureTrack) throw new Error("No video track was shared");
@@ -121698,48 +121772,74 @@ async function startTableRecording() {
121698
121772
  if (displaySurface && displaySurface !== "browser") {
121699
121773
  throw new Error("Please share this browser tab, not a window or the entire screen.");
121700
121774
  }
121775
+ try {
121776
+ captureTrack.contentHint = "detail";
121777
+ } catch {
121778
+ }
121701
121779
  try {
121702
121780
  await captureTrack.applyConstraints?.({
121703
121781
  frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate }
121704
121782
  });
121705
121783
  } catch {
121706
121784
  }
121785
+ const audioTrack = captureStream.getAudioTracks()[0] || null;
121786
+ if (audioTrack?.applyConstraints) {
121787
+ try {
121788
+ await audioTrack.applyConstraints({ suppressLocalAudioPlayback: false });
121789
+ } catch {
121790
+ }
121791
+ }
121707
121792
  captureVideo = createCaptureVideo(captureStream);
121708
121793
  await captureVideo.play().catch(() => {
121709
121794
  });
121710
121795
  await waitForCaptureVideo(captureVideo, captureTrack);
121711
- const { canvas, ctx } = createTableRecordingCanvas(captureVideo);
121712
- const canvasCapture = createCanvasRecordingStream(canvas);
121713
- const { stream: canvasStream, track: canvasTrack } = canvasCapture;
121714
- try {
121715
- canvasTrack.contentHint = "detail";
121716
- } catch {
121717
- }
121718
- const recordingAudio = recordingAudioTrack();
121719
- const audioTrack = recordingAudio?.track || null;
121720
- const recorderStream = audioTrack ? new MediaStream([canvasTrack, audioTrack]) : canvasStream;
121796
+ const sourceWidth = Math.max(2, captureVideo.videoWidth || innerWidth || 2);
121797
+ const sourceHeight = Math.max(2, captureVideo.videoHeight || innerHeight || 2);
121798
+ const { canvas, ctx } = createTableRecordingCanvas(sourceWidth, sourceHeight);
121721
121799
  recording = {
121722
121800
  recorder: null,
121801
+ recorderStream: null,
121723
121802
  captureStream,
121724
121803
  captureTrack,
121725
121804
  captureVideo,
121726
121805
  canvas,
121727
121806
  ctx,
121728
- canvasStream,
121729
- canvasTrack,
121807
+ canvasStream: null,
121808
+ outputVideoTrack: null,
121809
+ processorReader: null,
121810
+ generatorWriter: null,
121811
+ videoPumpPromise: null,
121812
+ animationFrameId: null,
121813
+ sourceWidth,
121814
+ sourceHeight,
121730
121815
  audioTrack,
121731
- audioDestination: recordingAudio?.destination || null,
121732
121816
  chunks: [],
121733
121817
  stopping: false,
121734
- animationFrameId: null
121818
+ pipelineError: null
121735
121819
  };
121736
- const mimeType = recorderMimeType();
121820
+ let outputVideoTrack = canTimestampVideo ? createTimestampedVideoPipeline(recording) : null;
121821
+ if (outputVideoTrack) {
121822
+ captureVideo.pause();
121823
+ captureVideo.srcObject = null;
121824
+ captureVideo.remove();
121825
+ captureVideo = null;
121826
+ recording.captureVideo = null;
121827
+ } else {
121828
+ if (!canCanvasVideo) throw new Error("This browser cannot create a recording video track");
121829
+ outputVideoTrack = createFallbackCanvasPipeline(recording);
121830
+ }
121831
+ const tracks = [outputVideoTrack];
121832
+ if (audioTrack) tracks.push(audioTrack);
121833
+ const recorderStream = new MediaStream(tracks);
121834
+ recording.recorderStream = recorderStream;
121835
+ const mimeType = recorderMimeType(Boolean(audioTrack));
121737
121836
  const videoBitsPerSecond = recordingVideoBitrate({
121738
121837
  width: canvas.width,
121739
121838
  height: canvas.height,
121740
121839
  frameRate: TABLE_RECORDING_CONFIG.frameRate
121741
121840
  });
121742
- const options = { videoBitsPerSecond, audioBitsPerSecond: TABLE_RECORDING_CONFIG.audioBitsPerSecond };
121841
+ const options = { videoBitsPerSecond };
121842
+ if (audioTrack) options.audioBitsPerSecond = TABLE_RECORDING_CONFIG.audioBitsPerSecond;
121743
121843
  if (mimeType) options.mimeType = mimeType;
121744
121844
  const recorder = new MediaRecorder(recorderStream, options);
121745
121845
  recording.recorder = recorder;
@@ -121750,29 +121850,21 @@ async function startTableRecording() {
121750
121850
  recorder.addEventListener("stop", () => finalizeTableRecording(recording, mimeType), { once: true });
121751
121851
  recorder.addEventListener("error", (event) => {
121752
121852
  if (tableRecording !== recording) return;
121853
+ recording.pipelineError = event.error || new Error("Recording failed");
121753
121854
  recording.stopping = true;
121754
- stopTableRecordingPainter(recording);
121755
- showActionToast(summarizeError(event.error || new Error("Recording failed")), "fold");
121855
+ stopTableRecordingVideo(recording);
121756
121856
  if (recorder.state !== "inactive") recorder.stop();
121757
- else {
121758
- disposeTableRecording(recording);
121759
- tableRecording = null;
121760
- setRecordButton(false);
121761
- }
121857
+ else finalizeTableRecording(recording, mimeType);
121762
121858
  });
121763
121859
  captureTrack.addEventListener("ended", () => {
121764
- if (tableRecording === recording && recorder.state !== "inactive") {
121765
- recording.stopping = true;
121766
- stopTableRecordingPainter(recording);
121767
- recorder.stop();
121768
- }
121860
+ if (tableRecording === recording && recorder.state !== "inactive") stopTableRecording();
121769
121861
  }, { once: true });
121770
- startTableRecordingPainter(recording);
121771
- recorder.start(1e3);
121862
+ recorder.start(500);
121863
+ startTableRecordingVideo(recording);
121772
121864
  setRecordButton(true);
121773
121865
  showActionToast(
121774
- `Recording \xB7 ${canvas.width}\xD7${canvas.height} \xB7 ${TABLE_RECORDING_CONFIG.frameRate} fps${audioTrack ? " \xB7 audio" : ""}`,
121775
- "check"
121866
+ `Recording \xB7 ${canvas.width}\xD7${canvas.height} \xB7 ${TABLE_RECORDING_CONFIG.frameRate} fps \xB7 ${audioTrack ? "tab audio" : "no tab audio"}`,
121867
+ audioTrack ? "check" : "fold"
121776
121868
  );
121777
121869
  } catch (err) {
121778
121870
  if (recording) disposeTableRecording(recording);
@@ -121792,14 +121884,16 @@ function stopTableRecording() {
121792
121884
  const recording = tableRecording;
121793
121885
  if (!recording || recording.stopping) return;
121794
121886
  recording.stopping = true;
121795
- stopTableRecordingPainter(recording);
121887
+ stopTableRecordingVideo(recording);
121796
121888
  if (recording.recorder.state !== "inactive") {
121889
+ try {
121890
+ recording.recorder.requestData?.();
121891
+ } catch {
121892
+ }
121797
121893
  recording.recorder.stop();
121798
121894
  return;
121799
121895
  }
121800
- disposeTableRecording(recording);
121801
- if (tableRecording === recording) tableRecording = null;
121802
- setRecordButton(false);
121896
+ finalizeTableRecording(recording, recording.recorder.mimeType);
121803
121897
  }
121804
121898
  function modelForPlayerId(playerId) {
121805
121899
  return currentState?.config?.players?.find?.((p) => p.id === playerId)?.model || "";
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.7.0 — benchmark methodology & results
1
+ # pokertools-arena 0.8.0 — benchmark methodology & results
2
2
 
3
3
  Generated: 2026-09-19T18:19:58.352Z
4
4
  Methodology: paired fixed-state corpus; deterministic interleaving with recorded experiment seed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,34 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.8.0 — tab-audio recording with timestamp-preserving A/V sync
4
+
5
+ - **Recordings capture the current tab's own audio.** The recorder now requests
6
+ audio from `getDisplayMedia()` (`audio: true`, `systemAudio: 'exclude'`) and
7
+ muxes the returned display-media audio track straight into the `MediaRecorder`
8
+ stream. The previous per-recording Web Audio
9
+ `MediaStreamAudioDestinationNode` was removed, so the exported WebM carries the
10
+ tab's real output instead of a re-routed copy of the table's own cues.
11
+ - **Timestamp-preserving video pipeline.** When the browser exposes
12
+ `MediaStreamTrackProcessor` / `MediaStreamTrackGenerator` / `VideoFrame`, each
13
+ captured source frame is cropped to the table on the canvas and re-emitted as
14
+ a `VideoFrame` that keeps the source capture `timestamp` and `duration`. Audio
15
+ and video therefore share the same capture clock.
16
+ - **Real-time backpressure guard.** The pump reads `writer.desiredSize` and drops
17
+ frames while the generator is saturated, so a slow encoder cannot build a
18
+ delayed video queue.
19
+ - **Fixed-rate canvas fallback kept.** Browsers without the timestamped media-
20
+ track APIs fall back to the existing `canvas.captureStream(30)` painter, so
21
+ recording still works everywhere the old path did.
22
+ - **Higher quality ceiling.** Maximum output size rose from 1920×1080 to
23
+ 2560×1440, the adaptive video bitrate range to 10–28 Mbps, and the audio target
24
+ bitrate to 192 kbps. High-quality canvas scaling, `contentHint = "detail"` for
25
+ UI/text, and a 30 FPS stability target are retained.
26
+ - **More robust lifecycle.** The crop canvas is sized from the actual capture
27
+ video dimensions, and generated tracks, readers, writers, the capture video and
28
+ the source stream are all torn down on stop or failure. Pipeline errors now
29
+ surface as a toast when the recording is finalized instead of failing
30
+ silently, and the recorder chunk interval dropped to 500 ms.
31
+
3
32
  ## 0.7.0 — table sound effects and recorded audio
4
33
 
5
34
  - **Real sound effects replace the synthesized tones.** The table now plays six
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Browser-first AI poker benchmark powered by @pokertools/engine with generic OpenAI-compatible endpoints, optional OpenRouter/Jev Decisions routing, deterministic hierarchical decisions, paired fixed-state methodology, and reproducible reporting.",
5
5
  "type": "module",
6
6
  "bin": {