diff --git a/dev/convert.html b/dev/convert.html index 2bf9b72..f88a0c8 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -16,7 +16,7 @@ const source = new Metamuxer.BlobSource(file); const target = new Metamuxer.BufferTarget(); - const outputFormat = new Metamuxer.Mp4OutputFormat() + const outputFormat = new Metamuxer.WebMOutputFormat() const abortController = new AbortController(); const button = document.createElement('button'); @@ -62,7 +62,16 @@ }, */ video: { - //forceReencode: true + //forceReencode: true, + //rotate: 90 + //width: 720 ?? 2160, + //height: 1280 ?? 3840, + fit: 'contain', + rotate: 90, + width: 512, + height: 512, + //width: 200, + //height: 100, }, trim: { start: 0, @@ -77,7 +86,14 @@ console.log(res) console.log("Done", target.buffer); - download(new Blob([target.buffer]), 'converted' + outputFormat.getFileExtension()); + + const video = document.createElement('video'); + video.src = URL.createObjectURL(new Blob([target.buffer])); + video.controls = true; + document.body.append(video); + video.play(); + + //download(new Blob([target.buffer]), 'converted' + outputFormat.fileExtension); function download(blob, filename) { const url = URL.createObjectURL(blob); diff --git a/dev/mux.html b/dev/mux.html index cbc9fe9..f950c26 100644 --- a/dev/mux.html +++ b/dev/mux.html @@ -41,6 +41,7 @@ let format = new Metamuxer.MkvOutputFormat({ streamable: false }); format = new Metamuxer.Mp4OutputFormat({ fastStart: 'fragmented' }); // new Metamuxer.MkvOutputFormat();// new Metamuxer.Mp4OutputFormat({ fastStart: false }); format = new Metamuxer.OggOutputFormat(); + format = new Metamuxer.Mp4OutputFormat(); let target = new Metamuxer.BufferTarget(); /* @@ -94,7 +95,7 @@ }); let subtitleSource = new Metamuxer.TextSubtitleSource('webvtt'); - //output.addVideoTrack(videoSource, { languageCode: 'eng' }); + output.addVideoTrack(videoSource, { languageCode: 'eng' }); output.addAudioTrack(audioSource); //output.addSubtitleTrack(subtitleSource); @@ -162,7 +163,7 @@ Testing... <00:17.350>One... <00:18.125>Two... context.fillStyle = ['red', 'green', 'blue', 'yellow'][i % 4]; context.fillRect(canvas.width * Math.random(), canvas.height * Math.random(), canvas.width * Math.random(), canvas.height * Math.random()); - //await videoSource.add(i / 10, 1 / 10); + await videoSource.add(i / 10, 1 / 10); } let audioContext = new AudioContext(); diff --git a/dev/player.html b/dev/player.html index 6f366e2..f10bd9d 100644 --- a/dev/player.html +++ b/dev/player.html @@ -107,7 +107,7 @@ durationElement.textContent = formatSeconds(totalDuration); playerDiv.style.display = 'flex'; -const videoSink = videoTrack && new Metamuxer.CanvasSink(videoTrack); +const videoSink = videoTrack && new Metamuxer.CanvasSink(videoTrack, { poolSize: 2 }); const audioSink = audioTrack && new Metamuxer.AudioBufferSink(audioTrack); let startTime = null; diff --git a/src/conversion.ts b/src/conversion.ts index ed72dec..93bdcc6 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -23,14 +23,13 @@ import { AudioDataSource, AudioEncodingConfig, AudioSource, - CanvasSource, EncodedVideoPacketSource, EncodedAudioPacketSource, VideoEncodingConfig, VideoFrameSource, VideoSource, } from './media-source'; -import { assert, clamp, promiseWithResolvers, Rotation, setVideoFrameTiming } from './misc'; +import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation, setVideoFrameTiming } from './misc'; import { Output, TrackType } from './output'; /** @public */ @@ -329,22 +328,26 @@ class Conversion { let videoSource: VideoSource; - const originalWidth = track.codedWidth; - const originalHeight = track.codedHeight; - const originalAspectRatio = originalWidth / originalHeight; + const totalRotation = normalizeRotation(track.rotation + (this.options.video?.rotate ?? 0)); + const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata; + + const [originalWidth, originalHeight] = totalRotation % 180 === 0 + ? [track.codedWidth, track.codedHeight] + : [track.codedHeight, track.codedWidth]; let width = originalWidth; let height = originalHeight; + const aspectRatio = width / height; // A lot of video encoders require that the dimensions be multiples of 2 const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2; if (this.options.video?.width !== undefined && this.options.video.height === undefined) { width = ceilToMultipleOfTwo(this.options.video.width); - height = ceilToMultipleOfTwo(Math.round(width / originalAspectRatio)); + height = ceilToMultipleOfTwo(Math.round(width / aspectRatio)); } else if (this.options.video?.width === undefined && this.options.video?.height !== undefined) { height = ceilToMultipleOfTwo(this.options.video.height); - width = ceilToMultipleOfTwo(Math.round(height * originalAspectRatio)); + width = ceilToMultipleOfTwo(Math.round(height * aspectRatio)); } else if (this.options.video?.width !== undefined && this.options.video.height !== undefined) { width = ceilToMultipleOfTwo(this.options.video.width); height = ceilToMultipleOfTwo(this.options.video.height); @@ -352,13 +355,15 @@ class Conversion { const firstTimestamp = await track.getFirstTimestamp(); const needsReencode = !!this.options.video?.forceReencode || this.startTimestamp > 0 || firstTimestamp < 0; - const needsResize = width !== originalWidth || height !== originalHeight; + const needsRerender = width !== originalWidth + || height !== originalHeight + || (totalRotation !== 0 && !outputSupportsRotation); let videoCodecs = this.output.format.getSupportedVideoCodecs(); if ( !needsReencode && !this.options.video?.bitrate - && !needsResize + && !needsRerender && videoCodecs.includes(sourceCodec) && (!this.options.video?.codec || this.options.video?.codec === sourceCodec) ) { @@ -406,10 +411,7 @@ class Conversion { videoCodecs = videoCodecs.filter(codec => codec === this.options.video?.codec); } - const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { - width: needsResize ? width : track.codedWidth, - height: needsResize ? height : track.codedHeight, - }); + const encodableCodecs = await getEncodableVideoCodecs(videoCodecs, { width, height }); if (encodableCodecs.length === 0) { this.result.discardedTracks.push({ track, @@ -424,22 +426,20 @@ class Conversion { onEncodedPacket: sample => this.reportProgress(track.id, sample.timestamp + sample.duration), }; - if (needsResize) { - // For resizing, we draw the frame onto a canvas and then encode the canvas - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d', { - alpha: false, - })!; - - const source = new CanvasSource(canvas, encodingConfig); + if (needsRerender) { + const source = new VideoFrameSource(encodingConfig); videoSource = source; this.trackPromises.push((async () => { await this.started; - const sink = new CanvasSink(track); + const sink = new CanvasSink(track, { + width, + height, + fit: this.options.video?.fit ?? 'fill', + rotation: totalRotation, // Bake the rotation into the output + poolSize: 1, + }); const iterator = sink.canvases(this.startTimestamp, this.endTimestamp); for await (const { canvas, timestamp, duration } of iterator) { @@ -447,33 +447,15 @@ class Conversion { await this.synchronizer.wait(timestamp); } - if (!this.options.video?.fit || this.options.video.fit === 'fill') { - context.drawImage(canvas, 0, 0, width, height); - } else if (this.options.video.fit === 'contain') { - const scale = Math.min(width / canvas.width, height / canvas.height); - const newWidth = canvas.width * scale; - const newHeight = canvas.height * scale; - const dx = (width - newWidth) / 2; - const dy = (height - newHeight) / 2; - context.drawImage(canvas, 0, 0, canvas.width, canvas.height, dx, dy, newWidth, newHeight); - } else if (this.options.video.fit === 'cover') { - const scale = Math.max(width / canvas.width, height / canvas.height); - const newWidth = canvas.width * scale; - const newHeight = canvas.height * scale; - const dx = (width - newWidth) / 2; - const dy = (height - newHeight) / 2; - context.drawImage(canvas, 0, 0, canvas.width, canvas.height, dx, dy, newWidth, newHeight); - } - if (this.options.abortSignal?.aborted) { return; } - await source.add(Math.max(timestamp - this.startTimestamp, 0), duration); + await source.add(new VideoFrame(canvas, { + timestamp: 1e6 * Math.max(timestamp - this.startTimestamp, 0), + duration: 1e6 * duration, + })); } - - await source.close(); - this.synchronizer.closeTrack(track.id); })()); } else { const source = new VideoFrameSource(encodingConfig); @@ -507,12 +489,9 @@ class Conversion { } } - // Rotation metadata is reset if we do resizing - const baseRotation = needsResize ? 0 : track.rotation; - this.output.addVideoTrack(videoSource, { languageCode: track.languageCode, - rotation: (baseRotation + (this.options.video?.rotate ?? 0)) % 360 as Rotation, + rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output }); this.addedCounts.video++; this.totalTrackCount++; diff --git a/src/index.ts b/src/index.ts index 21998b9..fe7098b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,7 @@ export { getEncodableSubtitleCodecs, } from './codec'; export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target'; -export { Rotation, TransformationMatrix, AnyIterable, setVideoFrameTiming } from './misc'; +export { Rotation, AnyIterable, setVideoFrameTiming } from './misc'; export { Source, BufferSource, StreamSource, StreamSourceOptions, BlobSource, UrlSource } from './source'; export { InputFormat, @@ -101,6 +101,7 @@ export { BaseMediaFrameSink, VideoFrameSink, WrappedVideoFrame, + CanvasSinkOptions, CanvasSink, WrappedCanvas, AudioDataSink, diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index e532f9b..c8936fa 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -9,8 +9,6 @@ import { TRANSFER_CHARACTERISTICS_MAP, MATRIX_COEFFICIENTS_MAP, colorSpaceIsComplete, - IDENTITY_MATRIX, - rotationMatrix, UNDETERMINED_LANGUAGE, } from '../misc'; import { @@ -220,6 +218,20 @@ const lastPresentedSample = (samples: Sample[]) => { return result; }; +const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => { + const theta = rotationInDegrees * (Math.PI / 180); + const cosTheta = Math.round(Math.cos(theta)); + const sinTheta = Math.round(Math.sin(theta)); + + // Matrices are post-multiplied in ISOBMFF, meaning this is the transpose of your typical rotation matrix + return [ + cosTheta, sinTheta, 0, + -sinTheta, cosTheta, 0, + 0, 0, 1, + ]; +}; +const IDENTITY_MATRIX = rotationMatrix(0); + const matrixToBytes = (matrix: TransformationMatrix) => { return [ fixed_16_16(matrix[0]), fixed_16_16(matrix[1]), fixed_2_30(matrix[2]), @@ -383,7 +395,7 @@ export const tkhd = ( let matrix: TransformationMatrix; if (trackData.type === 'video') { const rotation = trackData.track.metadata.rotation; - matrix = rotation === undefined || typeof rotation === 'number' ? rotationMatrix(rotation ?? 0) : rotation; + matrix = rotationMatrix(rotation ?? 0); } else { matrix = IDENTITY_MATRIX; } diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 0ed210e..26a6d16 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -35,10 +35,10 @@ import { findLastIndex, UNDETERMINED_LANGUAGE, TransformationMatrix, - extractRotationFromMatrix, roundToPrecision, isIso639Dash2LanguageCode, roundToMultiple, + normalizeRotation, } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { Reader } from '../reader'; @@ -656,11 +656,10 @@ export class IsobmffDemuxer extends Demuxer { this.metadataReader.readFixed_2_30(), ]; - const rotation = (roundToMultiple(extractRotationFromMatrix(matrix), 90) + 360) % 360 as Rotation; + const rotation = normalizeRotation(roundToMultiple(extractRotationFromMatrix(matrix), 90)); assert(rotation === 0 || rotation === 90 || rotation === 180 || rotation === 270); - // Flip clockwise to counter-clockwise - track.rotation = (-rotation + 360) % 360 as Rotation; + track.rotation = rotation; }; break; case 'elst': { @@ -2540,3 +2539,16 @@ const offsetFragmentTrackDataByTimestamp = (trackData: FragmentTrackData, timest entry.presentationTimestamp += timestamp; } }; + +/** Extracts the rotation component from a transformation matrix, in degrees. */ +const extractRotationFromMatrix = (matrix: TransformationMatrix) => { + const [m11, , , m21] = matrix; + + const scaleX = Math.hypot(m11, m21); + + const cosTheta = m11 / scaleX; + const sinTheta = m21 / scaleX; + + // Invert the rotation beacuse matrices are post-multiplied in ISOBMFF + return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI); +}; diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index abf2f1b..908e366 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -29,6 +29,7 @@ import { isIso639Dash2LanguageCode, last, MATRIX_COEFFICIENTS_MAP_INVERSE, + normalizeRotation, Rotation, roundToPrecision, TRANSFER_CHARACTERISTICS_MAP_INVERSE, @@ -820,9 +821,13 @@ export class MatroskaDemuxer extends Demuxer { case EBMLId.ProjectionPoseRoll: { if (this.currentTrack?.info?.type !== 'video') break; - const rotation = (reader.readFloat(size) + 360) % 360; - if ([0, 90, 180, 270].includes(rotation)) { - this.currentTrack.info.rotation = rotation as Rotation; + const rotation = reader.readFloat(size); + const flippedRotation = -rotation; // Convert clockwise to counter-clockwise + + try { + this.currentTrack.info.rotation = normalizeRotation(flippedRotation); + } catch { + // It wasn't a valid rotation } }; break; diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 9b1ae70..f6d9a08 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -5,6 +5,7 @@ import { UNDETERMINED_LANGUAGE, assert, colorSpaceIsComplete, + normalizeRotation, readBits, textEncoder, toUint8Array, @@ -272,6 +273,9 @@ export class MatroskaMuxer extends Muxer { : null), ]; + // Convert from clockwise to counter-clockwise + const flippedRotation = rotation ? normalizeRotation(-rotation) : 0; + const colorSpace = trackData.info.decoderConfig.colorSpace; const videoElement: EBMLElement = { id: EBMLId.Video, data: [ { id: EBMLId.PixelWidth, data: trackData.info.width }, @@ -299,7 +303,7 @@ export class MatroskaMuxer extends Muxer { ], } : null), - (typeof rotation === 'number' && rotation !== 0 + (flippedRotation ? { id: EBMLId.Projection, data: [ @@ -309,7 +313,7 @@ export class MatroskaMuxer extends Muxer { }, { id: EBMLId.ProjectionPoseRoll, - data: new EBMLFloat32((rotation + 180) % 360 - 180), // [0, 270] -> [-180, 90] + data: new EBMLFloat32((flippedRotation + 180) % 360 - 180), // [0, 270] -> [-180, 90] }, ], } diff --git a/src/media-sink.ts b/src/media-sink.ts index 7fce553..f8ff8e5 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -10,6 +10,7 @@ import { last, mapAsyncGenerator, promiseWithResolvers, + Rotation, toAsyncIterator, toDataView, validateAnyIterable, @@ -790,54 +791,152 @@ export type WrappedCanvas = { duration: number; }; +/** @public */ +export type CanvasSinkOptions = { + width?: number; + height?: number; + fit?: 'fill' | 'contain' | 'cover'; + rotation?: Rotation; + poolSize?: number; +}; + /** @public */ export class CanvasSink { /** @internal */ _videoTrack: InputVideoTrack; /** @internal */ - _dimensions?: { width: number; height: number }; + _width: number; + /** @internal */ + _height: number; + /** @internal */ + _fit: 'fill' | 'contain' | 'cover'; + /** @internal */ + _rotation: Rotation; /** @internal */ _videoFrameSink: VideoFrameSink; + /** @internal */ + _canvasPool: (HTMLCanvasElement | null)[]; + /** @internal */ + _nextCanvasIndex = 0; - constructor(videoTrack: InputVideoTrack, dimensions?: { width: number; height: number }) { + constructor(videoTrack: InputVideoTrack, options: CanvasSinkOptions = {}) { if (!(videoTrack instanceof InputVideoTrack)) { throw new TypeError('videoTrack must be an InputVideoTrack.'); } - if (dimensions && typeof dimensions !== 'object') { - throw new TypeError('dimensions, when defined, must be an object.'); + if (options && typeof options !== 'object') { + throw new TypeError('options must be an object.'); } - if (dimensions && (!Number.isInteger(dimensions.width) || dimensions.width <= 0)) { - throw new TypeError('dimensions.width must be a positive integer.'); + if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) { + throw new TypeError('options.width, when defined, must be a positive integer.'); } - if (dimensions && (!Number.isInteger(dimensions.height) || dimensions.height <= 0)) { - throw new TypeError('dimensions.height must be a positive integer.'); + if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) { + throw new TypeError('options.height, when defined, must be a positive integer.'); + } + if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) { + throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".'); + } + if ( + options.width !== undefined + && options.height !== undefined + && options.fit === undefined + ) { + throw new TypeError( + 'When both options.width and options.height are provided, options.fit must also be provided.', + ); + } + if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) { + throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.'); + } + if ( + options.poolSize !== undefined + && (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0) + ) { + throw new TypeError('poolSize must be a non-negative integer.'); + } + + const rotation = options.rotation ?? videoTrack.rotation; + let [width, height] = rotation % 180 === 0 + ? [videoTrack.codedWidth, videoTrack.codedHeight] + : [videoTrack.codedHeight, videoTrack.codedWidth]; + const originalAspectRatio = width / height; + + // If width and height aren't defined together, deduce the missing value using the aspect ratio + if (options.width !== undefined && options.height === undefined) { + width = options.width; + height = Math.round(width / originalAspectRatio); + } else if (options.width === undefined && options.height !== undefined) { + height = options.height; + width = Math.round(height * originalAspectRatio); + } else if (options.width !== undefined && options.height !== undefined) { + width = options.width; + height = options.height; } this._videoTrack = videoTrack; - this._dimensions = dimensions; + this._width = width; + this._height = height; + this._rotation = rotation; + this._fit = options.fit ?? 'fill'; this._videoFrameSink = new VideoFrameSink(videoTrack); + this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null); } /** @internal */ _videoFrameToWrappedCanvas(frame: WrappedVideoFrame): WrappedCanvas { - const width = this._dimensions?.width ?? this._videoTrack.displayWidth; - const height = this._dimensions?.height ?? this._videoTrack.displayHeight; - const rotation = this._videoTrack.rotation; + let canvas = this._canvasPool[this._nextCanvasIndex]; + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.width = this._width; + canvas.height = this._height; - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; + if (this._canvasPool.length > 0) { + this._canvasPool[this._nextCanvasIndex] = canvas; + } + } + + if (this._canvasPool.length > 0) { + this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length; + } const context = canvas.getContext('2d', { alpha: false }); assert(context); - context.translate(width / 2, height / 2); - context.rotate(rotation * Math.PI / 180); - context.translate(-width / 2, -height / 2); + context.resetTransform(); - const [imageWidth, imageHeight] = rotation % 180 === 0 ? [width, height] : [height, width]; + // These variables specify where the final frame will be drawn on the canvas + let dx: number; + let dy: number; + let newWidth: number; + let newHeight: number; - context.drawImage(frame.frame, (width - imageWidth) / 2, (height - imageHeight) / 2, imageWidth, imageHeight); + if (this._fit === 'fill') { + dx = 0; + dy = 0; + newWidth = this._width; + newHeight = this._height; + } else { + const [frameWidth, frameHeight] = this._rotation % 180 === 0 + ? [frame.frame.codedWidth, frame.frame.codedHeight] + : [frame.frame.codedHeight, frame.frame.codedWidth]; + + const scale = this._fit === 'contain' + ? Math.min(this._width / frameWidth, this._height / frameHeight) + : Math.max(this._width / frameWidth, this._height / frameHeight); + newWidth = frameWidth * scale; + newHeight = frameHeight * scale; + dx = (this._width - newWidth) / 2; + dy = (this._height - newHeight) / 2; + } + + const aspectRatioChange = this._rotation % 180 === 0 ? 1 : newWidth / newHeight; + context.translate(this._width / 2, this._height / 2); + context.rotate(this._rotation * Math.PI / 180); + // This aspect ratio compensation is done so that we can draw the frame with the intended dimensions and + // don't need to think about how those dimensions change after the rotation + context.scale(1 / aspectRatioChange, aspectRatioChange); + context.translate(-this._width / 2, -this._height / 2); + + context.drawImage(frame.frame, dx, dy, newWidth, newHeight); const result = { canvas, diff --git a/src/misc.ts b/src/misc.ts index 7a8467e..4eb5baf 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -7,7 +7,16 @@ export function assert(x: unknown): asserts x { /** @public */ export type Rotation = 0 | 90 | 180 | 270; -/** @public */ +export const normalizeRotation = (rotation: number) => { + const mappedRotation = (rotation % 360 + 360) % 360; + + if (mappedRotation === 0 || mappedRotation === 90 || mappedRotation === 180 || mappedRotation === 270) { + return mappedRotation as Rotation; + } else { + throw new Error(`Invalid rotation ${rotation}.`); + } +}; + export type TransformationMatrix = [number, number, number, number, number, number, number, number, number]; export const last = (arr: T[]) => { @@ -137,33 +146,6 @@ export class AsyncMutex { } } -export const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => { - const theta = rotationInDegrees * (Math.PI / 180); - const cosTheta = Math.round(Math.cos(theta)); - const sinTheta = Math.round(Math.sin(theta)); - - // Matrices are post-multiplied in ISOBMFF, meaning this is the transpose of your typical rotation matrix - return [ - cosTheta, sinTheta, 0, - -sinTheta, cosTheta, 0, - 0, 0, 1, - ]; -}; - -/** Extracts the rotation component from a transformation matrix, in degrees. */ -export const extractRotationFromMatrix = (matrix: TransformationMatrix) => { - const [m11, , , m21] = matrix; - - const scaleX = Math.hypot(m11, m21); - - const cosTheta = m11 / scaleX; - const sinTheta = m21 / scaleX; - - return Math.atan2(sinTheta, cosTheta) * (180 / Math.PI); -}; - -export const IDENTITY_MATRIX = rotationMatrix(0); - export const bytesToHexString = (bytes: Uint8Array) => { return [...bytes].map(x => x.toString(16).padStart(2, '0')).join(''); }; diff --git a/src/output-format.ts b/src/output-format.ts index f67a3c0..77480eb 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -31,11 +31,12 @@ export abstract class OutputFormat { /** @internal */ abstract _createMuxer(output: Output): Muxer; /** @internal */ - abstract _getName(): string; + abstract get _name(): string; - abstract getFileExtension(): string; + abstract get fileExtension(): string; abstract getSupportedCodecs(): MediaCodec[]; abstract getSupportedTrackCounts(): TrackCountLimits; + abstract get supportsVideoRotationMetadata(): boolean; getSupportedVideoCodecs() { return this.getSupportedCodecs() @@ -91,6 +92,10 @@ export abstract class IsobmffOutputFormat extends OutputFormat { }; } + get supportsVideoRotationMetadata() { + return true; + } + /** @internal */ _createMuxer(output: Output) { return new IsobmffMuxer(output, this); @@ -100,19 +105,15 @@ export abstract class IsobmffOutputFormat extends OutputFormat { /** @public */ export class Mp4OutputFormat extends IsobmffOutputFormat { /** @internal */ - _getName() { + get _name() { return 'MP4'; } - getFileExtension() { + get fileExtension() { return '.mp4'; } - getSupportedCodecs() { - return Mp4OutputFormat.getSupportedCodecs(); - } - - static getSupportedCodecs(): MediaCodec[] { + getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS, ...NON_PCM_AUDIO_CODECS, @@ -122,7 +123,7 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { /** @internal */ override _codecUnsupportedHint(codec: MediaCodec) { - if (MovOutputFormat.getSupportedCodecs().includes(codec)) { + if (new MovOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MOV will grant support for this codec.'; } @@ -133,19 +134,15 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { /** @public */ export class MovOutputFormat extends IsobmffOutputFormat { /** @internal */ - _getName() { + get _name() { return 'MOV'; } - getFileExtension() { + get fileExtension() { return '.mov'; } - getSupportedCodecs() { - return MovOutputFormat.getSupportedCodecs(); - } - - static getSupportedCodecs(): MediaCodec[] { + getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS, ...AUDIO_CODECS, @@ -154,7 +151,7 @@ export class MovOutputFormat extends IsobmffOutputFormat { /** @internal */ override _codecUnsupportedHint(codec: MediaCodec) { - if (Mp4OutputFormat.getSupportedCodecs().includes(codec)) { + if (new Mp4OutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MP4 will grant support for this codec.'; } @@ -191,7 +188,7 @@ export class MkvOutputFormat extends OutputFormat { } /** @internal */ - _getName() { + get _name() { return 'Matroska'; } @@ -204,15 +201,11 @@ export class MkvOutputFormat extends OutputFormat { }; } - getFileExtension() { + get fileExtension() { return '.mkv'; } - getSupportedCodecs() { - return MkvOutputFormat.getSupportedCodecs(); - } - - static getSupportedCodecs(): MediaCodec[] { + getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS, ...NON_PCM_AUDIO_CODECS, @@ -220,6 +213,11 @@ export class MkvOutputFormat extends OutputFormat { ...SUBTITLE_CODECS, ]; } + + get supportsVideoRotationMetadata() { + // While it technically does support it with ProjectionPoseRoll, many players appear to ignore this value + return false; + } } /** @public */ @@ -227,20 +225,7 @@ export type WebMOutputFormatOptions = MkvOutputFormatOptions; /** @public */ export class WebMOutputFormat extends MkvOutputFormat { - override getSupportedCodecs() { - return WebMOutputFormat.getSupportedCodecs(); - } - - /** @internal */ - override _getName() { - return 'WebM'; - } - - override getFileExtension() { - return '.webm'; - } - - static override getSupportedCodecs(): MediaCodec[] { + override getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS.filter(codec => ['vp8', 'vp9', 'av1'].includes(codec)), ...AUDIO_CODECS.filter(codec => ['opus', 'vorbis'].includes(codec)), @@ -248,9 +233,18 @@ export class WebMOutputFormat extends MkvOutputFormat { ]; } + /** @internal */ + override get _name() { + return 'WebM'; + } + + override get fileExtension() { + return '.webm'; + } + /** @internal */ override _codecUnsupportedHint(codec: MediaCodec) { - if (MkvOutputFormat.getSupportedCodecs().includes(codec)) { + if (new MkvOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MKV will grant support for this codec.'; } @@ -266,7 +260,7 @@ export class Mp3OutputFormat extends OutputFormat { } /** @internal */ - _getName() { + get _name() { return 'MP3'; } @@ -279,16 +273,16 @@ export class Mp3OutputFormat extends OutputFormat { }; } - getFileExtension() { + get fileExtension() { return '.mp3'; } - getSupportedCodecs() { - return Mp3OutputFormat.getSupportedCodecs(); + getSupportedCodecs(): MediaCodec[] { + return ['mp3']; } - static getSupportedCodecs(): MediaCodec[] { - return ['mp3']; + get supportsVideoRotationMetadata() { + return false; } } @@ -300,7 +294,7 @@ export class WaveOutputFormat extends OutputFormat { } /** @internal */ - _getName() { + get _name() { return 'WAVE'; } @@ -313,21 +307,21 @@ export class WaveOutputFormat extends OutputFormat { }; } - getFileExtension() { + get fileExtension() { return '.wav'; } - getSupportedCodecs() { - return WaveOutputFormat.getSupportedCodecs(); - } - - static getSupportedCodecs(): MediaCodec[] { + getSupportedCodecs(): MediaCodec[] { return [ ...PCM_AUDIO_CODECS.filter(codec => ['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-u8', 'ulaw', 'alaw'].includes(codec), ), ]; } + + get supportsVideoRotationMetadata() { + return false; + } } /** @public */ @@ -338,7 +332,7 @@ export class OggOutputFormat extends OutputFormat { } /** @internal */ - _getName() { + get _name() { return 'Ogg'; } @@ -351,17 +345,17 @@ export class OggOutputFormat extends OutputFormat { }; } - getFileExtension() { + get fileExtension() { return '.ogg'; } - getSupportedCodecs() { - return OggOutputFormat.getSupportedCodecs(); - } - - static getSupportedCodecs(): MediaCodec[] { + getSupportedCodecs(): MediaCodec[] { return [ ...AUDIO_CODECS.filter(codec => ['vorbis', 'opus'].includes(codec)), ]; } + + get supportsVideoRotationMetadata() { + return false; + } } diff --git a/src/output.ts b/src/output.ts index 4ead55e..b2ae1cd 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,4 +1,4 @@ -import { AsyncMutex, isIso639Dash2LanguageCode, TransformationMatrix } from './misc'; +import { AsyncMutex, isIso639Dash2LanguageCode } from './misc'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; @@ -48,7 +48,7 @@ export type BaseTrackMetadata = { /** @public */ export type VideoTrackMetadata = BaseTrackMetadata & { - rotation?: 0 | 90 | 180 | 270 | TransformationMatrix; + rotation?: 0 | 90 | 180 | 270; frameRate?: number; }; /** @public */ @@ -134,13 +134,11 @@ export class Output< throw new TypeError('source must be a VideoSource.'); } validateBaseTrackMetadata(metadata); - if (typeof metadata.rotation === 'number' && ![0, 90, 180, 270].includes(metadata.rotation)) { + if (metadata.rotation !== undefined && ![0, 90, 180, 270].includes(metadata.rotation)) { throw new TypeError(`Invalid video rotation: ${metadata.rotation}. Has to be 0, 90, 180 or 270.`); - } else if ( - Array.isArray(metadata.rotation) - && (metadata.rotation.length !== 9 || metadata.rotation.some(value => !Number.isFinite(value))) - ) { - throw new TypeError(`Invalid video transformation matrix: ${metadata.rotation.join()}`); + } + if (!this.format.supportsVideoRotationMetadata && metadata.rotation) { + throw new Error(`${this.format._name} does not support video rotation metadata.`); } if ( metadata.frameRate !== undefined @@ -191,15 +189,15 @@ export class Output< if (presentTracksOfThisType === maxCount) { throw new Error( maxCount === 0 - ? `${this.format._getName()} does not support ${type} tracks.` - : (`${this.format._getName()} does not support more than ${maxCount} ${type} track` + ? `${this.format._name} does not support ${type} tracks.` + : (`${this.format._name} does not support more than ${maxCount} ${type} track` + `${maxCount === 1 ? '' : 's'}.`), ); } const maxTotalCount = supportedTrackCounts.total.max; if (this._tracks.length === maxTotalCount) { throw new Error( - `${this.format._getName()} does not support more than ${maxTotalCount} tracks` + `${this.format._name} does not support more than ${maxTotalCount} tracks` + `${maxTotalCount === 1 ? '' : 's'} in total.`, ); } @@ -217,12 +215,12 @@ export class Output< if (supportedVideoCodecs.length === 0) { throw new Error( - `${this.format._getName()} does not support video tracks.` + `${this.format._name} does not support video tracks.` + this.format._codecUnsupportedHint(track.source._codec), ); } else if (!supportedVideoCodecs.includes(track.source._codec)) { throw new Error( - `Codec '${track.source._codec}' cannot be contained within ${this.format._getName()}. Supported` + `Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported` + ` video codecs are: ${supportedVideoCodecs.map(codec => `'${codec}'`).join(', ')}.` + this.format._codecUnsupportedHint(track.source._codec), ); @@ -232,12 +230,12 @@ export class Output< if (supportedAudioCodecs.length === 0) { throw new Error( - `${this.format._getName()} does not support audio tracks.` + `${this.format._name} does not support audio tracks.` + this.format._codecUnsupportedHint(track.source._codec), ); } else if (!supportedAudioCodecs.includes(track.source._codec)) { throw new Error( - `Codec '${track.source._codec}' cannot be contained within ${this.format._getName()}. Supported` + `Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported` + ` audio codecs are: ${supportedAudioCodecs.map(codec => `'${codec}'`).join(', ')}.` + this.format._codecUnsupportedHint(track.source._codec), ); @@ -247,12 +245,12 @@ export class Output< if (supportedSubtitleCodecs.length === 0) { throw new Error( - `${this.format._getName()} does not support subtitle tracks.` + `${this.format._name} does not support subtitle tracks.` + this.format._codecUnsupportedHint(track.source._codec), ); } else if (!supportedSubtitleCodecs.includes(track.source._codec)) { throw new Error( - `Codec '${track.source._codec}' cannot be contained within ${this.format._getName()}. Supported` + `Codec '${track.source._codec}' cannot be contained within ${this.format._name}. Supported` + ` subtitle codecs are: ${supportedSubtitleCodecs.map(codec => `'${codec}'`).join(', ')}.` + this.format._codecUnsupportedHint(track.source._codec), ); @@ -282,9 +280,9 @@ export class Output< if (presentTracksOfThisType < minCount) { throw new Error( minCount === supportedTrackCounts[trackType].max - ? (`${this.format._getName()} requires exactly ${minCount} ${trackType}` + ? (`${this.format._name} requires exactly ${minCount} ${trackType}` + ` track${minCount === 1 ? '' : 's'}.`) - : (`${this.format._getName()} requires at least ${minCount} ${trackType}` + : (`${this.format._name} requires at least ${minCount} ${trackType}` + ` track${minCount === 1 ? '' : 's'}.`), ); } @@ -293,9 +291,9 @@ export class Output< if (this._tracks.length < totalMinCount) { throw new Error( totalMinCount === supportedTrackCounts.total.max - ? (`${this.format._getName()} requires exactly ${totalMinCount} track` + ? (`${this.format._name} requires exactly ${totalMinCount} track` + `${totalMinCount === 1 ? '' : 's'}.`) - : (`${this.format._getName()} requires at least ${totalMinCount} track` + : (`${this.format._name} requires at least ${totalMinCount} track` + `${totalMinCount === 1 ? '' : 's'}.`), ); } diff --git a/src/packet.ts b/src/packet.ts index e2ad5a0..5212176 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -2,8 +2,10 @@ import { SECOND_TO_MICROSECOND_FACTOR } from './misc'; export const PLACEHOLDER_DATA = new Uint8Array(0); +/** @public */ export type PacketType = 'key' | 'delta'; +/** @public */ export class EncodedPacket { constructor( public readonly data: Uint8Array, diff --git a/todo.txt b/todo.txt index 07fc482..dab71aa 100644 --- a/todo.txt +++ b/todo.txt @@ -3,5 +3,4 @@ - is this fixed? https://github.com/Vanilagy/webm-muxer/issues/50 - cross-track offset for streaming sources - configurable fragmented mp4 fragment size, like the mp4-muxer PR -- fix rotation matrix thing, make sure its counter-clockwise everywhere (is this a breaking change from mp4-muxer?) -- canvassink ring buffer \ No newline at end of file +- cancel in convert causes error \ No newline at end of file