mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Ensure rotation is always clockwise, make CanvasSink more powerful, disable rotation metadata for Matroska outputs, & other changes
This commit is contained in:
+19
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user