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:
co-authored by
Claude Opus 5.5
parent
bbbbd3e53a
commit
65a8ca74bc
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user