diff --git a/frontend/src/shared/encodeShotWhenIdle.test.ts b/frontend/src/shared/encodeShotWhenIdle.test.ts index 3b1d39f7..7f055c65 100644 --- a/frontend/src/shared/encodeShotWhenIdle.test.ts +++ b/frontend/src/shared/encodeShotWhenIdle.test.ts @@ -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`); +}); diff --git a/frontend/src/shared/encodeShotWhenIdle.ts b/frontend/src/shared/encodeShotWhenIdle.ts index dd2d8179..42d35747 100644 --- a/frontend/src/shared/encodeShotWhenIdle.ts +++ b/frontend/src/shared/encodeShotWhenIdle.ts @@ -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); }