Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion src/lib/exporter/videoExporter.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): VideoExporterConfig {
Expand Down Expand Up @@ -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();
});
});
60 changes: 41 additions & 19 deletions src/lib/exporter/videoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}): Promise<void> {
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;
Expand Down Expand Up @@ -152,7 +183,6 @@ export class VideoExporter {
private videoColorSpace: VideoColorSpaceInit | undefined;
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
private lastEncoderOutputAt = 0;
private fatalEncoderError: Error | null = null;

constructor(config: VideoExporterConfig) {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -648,7 +671,6 @@ export class VideoExporter {
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
this.lastEncoderOutputAt = 0;
this.fatalEncoderError = null;
}

Expand Down
Loading