Use Blobs and createImageBitmap for pop-out frames

Frames are encoded with toBlob one at a time, stale decodes are dropped,
and fit and fullscreen use object-fit: contain so they no longer crop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 09:03:29 -08:00
co-authored by Claude Opus 5.5
parent bbbbd3e53a
commit 65a8ca74bc
5 changed files with 93 additions and 41 deletions
+11 -9
View File
@@ -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');
}
/**
+17 -5
View File
@@ -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', () => {
+37 -1
View File
@@ -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();
+9 -7
View File
@@ -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.
+19 -19
View File
@@ -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');
}