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