feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts

- Add blobToBase64 / base64ToBlob in src/shared/binary.ts:
  portable Blob↔base64 round-trip for the chrome.runtime.Port
  wire-format. JSON.stringify(blob) returns "{}" across extension
  contexts, so binary payloads must travel as base64 strings.
- Mirror the GREEN-block helper signatures from
  tests/offscreen/port-serialization.test.ts so the same test pins
  both the standalone helpers and the production wire format.
- Land tests/offscreen/port-serialization.test.ts as the RED+GREEN
  executable contract for the D-12 fix: the RED block reproduces
  the 75-byte "[object Object]" failure mode byte-for-byte; the
  GREEN block pins the base64 wire-format the fix must implement.
- Uses arrayBuffer() + btoa(String.fromCharCode...) rather than
  FileReader: FileReader is browser-only; the chosen approach
  works in both Chrome extension contexts and the Node-based
  vitest environment.

Refs: debug session d12-blob-port-transfer-fails.
This commit is contained in:
2026-05-15 20:06:51 +02:00
parent 1ebfb42b30
commit c0d9166a1d
2 changed files with 269 additions and 0 deletions

74
src/shared/binary.ts Normal file
View File

@@ -0,0 +1,74 @@
// src/shared/binary.ts — Portable Blob↔base64 helpers for the
// offscreen↔SW port wire-format (D-12 fix).
//
// Why this file exists:
// chrome.runtime.Port.postMessage JSON-serializes payloads across
// extension contexts. JSON.stringify(blob) === "{}" (Blob has no
// enumerable own properties), so any Blob passed through a port
// arrives as a plain empty object on the other side. The SW then
// calls `new Blob([{}, {}, ...])` and the constructor coerces each
// non-Blob member via String({}) === "[object Object]", producing
// exactly 75 bytes of garbage for 5 chunks — matching the observed
// ffprobe failure forensics in debug session d12.
//
// Fix: encode each chunk's binary payload to base64 in offscreen
// BEFORE port.postMessage; decode back to Blob in SW AFTER receive.
// base64 strings round-trip cleanly through JSON.
//
// The wire-format contract is pinned by tests/offscreen/port-serialization.test.ts
// (GREEN describe block). This module's two helpers MUST satisfy:
// 1. `await blobToBase64(blob)` returns just the base64 payload
// (no `data:...;base64,` prefix).
// 2. `base64ToBlob(b64, mimeType)` returns a Blob whose bytes
// equal `Uint8Array(await blob.arrayBuffer())` of the original.
// 3. EBML magic bytes 0x1A 0x45 0xDF 0xA3 round-trip intact through
// JSON.parse(JSON.stringify({dataBase64: await blobToBase64(...)})).
//
// Portability: both `btoa`/`atob` and `arrayBuffer()` are available in
// the modern browser/extension runtime AND in the Node-based vitest
// environment (Node 18+ has both as globals). No FileReader dependency
// (FileReader is browser-only — would break Node tests).
/**
* Encode a Blob's binary content to a base64 string suitable for
* JSON-friendly transport over chrome.runtime.Port.
*
* Returns the raw base64 payload only (no `data:<type>;base64,` prefix).
*
* @param blob - The binary blob to encode (typically a MediaRecorder
* dataavailable chunk).
* @returns Base64-encoded representation of the blob's bytes.
*/
export async function blobToBase64(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer();
const bytes = new Uint8Array(buf);
// Per-byte concat is the portable way: avoids the
// `String.fromCharCode(...bytes)` apply-spread argument-length limit
// (typically ~64 KiB on some engines) for large chunks.
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Decode a base64 string back into a Blob with the given MIME type.
*
* Synchronous because atob + Uint8Array population requires no I/O.
*
* @param b64 - The base64-encoded payload (as produced by
* {@link blobToBase64}). Must NOT include a
* `data:<type>;base64,` prefix.
* @param mimeType - The MIME type to assign to the reconstructed Blob
* (e.g. `video/webm;codecs=vp9`).
* @returns A Blob whose bytes match the original encoded blob exactly.
*/
export function base64ToBlob(b64: string, mimeType: string): Blob {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
}