From 067c4abbc7802e18ad1a87f8d96983dadd28edcc Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 2 May 2026 23:19:56 +0000 Subject: [PATCH] Fix GIF file mode: send transferable ArrayBuffer pixels instead of ImageData objects (structured clone can fail in some browsers). Worker reconstructs ImageData from received buffer. --- src/app/routes/receiver.tsx | 9 ++- src/tests/gif_browser.test.ts | 98 +++++++++++++++++++++++++++++++++ src/tests/gif_debug3.test.ts | 101 ++++++++++++++++++++++++++++++++++ src/workers/decode.worker.ts | 11 +++- 4 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/tests/gif_browser.test.ts create mode 100644 src/tests/gif_debug3.test.ts diff --git a/src/app/routes/receiver.tsx b/src/app/routes/receiver.tsx index f285f26..3992123 100644 --- a/src/app/routes/receiver.tsx +++ b/src/app/routes/receiver.tsx @@ -355,8 +355,13 @@ export function ReceiverPage() { for (let i = 0; i < gifData.frames.length; i++) { if (!scanningRef.current) break; const rgba = renderGifFrame(gifData, i); - const imageData = new ImageData(rgba, gifData.width, gifData.height); - worker.postMessage({ type: 'frame', imageData }); + // Send raw pixel buffer (ArrayBuffer) instead of ImageData to avoid + // structured clone issues with ImageData in some browsers. + const pixelBuf = rgba.buffer.slice(rgba.byteOffset, rgba.byteOffset + rgba.byteLength); + worker.postMessage( + { type: 'frame', pixels: pixelBuf, width: gifData.width, height: gifData.height }, + [pixelBuf], + ); } setStatus('GIF processed'); diff --git a/src/tests/gif_browser.test.ts b/src/tests/gif_browser.test.ts new file mode 100644 index 0000000..5c74a99 --- /dev/null +++ b/src/tests/gif_browser.test.ts @@ -0,0 +1,98 @@ +/** + * Debug: match EXACTLY what the browser app does. + */ +import { describe, it, expect } from 'vitest'; +import { SenderPacketizer } from '@/core/sender/packetizer'; +import { generateQRMatrix } from '@/core/qr/qr_encode'; +import { rasterizeQR } from '@/core/qr/frame_raster'; +import { createQRGif } from '@/core/gif/gif_render'; +import { parseGif, renderGifFrame } from '@/core/gif/gif_parser'; +import { decodeQRFromCanvas } from '@/core/qr/qr_decode'; + +describe('browser-accurate pipeline', () => { + it('uses raw packets (no FrameScheduler) like encode.worker.ts', async () => { + const text = 'Hello world test 123'; + const data = new TextEncoder().encode(text); + + // Step 1: Packetize (exactly like encode.worker.ts) + const sp = new SenderPacketizer(); + await sp.initialize(data, 'test.txt', 'text/plain'); + const manifest = sp.getManifest(); + const packets = sp.getPackets(); + + // The browser app uses allPackets = [...manifestPackets, ...packets] + // Just use packets directly for this test (includes manifest) + console.log('Total packets:', packets.length); + + // Step 2: Generate QR (exactly like gif.worker.ts) + const scale = 3; + const qrVersion = 20; + const eccLevel = 'Q'; + const frames: Uint8Array[] = []; + let fw = 0, fh = 0; + for (const packet of packets) { + const matrix = generateQRMatrix(packet, qrVersion, eccLevel); + const rgba = rasterizeQR(matrix, scale); + if (fw === 0) { fw = rgba.width; fh = rgba.height; } + frames.push(new Uint8Array(rgba.data.buffer)); + } + console.log('QR frames:', frames.length, `size: ${fw}×${fh}`); + + // Step 3: Create GIF (exactly like gif.worker.ts) + const delayMs = 100; + const gifBytes = createQRGif(frames, delayMs, fw, fh); + console.log('GIF size:', gifBytes.length, 'bytes'); + + // Step 4: Parse GIF (exactly like receiver.tsx) + const gifData = parseGif(gifBytes); + console.log('Parsed frames:', gifData.frames.length); + + // Step 5: Render + decode each frame + let decodedOk = 0; + let badLen = 0; + let badPixels = 0; + + for (let i = 0; i < gifData.frames.length; i++) { + const frame = gifData.frames[i]!; + const expectedPixels = frame.width * frame.height; + + if (frame.data.length !== expectedPixels) { + badLen++; + if (badLen <= 3) console.log(` Frame ${i}: data len ${frame.data.length} vs ${expectedPixels}`); + continue; + } + + const rgba = renderGifFrame(gifData, i); + const blackCount = countBlack(rgba, fw, fh); + const pct = blackCount / (fw * fh) * 100; + + // Check if image is mostly black (broken) or reasonable + if (pct > 80 || pct < 1) { + badPixels++; + if (badPixels <= 3) console.log(` Frame ${i}: ${blackCount}/${fw*fh} black (${pct.toFixed(1)}%) — too extreme`); + continue; + } + + const imageData = new ImageData(rgba, fw, fh); + const qr = decodeQRFromCanvas(imageData); + if (qr) { + decodedOk++; + } else { + if (badPixels + decodedOk <= 3) console.log(` Frame ${i}: QR null, ${blackCount}/${fw*fh} black`); + } + } + + console.log(`\nResults: ${decodedOk}/${gifData.frames.length} decoded`); + console.log(` Bad data length: ${badLen}`); + console.log(` Bad pixels (extreme): ${badPixels}`); + expect(decodedOk).toBe(gifData.frames.length); + }); +}); + +function countBlack(rgba: Uint8ClampedArray, w: number, h: number): number { + let black = 0; + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4]! < 128) black++; + } + return black; +} diff --git a/src/tests/gif_debug3.test.ts b/src/tests/gif_debug3.test.ts new file mode 100644 index 0000000..88be747 --- /dev/null +++ b/src/tests/gif_debug3.test.ts @@ -0,0 +1,101 @@ +/** + * Debug: dump GIF structure byte by byte. + */ +import { describe, it, expect } from 'vitest'; +import { SenderPacketizer } from '@/core/sender/packetizer'; +import { FrameScheduler } from '@/core/sender/scheduler'; +import { generateQRMatrix } from '@/core/qr/qr_encode'; +import { rasterizeQR } from '@/core/qr/frame_raster'; +import { createQRGif } from '@/core/gif/gif_render'; +import { parseGif, renderGifFrame, gifFrameToRgba } from '@/core/gif/gif_parser'; +import { decodeQRFromCanvas } from '@/core/qr/qr_decode'; + +describe('gif structure debug', () => { + it('dump GIF header, palette, and frame details', async () => { + const data = new TextEncoder().encode('Hello test'); + const sp = new SenderPacketizer(); + await sp.initialize(data, 'test.txt', 'text/plain'); + const manifest = sp.getManifest(); + const packets = sp.getPackets(); + const scheduler = new FrameScheduler(); + const schedule = scheduler.schedule(packets, manifest); + + const scale = 3; + const frames: Uint8Array[] = []; + let fw = 0, fh = 0; + for (const p of schedule) { + const matrix = generateQRMatrix(p, 20, 'Q'); + const rgba = rasterizeQR(matrix, scale); + if (fw === 0) { fw = rgba.width; fh = rgba.height; } + frames.push(new Uint8Array(rgba.data.buffer)); + } + + const gifBytes = createQRGif(frames, 100, fw, fh); + + // Dump GIF header + const header = new TextDecoder().decode(gifBytes.subarray(0, 6)); + console.log('Header:', header); + const width = gifBytes[6]! | (gifBytes[7]! << 8); + const height = gifBytes[8]! | (gifBytes[9]! << 8); + const packed = gifBytes[10]!; + console.log(`LSD: ${width}x${height}, packed=0x${packed.toString(16)}`); + console.log(` Global palette: ${!!(packed & 0x80)}, color res: ${(packed >> 4) & 0x07}, sort: ${!!(packed & 0x08)}, size field: ${packed & 0x07}`); + + const paletteSize = (packed & 0x07) + 1; + const paletteEntryCount = 1 << paletteSize; + console.log(` Palette entries: ${paletteEntryCount}`); + + const gifPaletteOffset = 13; // after bg color index + pixel aspect ratio + const gifPalette: number[][] = []; + for (let i = 0; i < paletteEntryCount; i++) { + const off = gifPaletteOffset + i * 3; + gifPalette.push([gifBytes[off]!, gifBytes[off + 1]!, gifBytes[off + 2]!]); + } + console.log('GIF palette:', gifPalette.map((c, i) => `${i}: rgb(${c.join(',')})`).join('\n ')); + + // Parse with our parser + const gifData = parseGif(gifBytes); + console.log('\nParsed frames:', gifData.frames.length); + console.log('Global palette:', gifData.globalPalette?.map((c,i)=>`${i}: rgb(${c.join(',')})`).join('\n ')); + + // Check each frame + for (let i = 0; i < Math.min(gifData.frames.length, 5); i++) { + const f = gifData.frames[i]!; + console.log(`\nFrame ${i}:`); + console.log(` Size: ${f.width}x${f.height}, data.len: ${f.data.length}, expected: ${f.width * f.height}`); + console.log(` Palette:`, f.palette.map((c,i)=>`${i}: rgb(${c.join(',')})`).join(' ')); + console.log(` Delay: ${f.delay}, disposal: ${f.disposal}, left: ${f.left}, top: ${f.top}`); + + // Check if data length matches + const expected = f.width * f.height; + if (f.data.length !== expected) { + console.log(` ⚠ DATA LENGTH MISMATCH: ${f.data.length} vs ${expected}`); + } + + // Sample pixel values + const sample = Array.from(f.data.slice(0, 20)); + console.log(` First 20 pixel indices:`, sample.join(',')); + + // Try rendering and decode + const rgba = gifFrameToRgba(f); + const blackPixels = countBlack(rgba, f.width, f.height); + console.log(` Black pixels: ${blackPixels}/${expected} (${(blackPixels/expected*100).toFixed(1)}%)`); + + const canvas = renderGifFrame(gifData, i); + const blackCanvas = countBlack(canvas, gifData.width, gifData.height); + console.log(` Canvas black: ${blackCanvas}/${gifData.width*gifData.height} (${(blackCanvas/(gifData.width*gifData.height)*100).toFixed(1)}%)`); + + const imageData = new ImageData(canvas, gifData.width, gifData.height); + const qr = decodeQRFromCanvas(imageData); + console.log(` QR decode: ${qr ? `OK (${qr.length} bytes)` : 'FAIL'}`); + } + }); +}); + +function countBlack(rgba: Uint8ClampedArray, w: number, h: number): number { + let black = 0; + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4]! < 128) black++; + } + return black; +} diff --git a/src/workers/decode.worker.ts b/src/workers/decode.worker.ts index 202daa6..371161f 100644 --- a/src/workers/decode.worker.ts +++ b/src/workers/decode.worker.ts @@ -53,10 +53,17 @@ self.onmessage = (e: MessageEvent) => { } if (msg.type === 'frame') { - const imageData: ImageData = msg.imageData ?? msg.frameData; + let imageData: ImageData | null = msg.imageData ?? msg.frameData ?? null; + if (!imageData && msg.pixels && msg.width && msg.height) { + imageData = new ImageData( + new Uint8ClampedArray(msg.pixels), + msg.width, + msg.height, + ); + } if (!imageData) return; try { - handleFrame(imageData); + handleFrame(imageData!); } catch (err: any) { // Don't crash worker on decode errors self.postMessage({ type: 'error', message: err.message ?? String(err) });