diff --git a/src/lib/exporter/videoExporter.test.ts b/src/lib/exporter/videoExporter.test.ts index 1b64255a2..bd424548b 100644 --- a/src/lib/exporter/videoExporter.test.ts +++ b/src/lib/exporter/videoExporter.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getSourceCopyFastPathBlockers, isSourceCopyFastPathEligible, type VideoExporterConfig, + waitForEncoderQueueSpace, } from "./videoExporter"; function createConfig(overrides: Partial = {}): VideoExporterConfig { @@ -119,3 +120,110 @@ describe("getSourceCopyFastPathBlockers", () => { ).toContain("output-size 1920x1080 differs from source 1920x1032"); }); }); + +// The original bug measured the timeout from the encoder's last *output* event +// (lastEncoderOutputAt), which went stale while the decoder discarded frames inside +// a trim region. waitForEncoderQueueSpace fixes this by starting the clock fresh on +// each call instead of accepting any such external timestamp — by construction, there +// is no "last output" state to go stale, so that regression can't be reintroduced +// without changing this function's signature. +describe("waitForEncoderQueueSpace", () => { + function fakeClock(start = 0) { + let elapsedMs = start; + return { + now: () => elapsedMs, + sleep: async (ms: number) => { + elapsedMs += ms; + }, + }; + } + + it("resolves immediately when the queue already has space", async () => { + const clock = fakeClock(); + const sleep = vi.fn(clock.sleep); + + await waitForEncoderQueueSpace({ + getQueueSize: () => 0, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).not.toHaveBeenCalled(); + }); + + it("waits for the queue to drain and then resolves", async () => { + const clock = fakeClock(); + let queueSize = 8; + // Queue drains well within the timeout. + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + queueSize = 0; + }); + + await waitForEncoderQueueSpace({ + getQueueSize: () => queueSize, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("throws a hardware-specific error once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow( + "The hardware video encoder stopped responding. Retrying with a safer encoder.", + ); + }); + + it("throws a generic error for the software encoder once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-software", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow("The video encoder stopped responding during export."); + }); + + it("stops waiting without throwing once cancelled", async () => { + const clock = fakeClock(); + let cancelled = false; + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + cancelled = true; + }); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => cancelled, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index a0b063789..8f239656c 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -20,6 +20,37 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; +/** + * Waits for the encoder's queue to drain below maxEncodeQueue before returning. + * + * The stall timer starts fresh on each call (not from the encoder's last output), so a + * long gap before this call — e.g. the decoder discarding frames inside a trim region — + * doesn't get blamed on the encoder once real frames resume. + */ +export async function waitForEncoderQueueSpace(params: { + getQueueSize: () => number; + maxEncodeQueue: number; + isCancelled: () => boolean; + encoderPreference: HardwareAcceleration; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const now = params.now ?? Date.now; + const sleep = params.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const stallWaitStartAt = now(); + while (params.getQueueSize() >= params.maxEncodeQueue && !params.isCancelled()) { + if (now() - stallWaitStartAt > ENCODER_STALL_TIMEOUT_MS) { + throw new Error( + params.encoderPreference === "prefer-hardware" + ? "The hardware video encoder stopped responding. Retrying with a safer encoder." + : "The video encoder stopped responding during export.", + ); + } + await sleep(5); + } +} + export interface VideoExporterConfig extends ExportConfig { videoUrl: string; webcamVideoUrl?: string; @@ -152,7 +183,6 @@ export class VideoExporter { private videoColorSpace: VideoColorSpaceInit | undefined; private muxingPromises: Promise[] = []; private chunkCount = 0; - private lastEncoderOutputAt = 0; private fatalEncoderError: Error | null = null; constructor(config: VideoExporterConfig) { @@ -384,20 +414,16 @@ export class VideoExporter { exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration }); } - while ( - this.encoder && - this.encoder.encodeQueueSize >= maxEncodeQueue && - !this.cancelled - ) { - if (Date.now() - this.lastEncoderOutputAt > ENCODER_STALL_TIMEOUT_MS) { - exportFrame.close(); - throw new Error( - encoderPreference === "prefer-hardware" - ? "The hardware video encoder stopped responding. Retrying with a safer encoder." - : "The video encoder stopped responding during export.", - ); - } - await new Promise((resolve) => setTimeout(resolve, 5)); + try { + await waitForEncoderQueueSpace({ + getQueueSize: () => this.encoder?.encodeQueueSize ?? 0, + maxEncodeQueue, + isCancelled: () => this.cancelled, + encoderPreference, + }); + } catch (error) { + exportFrame.close(); + throw error; } if (this.encoder && this.encoder.state === "configured") { @@ -496,14 +522,11 @@ export class VideoExporter { this.encodeQueue = 0; this.muxingPromises = []; this.chunkCount = 0; - this.lastEncoderOutputAt = Date.now(); this.fatalEncoderError = null; let videoDescription: Uint8Array | undefined; this.encoder = new VideoEncoder({ output: (chunk, meta) => { - this.lastEncoderOutputAt = Date.now(); - if (meta?.decoderConfig?.description && !videoDescription) { const desc = meta.decoderConfig.description; if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) { @@ -648,7 +671,6 @@ export class VideoExporter { this.chunkCount = 0; this.videoDescription = undefined; this.videoColorSpace = undefined; - this.lastEncoderOutputAt = 0; this.fatalEncoderError = null; }