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 CHANGED
@@ -17,7 +17,7 @@
17
17
  | **Package** | `npx pokertools-arena` |
18
18
  | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
19
 
20
- > **Current release: 0.7.0.** The table-audio release: the poker table now uses six normalized, role-named sound effects bundled straight into the app, and the table recorder mixes that Web Audio output into the video so recordings include the game audio.
20
+ > **Current release: 0.8.0.** The tab-audio recording release: table recordings now capture the current tab's own audio from `getDisplayMedia()` and rebuild video frames through a timestamp-preserving `MediaStreamTrackProcessor` → `VideoFrame` pipeline, so the exported WebM keeps audio and picture on the same capture clock at up to 2560×1440.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -115702,13 +115702,6 @@ function soundMaster() {
115702
115702
  }
115703
115703
  return soundMasterGain;
115704
115704
  }
115705
- function recordingAudioTrack() {
115706
- const ctx = getAudioContext();
115707
- if (!ctx || typeof ctx.createMediaStreamDestination !== "function") return null;
115708
- const destination = ctx.createMediaStreamDestination();
115709
- soundMaster()?.connect(destination);
115710
- return { destination, track: destination.stream.getAudioTracks()[0] || null };
115711
- }
115712
115705
  function tone(freq, duration = 0.05, gain = 0.035, offset = 0, type = "sine") {
115713
115706
  if (!soundEnabled) return;
115714
115707
  const ctx = getAudioContext();
@@ -118000,26 +117993,26 @@ function openTests() {
118000
117993
  }
118001
117994
  var TABLE_RECORDING_CONFIG = Object.freeze({
118002
117995
  frameRate: 30,
118003
- maxWidth: 1920,
118004
- maxHeight: 1080,
118005
- minBitrate: 6e6,
118006
- maxBitrate: 16e6,
118007
- bitsPerPixelPerFrame: 0.14,
118008
- audioBitsPerSecond: 128e3,
117996
+ maxWidth: 2560,
117997
+ maxHeight: 1440,
117998
+ minBitrate: 1e7,
117999
+ maxBitrate: 28e6,
118000
+ bitsPerPixelPerFrame: 0.2,
118001
+ audioBitsPerSecond: 192e3,
118009
118002
  background: "#07090b"
118010
118003
  });
118011
- function recorderMimeType() {
118004
+ function recorderMimeType(hasAudio = true) {
118012
118005
  if (!globalThis.MediaRecorder) return "";
118013
- for (const type of [
118006
+ const candidates = hasAudio ? [
118014
118007
  "video/webm;codecs=vp8,opus",
118015
118008
  "video/webm;codecs=vp9,opus",
118009
+ "video/webm"
118010
+ ] : [
118016
118011
  "video/webm;codecs=vp8",
118017
118012
  "video/webm;codecs=vp9",
118018
118013
  "video/webm"
118019
- ]) {
118020
- if (MediaRecorder.isTypeSupported?.(type)) return type;
118021
- }
118022
- return "";
118014
+ ];
118015
+ return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || "";
118023
118016
  }
118024
118017
  function recordingVideoBitrate({ width, height, frameRate = TABLE_RECORDING_CONFIG.frameRate }) {
118025
118018
  const pixelsPerSecond = Math.max(1, width) * Math.max(1, height) * Math.max(1, frameRate);
@@ -118037,7 +118030,7 @@ function setRecordButton(active, label = null) {
118037
118030
  const text = $(".action-label", els.recordBtn);
118038
118031
  if (icon) icon.textContent = active ? "\u25A0" : "\u25CF";
118039
118032
  if (text) text.textContent = label || (active ? "Stop rec" : "Record");
118040
- els.recordBtn.title = active ? "Stop table recording and save video" : "Record only the poker table";
118033
+ els.recordBtn.title = active ? "Stop table recording and save video" : "Record poker table with current-tab audio";
118041
118034
  els.recordBtn.setAttribute("aria-label", els.recordBtn.title);
118042
118035
  }
118043
118036
  function saveRecordingBlob(blob) {
@@ -118068,72 +118061,26 @@ function fitRecordingSize(sourceWidth, sourceHeight) {
118068
118061
  height: evenRecordingDimension(height * scale)
118069
118062
  };
118070
118063
  }
118071
- function waitForCaptureVideo(video, track) {
118072
- return new Promise((resolve, reject) => {
118073
- let settled = false;
118074
- const finish = (callback, value) => {
118075
- if (settled) return;
118076
- settled = true;
118077
- clearTimeout(timeoutId);
118078
- track?.removeEventListener?.("ended", onEnded);
118079
- video.removeEventListener("loadedmetadata", onReady);
118080
- video.removeEventListener("resize", onReady);
118081
- callback(value);
118082
- };
118083
- const onReady = () => {
118084
- if (video.videoWidth > 0 && video.videoHeight > 0) finish(resolve);
118085
- };
118086
- const onEnded = () => finish(reject, new Error("Screen sharing ended before recording started"));
118087
- const timeoutId = setTimeout(
118088
- () => finish(reject, new Error("Timed out waiting for the shared tab video")),
118089
- 8e3
118090
- );
118091
- track?.addEventListener?.("ended", onEnded, { once: true });
118092
- video.addEventListener("loadedmetadata", onReady);
118093
- video.addEventListener("resize", onReady);
118094
- onReady();
118095
- });
118096
- }
118097
- function createCaptureVideo(stream) {
118098
- const video = document.createElement("video");
118099
- video.muted = true;
118100
- video.playsInline = true;
118101
- video.autoplay = true;
118102
- video.setAttribute("aria-hidden", "true");
118103
- Object.assign(video.style, {
118104
- position: "fixed",
118105
- left: "0",
118106
- top: "0",
118107
- width: "2px",
118108
- height: "2px",
118109
- opacity: "0.001",
118110
- pointerEvents: "none",
118111
- zIndex: "2147483647"
118112
- });
118113
- video.srcObject = stream;
118114
- document.body.append(video);
118115
- return video;
118116
- }
118117
- function captureViewportMetrics(video) {
118064
+ function captureViewportMetrics(sourceWidth, sourceHeight) {
118118
118065
  const viewport = globalThis.visualViewport;
118119
118066
  const cssWidth = Math.max(1, viewport?.width || document.documentElement.clientWidth || innerWidth || 1);
118120
118067
  const cssHeight = Math.max(1, viewport?.height || document.documentElement.clientHeight || innerHeight || 1);
118121
- const sourceWidth = Math.max(2, video.videoWidth || cssWidth);
118122
- const sourceHeight = Math.max(2, video.videoHeight || cssHeight);
118068
+ const width = Math.max(2, Number(sourceWidth) || cssWidth);
118069
+ const height = Math.max(2, Number(sourceHeight) || cssHeight);
118123
118070
  return {
118124
118071
  cssWidth,
118125
118072
  cssHeight,
118126
- sourceWidth,
118127
- sourceHeight,
118128
- scaleX: sourceWidth / cssWidth,
118129
- scaleY: sourceHeight / cssHeight,
118073
+ sourceWidth: width,
118074
+ sourceHeight: height,
118075
+ scaleX: width / cssWidth,
118076
+ scaleY: height / cssHeight,
118130
118077
  offsetX: viewport?.offsetLeft || 0,
118131
118078
  offsetY: viewport?.offsetTop || 0
118132
118079
  };
118133
118080
  }
118134
- function tableCropSourceRect(video) {
118081
+ function tableCropSourceRect(sourceWidth, sourceHeight) {
118135
118082
  const rect = els.pokerTable.getBoundingClientRect();
118136
- const metrics = captureViewportMetrics(video);
118083
+ const metrics = captureViewportMetrics(sourceWidth, sourceHeight);
118137
118084
  const leftCss = rect.left - metrics.offsetX;
118138
118085
  const topCss = rect.top - metrics.offsetY;
118139
118086
  const sx = clamp(Math.round(leftCss * metrics.scaleX), 0, Math.max(0, metrics.sourceWidth - 2));
@@ -118142,73 +118089,197 @@ function tableCropSourceRect(video) {
118142
118089
  const sh = clamp(Math.round(rect.height * metrics.scaleY), 2, metrics.sourceHeight - sy);
118143
118090
  return { sx, sy, sw, sh };
118144
118091
  }
118145
- function createTableRecordingCanvas(video) {
118146
- const source = tableCropSourceRect(video);
118092
+ function createTableRecordingCanvas(sourceWidth, sourceHeight) {
118093
+ const source = tableCropSourceRect(sourceWidth, sourceHeight);
118147
118094
  const output = fitRecordingSize(source.sw, source.sh);
118148
118095
  const canvas = document.createElement("canvas");
118149
118096
  canvas.width = output.width;
118150
118097
  canvas.height = output.height;
118151
- const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true });
118098
+ const ctx = canvas.getContext("2d", { alpha: false });
118152
118099
  if (!ctx) throw new Error("Canvas video recording is not available in this browser");
118153
118100
  ctx.imageSmoothingEnabled = true;
118154
118101
  ctx.imageSmoothingQuality = "high";
118155
118102
  return { canvas, ctx };
118156
118103
  }
118157
- function createCanvasRecordingStream(canvas) {
118158
- const stream = canvas.captureStream(TABLE_RECORDING_CONFIG.frameRate);
118159
- const track = stream.getVideoTracks()[0];
118160
- if (!track) throw new Error("Could not create the table recording video track");
118161
- return { stream, track };
118162
- }
118163
- function paintTableRecordingFrame(recording) {
118164
- const { ctx, canvas, captureVideo } = recording;
118165
- if (!ctx || !canvas || !captureVideo || captureVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return false;
118166
- const source = tableCropSourceRect(captureVideo);
118167
- ctx.save();
118104
+ function paintTableRecordingFrame(recording, source, sourceWidth, sourceHeight) {
118105
+ const { ctx, canvas } = recording;
118106
+ if (!ctx || !canvas || !source) return false;
118107
+ const crop = tableCropSourceRect(sourceWidth, sourceHeight);
118168
118108
  ctx.globalCompositeOperation = "copy";
118169
118109
  ctx.fillStyle = TABLE_RECORDING_CONFIG.background;
118170
118110
  ctx.fillRect(0, 0, canvas.width, canvas.height);
118171
118111
  try {
118172
118112
  ctx.drawImage(
118173
- captureVideo,
118174
- source.sx,
118175
- source.sy,
118176
- source.sw,
118177
- source.sh,
118113
+ source,
118114
+ crop.sx,
118115
+ crop.sy,
118116
+ crop.sw,
118117
+ crop.sh,
118178
118118
  0,
118179
118119
  0,
118180
118120
  canvas.width,
118181
118121
  canvas.height
118182
118122
  );
118123
+ return true;
118183
118124
  } catch {
118184
- ctx.restore();
118185
118125
  return false;
118126
+ } finally {
118127
+ ctx.globalCompositeOperation = "source-over";
118186
118128
  }
118187
- ctx.restore();
118188
- return true;
118189
118129
  }
118190
- function startTableRecordingPainter(recording) {
118130
+ function createCaptureVideo(stream) {
118131
+ const video = document.createElement("video");
118132
+ video.muted = true;
118133
+ video.playsInline = true;
118134
+ video.autoplay = true;
118135
+ video.setAttribute("aria-hidden", "true");
118136
+ Object.assign(video.style, {
118137
+ position: "fixed",
118138
+ inset: "0 auto auto 0",
118139
+ width: "2px",
118140
+ height: "2px",
118141
+ opacity: "0.001",
118142
+ pointerEvents: "none",
118143
+ zIndex: "-1"
118144
+ });
118145
+ video.srcObject = stream;
118146
+ document.body.append(video);
118147
+ return video;
118148
+ }
118149
+ function waitForCaptureVideo(video, track) {
118150
+ return new Promise((resolve, reject) => {
118151
+ let settled = false;
118152
+ const finish = (callback, value) => {
118153
+ if (settled) return;
118154
+ settled = true;
118155
+ clearTimeout(timeoutId);
118156
+ track?.removeEventListener?.("ended", onEnded);
118157
+ video.removeEventListener("loadedmetadata", onReady);
118158
+ video.removeEventListener("resize", onReady);
118159
+ callback(value);
118160
+ };
118161
+ const onReady = () => {
118162
+ if (video.videoWidth > 0 && video.videoHeight > 0) finish(resolve);
118163
+ };
118164
+ const onEnded = () => finish(reject, new Error("Screen sharing ended before recording started"));
118165
+ const timeoutId = setTimeout(
118166
+ () => finish(reject, new Error("Timed out waiting for the shared tab video")),
118167
+ 8e3
118168
+ );
118169
+ track?.addEventListener?.("ended", onEnded, { once: true });
118170
+ video.addEventListener("loadedmetadata", onReady);
118171
+ video.addEventListener("resize", onReady);
118172
+ onReady();
118173
+ });
118174
+ }
118175
+ function supportsTimestampedVideoPipeline() {
118176
+ return typeof globalThis.MediaStreamTrackProcessor === "function" && typeof globalThis.MediaStreamTrackGenerator === "function" && typeof globalThis.VideoFrame === "function";
118177
+ }
118178
+ function createTimestampedVideoPipeline(recording) {
118179
+ if (!supportsTimestampedVideoPipeline()) return null;
118180
+ try {
118181
+ const processor = new MediaStreamTrackProcessor({ track: recording.captureTrack });
118182
+ const generator = new MediaStreamTrackGenerator({ kind: "video" });
118183
+ const reader = processor.readable.getReader();
118184
+ const writer = generator.writable.getWriter();
118185
+ generator.contentHint = "detail";
118186
+ recording.processorReader = reader;
118187
+ recording.generatorWriter = writer;
118188
+ recording.outputVideoTrack = generator;
118189
+ return generator;
118190
+ } catch {
118191
+ recording.processorReader = null;
118192
+ recording.generatorWriter = null;
118193
+ recording.outputVideoTrack = null;
118194
+ return null;
118195
+ }
118196
+ }
118197
+ function startTimestampedVideoPump(recording) {
118198
+ const reader = recording.processorReader;
118199
+ const writer = recording.generatorWriter;
118200
+ if (!reader || !writer) return;
118201
+ const fallbackDuration = Math.round(1e6 / TABLE_RECORDING_CONFIG.frameRate);
118202
+ recording.videoPumpPromise = (async () => {
118203
+ try {
118204
+ while (!recording.stopping) {
118205
+ const { value: frame, done } = await reader.read();
118206
+ if (done || !frame) break;
118207
+ try {
118208
+ const width = frame.displayWidth || frame.codedWidth || recording.sourceWidth;
118209
+ const height = frame.displayHeight || frame.codedHeight || recording.sourceHeight;
118210
+ if (!paintTableRecordingFrame(recording, frame, width, height)) continue;
118211
+ if (writer.desiredSize != null && writer.desiredSize <= 0) continue;
118212
+ const timestamp = Number.isFinite(frame.timestamp) ? frame.timestamp : Math.round(performance.now() * 1e3);
118213
+ const duration = Number.isFinite(frame.duration) && frame.duration > 0 ? frame.duration : fallbackDuration;
118214
+ const outputFrame = new VideoFrame(recording.canvas, { timestamp, duration });
118215
+ try {
118216
+ await writer.write(outputFrame);
118217
+ } finally {
118218
+ outputFrame.close();
118219
+ }
118220
+ } finally {
118221
+ frame.close();
118222
+ }
118223
+ }
118224
+ } catch (error) {
118225
+ if (!recording.stopping && tableRecording === recording) {
118226
+ recording.pipelineError = error;
118227
+ if (recording.recorder?.state !== "inactive") recording.recorder.stop();
118228
+ }
118229
+ }
118230
+ })();
118231
+ }
118232
+ function createFallbackCanvasPipeline(recording) {
118233
+ const stream = recording.canvas.captureStream(TABLE_RECORDING_CONFIG.frameRate);
118234
+ const track = stream.getVideoTracks()[0];
118235
+ if (!track) throw new Error("Could not create the table recording video track");
118236
+ track.contentHint = "detail";
118237
+ recording.canvasStream = stream;
118238
+ recording.outputVideoTrack = track;
118239
+ return track;
118240
+ }
118241
+ function startFallbackVideoPump(recording) {
118191
118242
  const frameInterval = 1e3 / TABLE_RECORDING_CONFIG.frameRate;
118192
118243
  let previousPaint = -frameInterval;
118193
118244
  const loop = (timestamp) => {
118194
118245
  if (recording.stopping || tableRecording !== recording) return;
118195
118246
  if (timestamp - previousPaint >= frameInterval - 1) {
118196
- paintTableRecordingFrame(recording);
118247
+ const video2 = recording.captureVideo;
118248
+ if (video2?.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
118249
+ paintTableRecordingFrame(recording, video2, video2.videoWidth, video2.videoHeight);
118250
+ }
118197
118251
  previousPaint = timestamp;
118198
118252
  }
118199
118253
  recording.animationFrameId = requestAnimationFrame(loop);
118200
118254
  };
118201
- paintTableRecordingFrame(recording);
118255
+ const video = recording.captureVideo;
118256
+ if (video?.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
118257
+ paintTableRecordingFrame(recording, video, video.videoWidth, video.videoHeight);
118258
+ }
118202
118259
  recording.animationFrameId = requestAnimationFrame(loop);
118203
118260
  }
118204
- function stopTableRecordingPainter(recording) {
118261
+ function startTableRecordingVideo(recording) {
118262
+ if (recording.processorReader) startTimestampedVideoPump(recording);
118263
+ else startFallbackVideoPump(recording);
118264
+ }
118265
+ function stopTableRecordingVideo(recording) {
118205
118266
  if (!recording) return;
118206
118267
  if (recording.animationFrameId != null) cancelAnimationFrame(recording.animationFrameId);
118207
118268
  recording.animationFrameId = null;
118269
+ try {
118270
+ recording.processorReader?.cancel?.();
118271
+ } catch {
118272
+ }
118273
+ try {
118274
+ recording.generatorWriter?.close?.();
118275
+ } catch {
118276
+ }
118277
+ recording.processorReader = null;
118278
+ recording.generatorWriter = null;
118208
118279
  }
118209
- function disposeTableRecording(recording, { stopCapture = true, stopCanvas = true } = {}) {
118280
+ function disposeTableRecording(recording) {
118210
118281
  if (!recording) return;
118211
- stopTableRecordingPainter(recording);
118282
+ stopTableRecordingVideo(recording);
118212
118283
  try {
118213
118284
  recording.captureVideo?.pause?.();
118214
118285
  } catch {
@@ -118217,28 +118288,28 @@ function disposeTableRecording(recording, { stopCapture = true, stopCanvas = tru
118217
118288
  recording.captureVideo.srcObject = null;
118218
118289
  recording.captureVideo.remove();
118219
118290
  }
118220
- if (stopCanvas) recording.canvasStream?.getTracks?.().forEach((track) => track.stop());
118221
- if (stopCapture) recording.captureStream?.getTracks?.().forEach((track) => track.stop());
118291
+ recording.canvasStream?.getTracks?.().forEach((track) => track.stop());
118222
118292
  try {
118223
- if (soundMasterGain && recording.audioDestination) soundMasterGain.disconnect(recording.audioDestination);
118224
- } catch {
118225
- }
118226
- try {
118227
- recording.audioTrack?.stop?.();
118293
+ recording.outputVideoTrack?.stop?.();
118228
118294
  } catch {
118229
118295
  }
118296
+ recording.captureStream?.getTracks?.().forEach((track) => track.stop());
118230
118297
  recording.canvas?.remove?.();
118231
118298
  }
118232
118299
  function finalizeTableRecording(recording, mimeType) {
118233
118300
  const blob = new Blob(recording.chunks, { type: recording.recorder.mimeType || mimeType || "video/webm" });
118301
+ const error = recording.pipelineError;
118234
118302
  disposeTableRecording(recording);
118235
118303
  if (tableRecording === recording) tableRecording = null;
118236
118304
  setRecordButton(false);
118237
118305
  if (blob.size) saveRecordingBlob(blob);
118306
+ if (error) showActionToast(`Recording stopped: ${summarizeError(error)}`, "fold");
118238
118307
  }
118239
118308
  async function startTableRecording() {
118240
118309
  if (tableRecording || !els.pokerTable) return;
118241
- if (!navigator.mediaDevices?.getDisplayMedia || !globalThis.MediaRecorder || !HTMLCanvasElement.prototype.captureStream) {
118310
+ const canTimestampVideo = supportsTimestampedVideoPipeline();
118311
+ const canCanvasVideo = typeof HTMLCanvasElement.prototype.captureStream === "function";
118312
+ if (!navigator.mediaDevices?.getDisplayMedia || !globalThis.MediaRecorder || !canTimestampVideo && !canCanvasVideo) {
118242
118313
  showActionToast("Table recording is not supported in this browser", "fold");
118243
118314
  return;
118244
118315
  }
@@ -118249,12 +118320,15 @@ async function startTableRecording() {
118249
118320
  captureStream = await navigator.mediaDevices.getDisplayMedia({
118250
118321
  video: {
118251
118322
  displaySurface: "browser",
118252
- frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate }
118323
+ frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate },
118324
+ width: { ideal: TABLE_RECORDING_CONFIG.maxWidth },
118325
+ height: { ideal: TABLE_RECORDING_CONFIG.maxHeight }
118253
118326
  },
118254
- audio: false,
118327
+ audio: true,
118255
118328
  preferCurrentTab: true,
118256
118329
  selfBrowserSurface: "include",
118257
- surfaceSwitching: "exclude"
118330
+ surfaceSwitching: "exclude",
118331
+ systemAudio: "exclude"
118258
118332
  });
118259
118333
  const captureTrack = captureStream.getVideoTracks()[0];
118260
118334
  if (!captureTrack) throw new Error("No video track was shared");
@@ -118262,48 +118336,74 @@ async function startTableRecording() {
118262
118336
  if (displaySurface && displaySurface !== "browser") {
118263
118337
  throw new Error("Please share this browser tab, not a window or the entire screen.");
118264
118338
  }
118339
+ try {
118340
+ captureTrack.contentHint = "detail";
118341
+ } catch {
118342
+ }
118265
118343
  try {
118266
118344
  await captureTrack.applyConstraints?.({
118267
118345
  frameRate: { ideal: TABLE_RECORDING_CONFIG.frameRate, max: TABLE_RECORDING_CONFIG.frameRate }
118268
118346
  });
118269
118347
  } catch {
118270
118348
  }
118349
+ const audioTrack = captureStream.getAudioTracks()[0] || null;
118350
+ if (audioTrack?.applyConstraints) {
118351
+ try {
118352
+ await audioTrack.applyConstraints({ suppressLocalAudioPlayback: false });
118353
+ } catch {
118354
+ }
118355
+ }
118271
118356
  captureVideo = createCaptureVideo(captureStream);
118272
118357
  await captureVideo.play().catch(() => {
118273
118358
  });
118274
118359
  await waitForCaptureVideo(captureVideo, captureTrack);
118275
- const { canvas, ctx } = createTableRecordingCanvas(captureVideo);
118276
- const canvasCapture = createCanvasRecordingStream(canvas);
118277
- const { stream: canvasStream, track: canvasTrack } = canvasCapture;
118278
- try {
118279
- canvasTrack.contentHint = "detail";
118280
- } catch {
118281
- }
118282
- const recordingAudio = recordingAudioTrack();
118283
- const audioTrack = recordingAudio?.track || null;
118284
- const recorderStream = audioTrack ? new MediaStream([canvasTrack, audioTrack]) : canvasStream;
118360
+ const sourceWidth = Math.max(2, captureVideo.videoWidth || innerWidth || 2);
118361
+ const sourceHeight = Math.max(2, captureVideo.videoHeight || innerHeight || 2);
118362
+ const { canvas, ctx } = createTableRecordingCanvas(sourceWidth, sourceHeight);
118285
118363
  recording = {
118286
118364
  recorder: null,
118365
+ recorderStream: null,
118287
118366
  captureStream,
118288
118367
  captureTrack,
118289
118368
  captureVideo,
118290
118369
  canvas,
118291
118370
  ctx,
118292
- canvasStream,
118293
- canvasTrack,
118371
+ canvasStream: null,
118372
+ outputVideoTrack: null,
118373
+ processorReader: null,
118374
+ generatorWriter: null,
118375
+ videoPumpPromise: null,
118376
+ animationFrameId: null,
118377
+ sourceWidth,
118378
+ sourceHeight,
118294
118379
  audioTrack,
118295
- audioDestination: recordingAudio?.destination || null,
118296
118380
  chunks: [],
118297
118381
  stopping: false,
118298
- animationFrameId: null
118382
+ pipelineError: null
118299
118383
  };
118300
- const mimeType = recorderMimeType();
118384
+ let outputVideoTrack = canTimestampVideo ? createTimestampedVideoPipeline(recording) : null;
118385
+ if (outputVideoTrack) {
118386
+ captureVideo.pause();
118387
+ captureVideo.srcObject = null;
118388
+ captureVideo.remove();
118389
+ captureVideo = null;
118390
+ recording.captureVideo = null;
118391
+ } else {
118392
+ if (!canCanvasVideo) throw new Error("This browser cannot create a recording video track");
118393
+ outputVideoTrack = createFallbackCanvasPipeline(recording);
118394
+ }
118395
+ const tracks = [outputVideoTrack];
118396
+ if (audioTrack) tracks.push(audioTrack);
118397
+ const recorderStream = new MediaStream(tracks);
118398
+ recording.recorderStream = recorderStream;
118399
+ const mimeType = recorderMimeType(Boolean(audioTrack));
118301
118400
  const videoBitsPerSecond = recordingVideoBitrate({
118302
118401
  width: canvas.width,
118303
118402
  height: canvas.height,
118304
118403
  frameRate: TABLE_RECORDING_CONFIG.frameRate
118305
118404
  });
118306
- const options = { videoBitsPerSecond, audioBitsPerSecond: TABLE_RECORDING_CONFIG.audioBitsPerSecond };
118405
+ const options = { videoBitsPerSecond };
118406
+ if (audioTrack) options.audioBitsPerSecond = TABLE_RECORDING_CONFIG.audioBitsPerSecond;
118307
118407
  if (mimeType) options.mimeType = mimeType;
118308
118408
  const recorder = new MediaRecorder(recorderStream, options);
118309
118409
  recording.recorder = recorder;
@@ -118314,29 +118414,21 @@ async function startTableRecording() {
118314
118414
  recorder.addEventListener("stop", () => finalizeTableRecording(recording, mimeType), { once: true });
118315
118415
  recorder.addEventListener("error", (event) => {
118316
118416
  if (tableRecording !== recording) return;
118417
+ recording.pipelineError = event.error || new Error("Recording failed");
118317
118418
  recording.stopping = true;
118318
- stopTableRecordingPainter(recording);
118319
- showActionToast(summarizeError(event.error || new Error("Recording failed")), "fold");
118419
+ stopTableRecordingVideo(recording);
118320
118420
  if (recorder.state !== "inactive") recorder.stop();
118321
- else {
118322
- disposeTableRecording(recording);
118323
- tableRecording = null;
118324
- setRecordButton(false);
118325
- }
118421
+ else finalizeTableRecording(recording, mimeType);
118326
118422
  });
118327
118423
  captureTrack.addEventListener("ended", () => {
118328
- if (tableRecording === recording && recorder.state !== "inactive") {
118329
- recording.stopping = true;
118330
- stopTableRecordingPainter(recording);
118331
- recorder.stop();
118332
- }
118424
+ if (tableRecording === recording && recorder.state !== "inactive") stopTableRecording();
118333
118425
  }, { once: true });
118334
- startTableRecordingPainter(recording);
118335
- recorder.start(1e3);
118426
+ recorder.start(500);
118427
+ startTableRecordingVideo(recording);
118336
118428
  setRecordButton(true);
118337
118429
  showActionToast(
118338
- `Recording \xB7 ${canvas.width}\xD7${canvas.height} \xB7 ${TABLE_RECORDING_CONFIG.frameRate} fps${audioTrack ? " \xB7 audio" : ""}`,
118339
- "check"
118430
+ `Recording \xB7 ${canvas.width}\xD7${canvas.height} \xB7 ${TABLE_RECORDING_CONFIG.frameRate} fps \xB7 ${audioTrack ? "tab audio" : "no tab audio"}`,
118431
+ audioTrack ? "check" : "fold"
118340
118432
  );
118341
118433
  } catch (err) {
118342
118434
  if (recording) disposeTableRecording(recording);
@@ -118356,14 +118448,16 @@ function stopTableRecording() {
118356
118448
  const recording = tableRecording;
118357
118449
  if (!recording || recording.stopping) return;
118358
118450
  recording.stopping = true;
118359
- stopTableRecordingPainter(recording);
118451
+ stopTableRecordingVideo(recording);
118360
118452
  if (recording.recorder.state !== "inactive") {
118453
+ try {
118454
+ recording.recorder.requestData?.();
118455
+ } catch {
118456
+ }
118361
118457
  recording.recorder.stop();
118362
118458
  return;
118363
118459
  }
118364
- disposeTableRecording(recording);
118365
- if (tableRecording === recording) tableRecording = null;
118366
- setRecordButton(false);
118460
+ finalizeTableRecording(recording, recording.recorder.mimeType);
118367
118461
  }
118368
118462
  function modelForPlayerId(playerId) {
118369
118463
  return currentState?.config?.players?.find?.((p) => p.id === playerId)?.model || "";
package/dist/index.html CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  </script>
55
55
 
56
- <link rel="stylesheet" href="./styles.css?v=0.6.5-recording-refactor" />
56
+ <link rel="stylesheet" href="./styles.css?v=0.6.6-tab-avsync-quality" />
57
57
  </head>
58
58
  <body>
59
59
  <div class="app-shell">
@@ -128,7 +128,7 @@
128
128
  <div id="tournamentMeta" class="mono muted">Seat 2–10 models, then press ▶</div>
129
129
  <div class="arena-head-actions">
130
130
  <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>
131
- <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>
131
+ <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>
132
132
  </div>
133
133
  </div>
134
134
 
@@ -391,6 +391,6 @@
391
391
  </template>
392
392
 
393
393
  <script src="./arena-env.js"></script>
394
- <script type="module" src="./app.js?v=0.6.5-recording-refactor"></script>
394
+ <script type="module" src="./app.js?v=0.6.6-tab-avsync-quality"></script>
395
395
  </body>
396
396
  </html>