diff --git a/src/encode.ts b/src/encode.ts index 1a556e3..0cc2f52 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -112,6 +112,11 @@ export type VideoEncodingConfig = { process?: (sample: VideoSample) => MaybePromise< CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null >; + /** + * Forces every video frame through the transformation step even if no transformation properties are defined. + * This can be used, for example, to bake rotation into the encoded video frames. + */ + force?: boolean; }; /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ @@ -202,6 +207,9 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { ) { throw new TypeError('config.transform.frameRate, when provided, must be a finite positive number.'); } + if (config.transform.force !== undefined && typeof config.transform.force !== 'boolean') { + throw new TypeError('config.transform.force, when provided, must be a boolean.'); + } } if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); diff --git a/src/media-source.ts b/src/media-source.ts index 56d938a..9ad8440 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -309,7 +309,8 @@ class VideoEncoderWrapper { const hasTransformConfig = config.transform?.width !== undefined || config.transform?.height !== undefined || config.transform?.rotate !== undefined - || config.transform?.crop !== undefined; + || config.transform?.crop !== undefined + || config.transform?.force === true; const needsTransform = hasTransformConfig || (isSizeChange && sizeChangeBehavior !== 'passThrough'); if (needsTransform) { diff --git a/test/browser/media-sources.test.ts b/test/browser/media-sources.test.ts index 29e95aa..2f6aa79 100644 --- a/test/browser/media-sources.test.ts +++ b/test/browser/media-sources.test.ts @@ -9,7 +9,7 @@ import { Input } from '../../src/input.js'; import { ALL_FORMATS } from '../../src/input-format.js'; import { BufferSource } from '../../src/source.js'; import { VideoSampleSink } from '../../src/media-sink.js'; -import { assert } from '../../src/misc.js'; +import { assert, Rotation } from '../../src/misc.js'; import { InputVideoTrack } from '../../src/input-track.js'; test('VideoSampleSource.close() should be idempotent after finalize()', async () => { @@ -169,6 +169,36 @@ test('Changing dimensions with passThrough, width and height set', async () => { expect(samples[1]).toMatchObject({ codedWidth: 50, codedHeight: 80 }); }); +test('Encoding rotated video frames', async () => { + const buffer = await encodeFrames( + { codec: 'vp8', bitrate: QUALITY_MEDIUM }, + [{ width: 200, height: 100, rotation: 90 }, { width: 200, height: 100, rotation: 90 }], + ); + + const samples = await readBackSamples(buffer); + + // They were encoded with no regard for the rotation metadata + for (const sample of samples) { + expect(sample.codedWidth).toBe(200); + expect(sample.codedHeight).toBe(100); + } +}); + +test('Encoding rotated video frames with forced transform', async () => { + const buffer = await encodeFrames( + { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { force: true } }, + [{ width: 200, height: 100, rotation: 90 }, { width: 200, height: 100, rotation: 90 }], + ); + + const samples = await readBackSamples(buffer); + + // The rotation has been baked into the samples + for (const sample of samples) { + expect(sample.codedWidth).toBe(100); + expect(sample.codedHeight).toBe(200); + } +}); + test('transform.process identity function', async () => { const buffer = await encodeFrames( { codec: 'vp8', bitrate: QUALITY_MEDIUM, transform: { process: sample => sample } }, @@ -460,7 +490,13 @@ const makeCanvas = (width: number, height: number) => { const encodeFrames = async ( encodingConfig: ConstructorParameters[0], - frames: { width: number; height: number; timestamp?: number; duration?: number }[], + frames: { + width: number; + height: number; + timestamp?: number; + duration?: number; + rotation?: Rotation; + }[], ) => { const output = new Output({ format: new Mp4OutputFormat(), @@ -477,6 +513,7 @@ const encodeFrames = async ( const sample = new VideoSample(canvas, { timestamp: f.timestamp ?? i / 30, duration: f.duration ?? 1 / 30, + rotation: f.rotation, }); await videoSource.add(sample); sample.close();