Fix async race condition in transparency code, reduce HEVC conversion duration

This commit is contained in:
Vanilagy
2026-05-12 00:24:03 +02:00
parent 4e5f894d7a
commit 5599e52a1a
4 changed files with 68 additions and 62 deletions
+3 -3
View File
@@ -1036,8 +1036,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
if (!this.alphaDecoder) { if (!this.alphaDecoder) {
const alphaHandler = (frame: VideoFrame) => { const alphaHandler = (frame: VideoFrame) => {
this.frameHandlerSerializer.call(async () => { this.frameHandlerSerializer.call(async () => {
this.alphaDecoderQueueSize--;
if (this.colorQueue.length > 0) { if (this.colorQueue.length > 0) {
const colorFrame = this.colorQueue.shift(); const colorFrame = this.colorQueue.shift();
assert(colorFrame !== undefined); assert(colorFrame !== undefined);
@@ -1064,6 +1062,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.alphaQueue.push(null); this.alphaQueue.push(null);
} }
} }
this.alphaDecoderQueueSize--;
}).catch((error: Error) => this.onError(error)); }).catch((error: Error) => this.onError(error));
}; };
@@ -1266,7 +1266,7 @@ let mergerGpuUnavailable = false;
/** Utility class that merges together color and alpha information using simple WebGL 2 shaders. */ /** Utility class that merges together color and alpha information using simple WebGL 2 shaders. */
export class ColorAlphaMerger { export class ColorAlphaMerger {
static forceCpu = false; static forceCpu = true;
canvas: OffscreenCanvas | HTMLCanvasElement | null = null; canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private gl: WebGL2RenderingContext | null = null; private gl: WebGL2RenderingContext | null = null;
+1 -1
View File
@@ -877,7 +877,7 @@ let splitterGpuUnavailable = false;
/** Utility class for splitting a composite frame into separate color and alpha components. */ /** Utility class for splitting a composite frame into separate color and alpha components. */
export class ColorAlphaSplitter { export class ColorAlphaSplitter {
static forceCpu = false; static forceCpu = true;
canvas: OffscreenCanvas | HTMLCanvasElement | null = null; canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+59 -56
View File
@@ -202,73 +202,76 @@ test('Can encode transparent video, forced CPU path', async () => {
}); });
test('Can encode video with alternating transparency', async () => { test('Can encode video with alternating transparency', async () => {
const output = new Output({ // This test is already brutal when it comes to finding async race conditions, so run it thrice to be MORE brutal
format: new WebMOutputFormat(), for (let j = 0; j < 3; j++) {
target: new BufferTarget(), const output = new Output({
}); format: new WebMOutputFormat(),
target: new BufferTarget(),
const canvas1 = new OffscreenCanvas(640, 480);
const context1 = canvas1.getContext('2d', { alpha: true })!;
context1.fillStyle = '#ff000080';
context1.fillRect(0, 0, canvas1.width, canvas1.height);
const canvas2 = new OffscreenCanvas(640, 480);
const context2 = canvas2.getContext('2d', { alpha: false })!;
context2.fillStyle = '#0000ff';
context2.fillRect(0, 0, canvas2.width, canvas2.height);
const source = new VideoSampleSource({
codec: 'vp9',
bitrate: QUALITY_HIGH,
alpha: 'keep',
});
output.addVideoTrack(source);
await output.start();
for (let i = 0; i < 64; i++) {
using sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
format: i % 2 ? 'RGBX' : 'RGBA',
codedWidth: 640,
codedHeight: 480,
timestamp: i,
duration: 1,
}); });
await source.add(sample);
}
await output.finalize(); const canvas1 = new OffscreenCanvas(640, 480);
const context1 = canvas1.getContext('2d', { alpha: true })!;
context1.fillStyle = '#ff000080';
context1.fillRect(0, 0, canvas1.width, canvas1.height);
using input = new Input({ const canvas2 = new OffscreenCanvas(640, 480);
source: new BufferSource(output.target.buffer!), const context2 = canvas2.getContext('2d', { alpha: false })!;
formats: ALL_FORMATS, context2.fillStyle = '#0000ff';
}); context2.fillRect(0, 0, canvas2.width, canvas2.height);
const videoTrack = (await input.getPrimaryVideoTrack())!; const source = new VideoSampleSource({
const packetSink = new EncodedPacketSink(videoTrack); codec: 'vp9',
bitrate: QUALITY_HIGH,
alpha: 'keep',
});
output.addVideoTrack(source);
let i = 0; await output.start();
for await (const packet of packetSink.packets()) {
if (i % 2) { for (let i = 0; i < 64; i++) {
expect(packet.sideData.alpha).toBeUndefined(); using sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
} else { format: i % 2 ? 'RGBX' : 'RGBA',
expect(packet.sideData.alpha).toBeDefined(); codedWidth: 640,
codedHeight: 480,
timestamp: i,
duration: 1,
});
await source.add(sample);
} }
i++; await output.finalize();
}
const sampleSink = new VideoSampleSink(videoTrack); using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
i = 0; const videoTrack = (await input.getPrimaryVideoTrack())!;
for await (using sample of sampleSink.samples()) { const packetSink = new EncodedPacketSink(videoTrack);
if (i % 2) {
expect(sample.format).not.toContain('A'); let i = 0;
} else { for await (const packet of packetSink.packets()) {
expect(sample.format).toContain('A'); if (i % 2) {
expect(packet.sideData.alpha).toBeUndefined();
} else {
expect(packet.sideData.alpha).toBeDefined();
}
i++;
} }
i++; const sampleSink = new VideoSampleSink(videoTrack);
i = 0;
for await (using sample of sampleSink.samples()) {
if (i % 2) {
expect(sample.format).not.toContain('A');
} else {
expect(sample.format).toContain('A');
}
i++;
}
} }
}); });
+5 -2
View File
@@ -593,7 +593,7 @@ describe('Video', async () => {
}); });
test('HEVC conversion roundtrip', { timeout: 20_000 }, async () => { test('HEVC conversion roundtrip', { timeout: 20_000 }, async () => {
await conversionRoundtrip('hevc'); await conversionRoundtrip('hevc', 1); // GitHub is extremely slow on this one
}); });
test('VP8 conversion roundtrip', { timeout: 20_000 }, async () => { test('VP8 conversion roundtrip', { timeout: 20_000 }, async () => {
@@ -608,7 +608,7 @@ describe('Video', async () => {
await conversionRoundtrip('av1'); await conversionRoundtrip('av1');
}); });
const conversionRoundtrip = async (codec: VideoCodec) => { const conversionRoundtrip = async (codec: VideoCodec, duration?: number) => {
using input = new Input({ using input = new Input({
source: new FilePathSource('./test/public/video.mp4'), source: new FilePathSource('./test/public/video.mp4'),
formats: ALL_FORMATS, formats: ALL_FORMATS,
@@ -640,6 +640,9 @@ describe('Video', async () => {
}, audio: { }, audio: {
discard: true, discard: true,
}, },
trim: {
end: duration,
},
}); });
await conversion.execute(); await conversion.execute();