| 1 | /// <reference lib="dom" /> |
| 2 | // This file is compiled independently, without Node integration or page code. |
| 3 | window.addEventListener("message", event => { |
| 4 | if (event.source !== window || event.data !== "reasonix-recorder-port" || !event.ports[0]) return; |
| 5 | const port = event.ports[0]; |
| 6 | let recorder: MediaRecorder | undefined; |
| 7 | let stream: MediaStream | undefined; |
| 8 | let output: MediaStream | undefined; |
| 9 | let paintTimer: ReturnType<typeof setInterval> | undefined; |
| 10 | let bytes = 0; |
| 11 | let chunks: Blob[] = []; |
| 12 | let started = 0; |
| 13 | let announced = false; |
| 14 | let cancelled = false; |
| 15 | let writes = Promise.resolve(); |
| 16 | const cleanup = () => { clearInterval(paintTimer); output?.getTracks().forEach(track => track.stop()); stream?.getTracks().forEach(track => track.stop()); stream = undefined; }; |
| 17 | const fail = (error: unknown) => { cancelled = true; cleanup(); port.postMessage({ type: "error", message: String(error).slice(0, 300) }); }; |
| 18 | port.onmessage = async ({ data }) => { |
| 19 | if (data.type === "cancel") { cancelled = true; if (recorder?.state === "recording") recorder.stop(); cleanup(); return; } |
| 20 | if (data.type === "stop") { if (recorder?.state === "recording") recorder.stop(); return; } |
| 21 | if (data.type !== "start" || recorder) return; |
| 22 | try { |
| 23 | // No frameRate: a static page sends only its first frame and the refresh a |
| 24 | // new sink asks for, and a capped track drops that refresh when it trails |
| 25 | // the first by less than a frame period. The paint interval sets output rate. |
| 26 | stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); |
| 27 | if (cancelled) { cleanup(); return; } |
| 28 | const input = document.createElement("video"); input.muted = true; input.srcObject = stream; |
| 29 | await input.play(); |
| 30 | const canvas = document.createElement("canvas"); canvas.width = data.width; canvas.height = data.height; |
| 31 | const context = canvas.getContext("2d"); |
| 32 | if (!context || !canvas.width || !canvas.height) throw new Error("invalid recording viewport"); |
| 33 | const paint = () => { |
| 34 | if (!input.videoWidth || !input.videoHeight) return; |
| 35 | context.drawImage(input, 0, 0, input.videoWidth * data.cropWidth, input.videoHeight * data.cropHeight, 0, 0, canvas.width, canvas.height); |
| 36 | (output?.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack | undefined)?.requestFrame(); |
| 37 | }; |
| 38 | output = canvas.captureStream(0); |
| 39 | const mimeType = ["video/webm;codecs=vp8", "video/webm"].find(type => MediaRecorder.isTypeSupported(type)); |
| 40 | if (!mimeType) throw new Error("WebM recording is unavailable"); |
| 41 | recorder = new MediaRecorder(output, { mimeType, videoBitsPerSecond: 3_000_000 }); |
| 42 | recorder.ondataavailable = event => { |
| 43 | if (cancelled || !event.data.size) return; |
| 44 | bytes += event.data.size; |
| 45 | if (bytes > 64 * 1024 * 1024) { fail("recording exceeded 64 MiB"); recorder?.stop(); return; } |
| 46 | chunks.push(event.data); |
| 47 | if (!announced) { announced = true; port.postMessage({ type: "started", elapsedMs: performance.now() - started }); } |
| 48 | // Electron MessagePortMain can drop transferred ArrayBuffers. Structured |
| 49 | // clone preserves chunk ordering across the DOM-to-main boundary. |
| 50 | writes = writes.then(async () => { const chunk = await event.data.arrayBuffer(); port.postMessage({ type: "chunk", data: chunk }); }); |
| 51 | }; |
| 52 | recorder.onerror = event => fail(event.error); |
| 53 | recorder.onstop = async () => { |
| 54 | cleanup(); |
| 55 | if (cancelled) return; |
| 56 | try { |
| 57 | await writes; |
| 58 | const blob = new Blob(chunks, { type: mimeType }); chunks = []; |
| 59 | const url = URL.createObjectURL(blob); |
| 60 | const video = document.createElement("video"); video.muted = true; video.src = url; |
| 61 | try { |
| 62 | await new Promise<void>((resolve, reject) => { video.onloadeddata = () => resolve(); video.onerror = () => reject(new Error("recorded WebM cannot be decoded")); }); |
| 63 | if (!video.videoWidth || !video.videoHeight) throw new Error("recording has no video dimensions"); |
| 64 | port.postMessage({ type: "stopped", width: video.videoWidth, height: video.videoHeight, durationMs: performance.now() - started, bytes }); |
| 65 | } finally { video.removeAttribute("src"); video.load(); URL.revokeObjectURL(url); } |
| 66 | } catch (error) { fail(error); } |
| 67 | }; |
| 68 | stream.getVideoTracks()[0]?.addEventListener("ended", () => { if (recorder?.state === "recording") fail("target page capture ended"); }); |
| 69 | started = performance.now(); recorder.start(250); |
| 70 | paint(); paintTimer = setInterval(paint, 40); |
| 71 | } catch (error) { fail(error); } |
| 72 | }; |
| 73 | port.postMessage({ type: "ready" }); |
| 74 | }, { once: true }); |
| 75 |