Ensure rotation is always clockwise, make CanvasSink more powerful, disable rotation metadata for Matroska outputs, & other changes

This commit is contained in:
Vanilagy
2025-03-02 11:59:11 +01:00
parent 2ab385b16e
commit 453f8b6884
15 changed files with 304 additions and 200 deletions
+19 -3
View File
@@ -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);
+3 -2
View File
@@ -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();
+1 -1
View File
@@ -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;
+29 -50
View File
@@ -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++;
+2 -1
View File
@@ -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,
+15 -3
View File
@@ -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;
}
+16 -4
View File
@@ -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);
};
+8 -3
View File
@@ -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;
+6 -2
View File
@@ -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]
},
],
}
+119 -20
View File
@@ -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,
+10 -28
View File
@@ -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 = <T>(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('');
};
+54 -60
View File
@@ -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;
}
}
+19 -21
View File
@@ -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'}.`),
);
}
+2
View File
@@ -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,
+1 -2
View File
@@ -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
- cancel in convert causes error