diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index 57b113a..5db0b2a 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -87,7 +87,7 @@ type PopOutMessage = | { type: 'pong' } | { type: 'close' } | { type: 'visibility'; data: boolean } - | { type: 'frame'; data: string } + | { type: 'frame'; data: Blob } | { type: 'config'; data: PopOutConfig } | { type: 'info'; data: PopOutInfo } | { type: 'nodes-list'; data: NodeListData } @@ -133,6 +133,8 @@ export class PopOutManager { /** Throttle frame sending to ~30fps */ private lastFrameTime: number = 0; private readonly FRAME_INTERVAL = 33; // ~30fps + /** One frame is encoded at a time; a slow encode drops frames instead of queueing them */ + private frameInFlight: boolean = false; /** Unanswered pings, one a second, before the viewer counts as gone */ private readonly PING_LIMIT = 5; @@ -283,12 +285,12 @@ export class PopOutManager { } /** - * Send a frame to the pop-out viewer, unless it is hidden. + * Send a frame to the pop-out viewer as a PNG Blob, unless it is hidden. * Throttled to ~30fps for performance. * @param canvas - The source canvas to send */ sendFrame(canvas: HTMLCanvasElement): void { - if (!this.isOpen || this.viewerHidden || !this.channel) return; + if (!this.isOpen || this.viewerHidden || this.frameInFlight || !this.channel) return; // Throttle frame rate const now = Date.now(); @@ -297,12 +299,12 @@ export class PopOutManager { } this.lastFrameTime = now; - try { - // Convert canvas to data URL (efficient for small canvases) - this.sendMessage({ type: 'frame', data: canvas.toDataURL('image/png') }); - } catch (e) { - Logger.error('[PopOut] Failed to send frame:', e); - } + // The WebGL canvas keeps its drawing buffer, so encoding after this frame is safe + this.frameInFlight = true; + canvas.toBlob((blob) => { + this.frameInFlight = false; + if (blob && this.isOpen) this.sendMessage({ type: 'frame', data: blob }); + }, 'image/png'); } /** diff --git a/tests/unit/PopOutConnection.test.ts b/tests/unit/PopOutConnection.test.ts index a55fd17..768e546 100644 --- a/tests/unit/PopOutConnection.test.ts +++ b/tests/unit/PopOutConnection.test.ts @@ -131,18 +131,30 @@ describe('Pop-out connection', () => { expect(configs()).toEqual([['on', true], ['hidden', true], ['off', false]]); }); - it('sends no frames while the viewer is hidden', () => { - const canvas = { toDataURL: () => 'data:image/png;base64,AA' } as unknown as HTMLCanvasElement; - const frames = () => posted().filter(m => m.type === 'frame').length; + it('sends frames as Blobs, one encode at a time, and none while the viewer is hidden', () => { + const blob = new Blob(['png'], { type: 'image/png' }); + let encode: BlobCallback | null = null; + const canvas = { toBlob: (callback: BlobCallback) => { encode = callback; } } as unknown as HTMLCanvasElement; + const frames = () => posted().filter(m => m.type === 'frame').map(m => m.data); receive({ type: 'pong' }); receive({ type: 'visibility', data: true }); manager.sendFrame(canvas); - expect(frames()).toBe(0); + expect(encode).toBeNull(); receive({ type: 'visibility', data: false }); manager.sendFrame(canvas); - expect(frames()).toBe(1); + const first = encode!; + encode = null; + // The first frame is still encoding + vi.advanceTimersByTime(100); + manager.sendFrame(canvas); + expect(encode).toBeNull(); + + first(blob); + expect(frames()).toEqual([blob]); + manager.sendFrame(canvas); + expect(encode).not.toBeNull(); }); it('hands viewer requests to the Inspector and sends its info as it is', () => { diff --git a/tests/unit/PopOutViewer.test.ts b/tests/unit/PopOutViewer.test.ts index 229c9e7..faf6de1 100644 --- a/tests/unit/PopOutViewer.test.ts +++ b/tests/unit/PopOutViewer.test.ts @@ -38,7 +38,7 @@ describe('Pop-out viewer editing', () => { constructor(public name: string) { channel = this as any; } }); history.replaceState(null, '', '/popout-viewer.html?v=1&session=abc123'); - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ clearRect: vi.fn(), drawImage: vi.fn() } as any); vi.spyOn(window, 'close').mockImplementation(() => { }); document.body.innerHTML = bodyHtml; new Function(script)(); @@ -242,6 +242,42 @@ describe('Pop-out viewer editing', () => { expect(valueOf('seed')).toBeDefined(); }); + it('draws a Blob frame at its own size and closes its bitmap', async () => { + const bitmap = { width: 400, height: 400, close: vi.fn() }; + const decode = vi.fn().mockResolvedValue(bitmap); + vi.stubGlobal('createImageBitmap', decode); + const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement; + const ctx = canvas.getContext('2d') as any; + const blob = new Blob(['png'], { type: 'image/png' }); + + channel.onmessage!({ data: { type: 'frame', data: blob } }); + await vi.waitFor(() => expect(bitmap.close).toHaveBeenCalled()); + + expect(decode).toHaveBeenCalledWith(blob); + expect([canvas.width, canvas.height]).toEqual([400, 400]); + expect(ctx.drawImage).toHaveBeenCalledWith(bitmap, 0, 0); + expect(document.querySelector('.canvas-placeholder')!.classList.contains('hidden')).toBe(true); + }); + + it('never draws an older frame over a newer one', async () => { + const older = { width: 400, height: 400, close: vi.fn() }; + const newer = { width: 400, height: 400, close: vi.fn() }; + let resolveOlder!: (bitmap: typeof older) => void; + vi.stubGlobal('createImageBitmap', vi.fn() + .mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve; })) + .mockResolvedValueOnce(newer)); + const ctx = (document.getElementById('viewer-canvas') as HTMLCanvasElement).getContext('2d') as any; + + channel.onmessage!({ data: { type: 'frame', data: new Blob(['a']) } }); + channel.onmessage!({ data: { type: 'frame', data: new Blob(['b']) } }); + await vi.waitFor(() => expect(newer.close).toHaveBeenCalled()); + resolveOlder(older); + await vi.waitFor(() => expect(older.close).toHaveBeenCalled()); + + expect(ctx.drawImage).toHaveBeenCalledTimes(1); + expect(ctx.drawImage).toHaveBeenCalledWith(newer, 0, 0); + }); + it('Escape closes the node list, never the window', () => { sendInfo([]); (sidebar().querySelector('[data-action="node-dropdown"]') as HTMLElement).click(); diff --git a/web/magnify-glass/PopOutManager.js b/web/magnify-glass/PopOutManager.js index d305829..4dc5c78 100644 --- a/web/magnify-glass/PopOutManager.js +++ b/web/magnify-glass/PopOutManager.js @@ -33,6 +33,8 @@ class PopOutManager { __publicField(this, "lastFrameTime", 0); __publicField(this, "FRAME_INTERVAL", 33); // ~30fps + /** One frame is encoded at a time; a slow encode drops frames instead of queueing them */ + __publicField(this, "frameInFlight", false); /** Unanswered pings, one a second, before the viewer counts as gone */ __publicField(this, "PING_LIMIT", 5); this.getStatus = getStatus; @@ -168,22 +170,22 @@ class PopOutManager { return this.isOpen; } /** - * Send a frame to the pop-out viewer, unless it is hidden. + * Send a frame to the pop-out viewer as a PNG Blob, unless it is hidden. * Throttled to ~30fps for performance. * @param canvas - The source canvas to send */ sendFrame(canvas) { - if (!this.isOpen || this.viewerHidden || !this.channel) return; + if (!this.isOpen || this.viewerHidden || this.frameInFlight || !this.channel) return; const now = Date.now(); if (now - this.lastFrameTime < this.FRAME_INTERVAL) { return; } this.lastFrameTime = now; - try { - this.sendMessage({ type: "frame", data: canvas.toDataURL("image/png") }); - } catch (e) { - Logger.error("[PopOut] Failed to send frame:", e); - } + this.frameInFlight = true; + canvas.toBlob((blob) => { + this.frameInFlight = false; + if (blob && this.isOpen) this.sendMessage({ type: "frame", data: blob }); + }, "image/png"); } /** * Send the theme and the glass and Inspector state when they change. diff --git a/web/popout-viewer.html b/web/popout-viewer.html index 28240eb..55989d5 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -558,7 +558,7 @@ max-width: 100% !important; max-height: 100% !important; border-radius: 0; - object-fit: cover; + object-fit: contain; box-shadow: none; } @@ -580,7 +580,7 @@ max-height: none !important; border-radius: 0 !important; box-shadow: none !important; - object-fit: cover; + object-fit: contain; } /* Clickable row styles */ @@ -896,7 +896,7 @@ let connection = 'connecting'; let lastPingTime = 0; let config = { glass: 'on', inspector: true }; - const frameImage = new Image(); + let frameCount = 0; const channel = new BroadcastChannel(`${CHANNEL_NAME}:${session}`); @@ -957,22 +957,22 @@ } }; - function handleFrame(dataUrl) { - if (!dataUrl) return; - - frameImage.onload = () => { - if (canvas.width !== frameImage.width || canvas.height !== frameImage.height) { - canvas.width = frameImage.width; - canvas.height = frameImage.height; - // Resolution display removed - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(frameImage, 0, 0); - canvas.classList.add('active'); - }; - - frameImage.src = dataUrl; + async function handleFrame(blob) { + const frame = ++frameCount; + const bitmap = await createImageBitmap(blob); + // Decodes can finish out of order; never draw over a newer frame + if (frame !== frameCount) { + bitmap.close(); + return; + } + if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + canvas.classList.add('active'); placeholder.classList.add('hidden'); }