[eric] canvas: deferred shot encodes drain one per idle slot with a breath between, never as one frameless run

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-02 19:47:59 -07:00
co-authored by Claude Fable 5.1
parent 84772545a9
commit b645d4e03f
2 changed files with 55 additions and 20 deletions
@@ -57,3 +57,15 @@ test('an encoder that throws reports an empty string rather than killing the cal
await wait(40);
assert.equal(out, '');
});
test('shots that come due together encode one at a time with a breath between, never as one run', async () => {
const ticks: number[] = [];
const t0 = Date.now();
for (let i = 0; i < 4; i += 1) {
const log: string[] = [];
encodeShotWhenIdle(fakeImage(300 + i, log), 640, () => { ticks.push(Date.now() - t0); });
}
await wait(400);
assert.equal(ticks.length, 4);
for (let i = 1; i < ticks.length; i += 1) assert.ok(ticks[i] - ticks[i - 1] >= 40, `encodes ${i - 1} and ${i} ran ${ticks[i] - ticks[i - 1]} ms apart`);
});
+43 -20
View File
@@ -6,31 +6,54 @@ import { perfBaseline } from '@/shared/perfBaseline';
const MAX_GESTURE_WAITS = 6;
const GESTURE_WAIT_MS = 400;
const IDLE_TIMEOUT_MS = 1500;
// One encode per idle slot with a breath between: a board full of shots coming due together used to land as one frameless run of encodes.
const BETWEEN_ENCODES_MS = 50;
// PNG-encoding a full-page capture blocks the main thread for ~180 ms; shrinking first and encoding in an idle slot keeps it off every gesture frame.
interface PendingShot { image: ElectronNativeImage; maxWidth: number; done: (dataUrl: string) => void; waits: number }
const queue: PendingShot[] = [];
let pumping = false;
function encodeNow(job: PendingShot): void {
let dataUrl = '';
try {
const sized = job.image.getSize().width > job.maxWidth ? job.image.resize({ width: job.maxWidth, quality: 'good' }) : job.image;
dataUrl = sized.toDataURL();
} catch {
dataUrl = '';
}
job.done(dataUrl);
}
function whenIdle(fn: () => void): void {
if (typeof requestIdleCallback === 'function') requestIdleCallback(() => fn(), { timeout: IDLE_TIMEOUT_MS });
else window.setTimeout(fn, 0);
}
function pump(): void {
const job = queue[0];
if (!job) { pumping = false; return; }
if (interactionActive() && job.waits < MAX_GESTURE_WAITS) {
job.waits += 1;
window.setTimeout(pump, GESTURE_WAIT_MS);
return;
}
queue.shift();
encodeNow(job);
if (queue.length === 0) { pumping = false; return; }
window.setTimeout(() => whenIdle(pump), BETWEEN_ENCODES_MS);
}
// PNG-encoding a full-page capture blocks the main thread for ~180 ms; shrinking first and encoding one per idle slot keeps it off every gesture frame.
export function encodeShotWhenIdle(
image: ElectronNativeImage,
maxWidth: number,
done: (dataUrl: string) => void,
): void {
let waits = 0;
const run = (): void => {
if (!perfBaseline() && interactionActive() && waits < MAX_GESTURE_WAITS) {
waits += 1;
window.setTimeout(run, GESTURE_WAIT_MS);
return;
}
let dataUrl = '';
try {
const sized = image.getSize().width > maxWidth ? image.resize({ width: maxWidth, quality: 'good' }) : image;
dataUrl = sized.toDataURL();
} catch {
dataUrl = '';
}
done(dataUrl);
};
const job: PendingShot = { image, maxWidth, done, waits: 0 };
// The A/B seam keeps the old shape: encode right here, on whatever frame the capture landed in.
if (perfBaseline()) run();
else if (typeof requestIdleCallback === 'function') requestIdleCallback(() => run(), { timeout: IDLE_TIMEOUT_MS });
else window.setTimeout(run, 0);
if (perfBaseline()) { encodeNow(job); return; }
queue.push(job);
if (pumping) return;
pumping = true;
whenIdle(pump);
}