From f9a7735aae6f426323ff14acbecbc2d48c764e02 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:37:31 +0200 Subject: [PATCH] Explicitly throw for odd video dimensions for AVC and HEVC (closes #142) --- package.json | 4 +- src/encode.ts | 9 +++ src/media-source.ts | 23 ++++++-- test/browser/encode-dimensions.test.ts | 81 ++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 test/browser/encode-dimensions.test.ts diff --git a/package.json b/package.json index 0ad0e3a..6eb171a 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "watch": "tsx scripts/bundle.ts --watch", "lint": "eslint .", "test-node": "cd test && vitest node --run", - "test-web": "cd test && vitest browser --run --browser", - "test": "npm run test-node && npm run test-web", + "test-browser": "cd test && vitest browser --run --browser", + "test": "npm run test-node && npm run test-browser", "check": "tsc -p src --noEmit && tsc -p packages/mp3-encoder/src --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo", "check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts", "docs:dev": "vitepress dev docs", diff --git a/src/encode.ts b/src/encode.ts index 0eb26da..b77bb52 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -490,6 +490,15 @@ export const canEncodeVideo = async ( return false; } + const hasOddDimension = width % 2 === 1 || height % 2 === 1; + if ( + hasOddDimension + && (codec === 'avc' || codec === 'hevc') + ) { + // Disallow odd dimensions for certain codecs + return false; + } + encoderConfig ??= buildVideoEncoderConfig({ codec, width, diff --git a/src/media-source.ts b/src/media-source.ts index 44a7d42..7df06fb 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -292,7 +292,7 @@ class VideoEncoderWrapper { if (!this.encoderInitialized) { if (!this.ensureEncoderPromise) { - void this.ensureEncoder(videoSample); + this.ensureEncoder(videoSample); } // No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have @@ -363,13 +363,13 @@ class VideoEncoderWrapper { } } - private async ensureEncoder(videoSample: VideoSample) { + private ensureEncoder(videoSample: VideoSample) { if (this.encoder) { return; } const encoderError = new Error(); - return this.ensureEncoderPromise = (async () => { + this.ensureEncoderPromise = (async () => { const encoderConfig = buildVideoEncoderConfig({ width: videoSample.codedWidth, height: videoSample.codedHeight, @@ -409,6 +409,19 @@ class VideoEncoderWrapper { throw new Error('VideoEncoder is not supported by this browser.'); } + const hasOddDimension = encoderConfig.width % 2 === 1 || encoderConfig.height % 2 === 1; + if ( + hasOddDimension + && (this.encodingConfig.codec === 'avc' || this.encodingConfig.codec === 'hevc') + ) { + // Throw a special error for this case as it gets hit often + throw new Error( + `The dimensions ${encoderConfig.width}x${encoderConfig.height} are not supported for codec` + + ` '${this.encodingConfig.codec}'; both width and height must be even numbers. Make sure to` + + ` round your dimensions to the nearest even number.`, + ); + } + const support = await VideoEncoder.isConfigSupported(encoderConfig); if (!support.supported) { throw new Error( @@ -873,7 +886,7 @@ class AudioEncoderWrapper { if (!this.encoderInitialized) { if (!this.ensureEncoderPromise) { - void this.ensureEncoder(audioSample); + this.ensureEncoder(audioSample); } // No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have @@ -1010,7 +1023,7 @@ class AudioEncoderWrapper { } const encoderError = new Error(); - return this.ensureEncoderPromise = (async () => { + this.ensureEncoderPromise = (async () => { const { numberOfChannels, sampleRate } = audioSample; const encoderConfig = buildAudioEncoderConfig({ diff --git a/test/browser/encode-dimensions.test.ts b/test/browser/encode-dimensions.test.ts new file mode 100644 index 0000000..2a7320c --- /dev/null +++ b/test/browser/encode-dimensions.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'vitest'; +import { Output } from '../../src/output.js'; +import { Mp4OutputFormat } from '../../src/output-format.js'; +import { NullTarget } from '../../src/target.js'; +import { VideoSampleSource } from '../../src/media-source.js'; +import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js'; +import { VideoSample } from '../../src/sample.js'; + +test('Odd video dimensions fail for AVC', async () => { + const output = new Output({ + format: new Mp4OutputFormat(), + target: new NullTarget(), + }); + + const source = new VideoSampleSource({ + codec: 'avc', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(source); + + await output.start(); + + const canvas = document.createElement('canvas'); + canvas.width = 1281; + canvas.height = 720; + const sample = new VideoSample(canvas, { timestamp: 0 }); + + await expect(source.add(sample)).rejects.toThrow('even number'); // The error message is explicit +}); + +test('Odd video dimensions fail for HEVC', async () => { + const output = new Output({ + format: new Mp4OutputFormat(), + target: new NullTarget(), + }); + + const source = new VideoSampleSource({ + codec: 'hevc', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(source); + + await output.start(); + + const canvas = document.createElement('canvas'); + canvas.width = 1281; + canvas.height = 720; + const sample = new VideoSample(canvas, { timestamp: 0 }); + + await expect(source.add(sample)).rejects.toThrow('even number'); +}); + +test('Odd video dimensions pass for VP9', async () => { + const output = new Output({ + format: new Mp4OutputFormat(), + target: new NullTarget(), + }); + + const source = new VideoSampleSource({ + codec: 'vp9', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(source); + + await output.start(); + + const canvas = document.createElement('canvas'); + canvas.width = 1281; + canvas.height = 720; + const sample = new VideoSample(canvas, { timestamp: 0 }); + + await source.add(sample); +}); + +test('Odd video dimensions encodability checks', async () => { + expect(await canEncodeVideo('avc', { width: 1920, height: 1081 })).toBe(false); + expect(await canEncodeVideo('hevc', { width: 1920, height: 1081 })).toBe(false); + expect(await canEncodeVideo('vp8', { width: 1920, height: 1081 })).toBe(true); + expect(await canEncodeVideo('vp9', { width: 1920, height: 1081 })).toBe(true); + expect(await canEncodeVideo('av1', { width: 1920, height: 1081 })).toBe(true); +});