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.
- package/README.md +1 -1
- package/dist/app.js +244 -150
- package/dist/index.html +3 -3
- package/dist/pokertools-arena.html +245 -151
- package/docs/diagnostics/SUMMARY.md +1 -1
- package/docs/diagnostics/summary.json +1 -1
- package/docs/releases/RELEASE_NOTES.md +29 -0
- package/package.json +1 -1
- package/src/app.js +244 -160
- package/src/index.html +3 -3
|
@@ -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
|
|
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:
|
|
121440
|
-
maxHeight:
|
|
121441
|
-
minBitrate:
|
|
121442
|
-
maxBitrate:
|
|
121443
|
-
bitsPerPixelPerFrame: 0.
|
|
121444
|
-
audioBitsPerSecond:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
121558
|
-
const
|
|
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:
|
|
121565
|
-
scaleY:
|
|
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(
|
|
121517
|
+
function tableCropSourceRect(sourceWidth, sourceHeight) {
|
|
121571
121518
|
const rect = els.pokerTable.getBoundingClientRect();
|
|
121572
|
-
const metrics = captureViewportMetrics(
|
|
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(
|
|
121582
|
-
const source = tableCropSourceRect(
|
|
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
|
|
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
|
|
121594
|
-
const
|
|
121595
|
-
|
|
121596
|
-
|
|
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
|
-
|
|
121610
|
-
|
|
121611
|
-
|
|
121612
|
-
|
|
121613
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
121716
|
+
function disposeTableRecording(recording) {
|
|
121646
121717
|
if (!recording) return;
|
|
121647
|
-
|
|
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
|
-
|
|
121657
|
-
if (stopCapture) recording.captureStream?.getTracks?.().forEach((track) => track.stop());
|
|
121727
|
+
recording.canvasStream?.getTracks?.().forEach((track) => track.stop());
|
|
121658
121728
|
try {
|
|
121659
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
121712
|
-
const
|
|
121713
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
121818
|
+
pipelineError: null
|
|
121735
121819
|
};
|
|
121736
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121771
|
-
|
|
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 ? "
|
|
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
|
-
|
|
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
|
-
|
|
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,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.
|
|
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": {
|