Compare commits

..
4 Commits
Author SHA1 Message Date
Vanilagy ca1305da93 Bump minor 2025-08-24 16:37:41 +02:00
Vanilagy 63b8190185 Add logic for encoding video frames that change size over time (#63) 2025-08-24 16:37:03 +02:00
Vanilagy ad26edc6b5 Catch degenerate ISOBMFF matrices 2025-08-24 14:49:33 +02:00
Vanilagy 945df51944 Copy track name in Conversion API 2025-08-24 11:15:08 +02:00
14 changed files with 178 additions and 53 deletions
+2
View File
@@ -74,6 +74,8 @@
}, },
*/ */
video: () => ({ video: () => ({
codec: 'avc',
//fit: 'contain',
//frameRate: 27.123, //frameRate: 27.123,
//width: 320, //width: 320,
//forceTranscode: true, //forceTranscode: true,
+2
View File
@@ -141,6 +141,8 @@ If `width` or `height` is used in conjunction with `rotation`, they control the
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options). If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
In the rare case that the input video changes size over time, the `fit` field can be used to control the size change behavior (see [`VideoEncodingConfig`](./media-sources#video-encoding-config)). When unset, the behavior is `'passThrough'`.
### Adjusting frame rate ### Adjusting frame rate
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable). The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
+2
View File
@@ -54,6 +54,7 @@ type VideoEncodingConfig = {
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software'; hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
scalabilityMode?: string; scalabilityMode?: string;
contentHint?: string; contentHint?: string;
sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover';
onEncodedPacket?: ( onEncodedPacket?: (
packet: EncodedPacket, packet: EncodedPacket,
@@ -73,6 +74,7 @@ type VideoEncodingConfig = {
- `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`. - `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
- `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*). - `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
- `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints). - `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
- `sizeChangeBehavior`: Video frames may change size overtime. This field controls the behavior in case this happens. Defaults to `'deny'`.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
+11
View File
@@ -343,6 +343,17 @@ draw(
``` ```
These methods behave like [drawImage](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage) and paint the video frame at the given position with the given dimensions. This method will automatically draw the frame with the correct rotation based on its `rotation` property. These methods behave like [drawImage](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage) and paint the video frame at the given position with the given dimensions. This method will automatically draw the frame with the correct rotation based on its `rotation` property.
The `drawWithFit` method can be used to draw the video sample to fill an entire canvas with a specified fitting algorithm:
```ts
drawWithFit(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
options: {
fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation
},
): void;
```
If you want to draw the raw underlying image to a canvas directly (without respecting the rotation metadata), then you can use the following method: If you want to draw the raw underlying image to a canvas directly (without respecting the rotation metadata), then you can use the following method:
```ts ```ts
videoSample.toCanvasImageSource(); // => VideoFrame | OffscreenCanvas; videoSample.toCanvasImageSource(); // => VideoFrame | OffscreenCanvas;
+4 -1
View File
@@ -144,7 +144,10 @@ const initMediaPlayer = async (file: File) => {
// For video, let's use a CanvasSink as it handles rotation and closing video samples for us. // For video, let's use a CanvasSink as it handles rotation and closing video samples for us.
// Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases. // Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases.
videoSink = videoTrack && new CanvasSink(videoTrack, { poolSize: 2 }); videoSink = videoTrack && new CanvasSink(videoTrack, {
poolSize: 2,
fit: 'contain', // In case the video changes dimensions over time
});
// For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API // For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API
audioSink = audioTrack && new AudioBufferSink(audioTrack); audioSink = audioTrack && new AudioBufferSink(audioTrack);
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.10.0", "version": "1.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.10.0", "version": "1.11.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -5900,9 +5900,9 @@
} }
}, },
"node_modules/mediabunny": { "node_modules/mediabunny": {
"version": "1.9.3", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.9.3.tgz", "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.10.0.tgz",
"integrity": "sha512-uVspdzrpwvW8NxtGspHPOJvdNSY0dNFEmOQngSheX9PYX9oXijZENBjfYWOgm9c9IgSSlN2bdJF9iyPyY3f+fQ==", "integrity": "sha512-M2Fe9jZJ1VEjp8i7CmwBY38UDiA3V0qWIv1hbFBfL8rh27ZVQyL9f1u3FtrerlpaKT0Q+c1LHjcjVmhwp9N5kA==",
"license": "MPL-2.0", "license": "MPL-2.0",
"peer": true, "peer": true,
"workspaces": [ "workspaces": [
@@ -9017,7 +9017,7 @@
}, },
"packages/mp3-encoder": { "packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"version": "1.10.0", "version": "1.11.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.10.0", "version": "1.11.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.10.0", "version": "1.11.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.", "description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+4 -1
View File
@@ -59,7 +59,7 @@ export type ConversionVideoOptions = {
*/ */
height?: number; height?: number;
/** /**
* The fitting algorithm in case both width and height are set. * The fitting algorithm in case both width and height are set, or if the input video changes its size over time.
* *
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio. * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
@@ -623,6 +623,7 @@ export class Conversion {
const encodingConfig: VideoEncodingConfig = { const encodingConfig: VideoEncodingConfig = {
codec: encodableCodec, codec: encodableCodec,
bitrate, bitrate,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
}; };
@@ -811,6 +812,7 @@ export class Conversion {
this.output.addVideoTrack(videoSource, { this.output.addVideoTrack(videoSource, {
frameRate: trackOptions.frameRate, frameRate: trackOptions.frameRate,
languageCode: track.languageCode, languageCode: track.languageCode,
name: track.name ?? undefined,
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
}); });
this._addedCounts.video++; this._addedCounts.video++;
@@ -980,6 +982,7 @@ export class Conversion {
this.output.addAudioTrack(audioSource, { this.output.addAudioTrack(audioSource, {
languageCode: track.languageCode, languageCode: track.languageCode,
name: track.name ?? undefined,
}); });
this._addedCounts.audio++; this._addedCounts.audio++;
this._totalTrackCount++; this._totalTrackCount++;
+14
View File
@@ -43,6 +43,19 @@ export type VideoEncodingConfig = {
* all the same key frame interval. * all the same key frame interval.
*/ */
keyFrameInterval?: number; keyFrameInterval?: number;
/**
* Video frames may change size overtime. This field controls the behavior in case this happens.
*
* - 'deny' (default) will throw an error, requiring all frames to have the exact same dimensions.
* - 'passThrough' will allow the change and directly pass the frame to the encoder.
* - 'fill' will stretch the image to fill the entire original box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the originalbox while preserving aspect ratio. This may lead to
* letterboxing.
* - 'cover' will scale the image until the entire original box is filled, while preserving aspect ratio.
*
* The "original box" refers to the dimensions of the first encoded frame.
*/
sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover';
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown;
@@ -66,6 +79,7 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
) { ) {
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.'); throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
} }
// todo here
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') { if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.'); throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
} }
+8 -1
View File
@@ -2755,7 +2755,14 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
const sinTheta = m21 / scaleX; const sinTheta = m21 / scaleX;
// Invert the rotation because matrices are post-multiplied in ISOBMFF // Invert the rotation because matrices are post-multiplied in ISOBMFF
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI); const result = -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
if (!Number.isFinite(result)) {
// Can happen if the entire matrix is 0, for example
return 0;
}
return result;
}; };
const sampleTableIsEmpty = (sampleTable: SampleTable) => { const sampleTableIsEmpty = (sampleTable: SampleTable) => {
+10 -32
View File
@@ -1118,6 +1118,8 @@ export class CanvasSink {
/** @internal */ /** @internal */
_videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas { _videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas {
let canvas = this._canvasPool[this._nextCanvasIndex]; let canvas = this._canvasPool[this._nextCanvasIndex];
let canvasIsNew = false;
if (!canvas) { if (!canvas) {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
// Prefer an HTMLCanvasElement // Prefer an HTMLCanvasElement
@@ -1131,6 +1133,8 @@ export class CanvasSink {
if (this._canvasPool.length > 0) { if (this._canvasPool.length > 0) {
this._canvasPool[this._nextCanvasIndex] = canvas; this._canvasPool[this._nextCanvasIndex] = canvas;
} }
canvasIsNew = true;
} }
if (this._canvasPool.length > 0) { if (this._canvasPool.length > 0) {
@@ -1143,40 +1147,14 @@ export class CanvasSink {
context.resetTransform(); context.resetTransform();
// These variables specify where the final sample will be drawn on the canvas if (!canvasIsNew) {
let dx: number; context.clearRect(0, 0, this._width, this._height);
let dy: number;
let newWidth: number;
let newHeight: number;
if (this._fit === 'fill') {
dx = 0;
dy = 0;
newWidth = this._width;
newHeight = this._height;
} else {
const [sampleWidth, sampleHeight] = this._rotation % 180 === 0
? [sample.codedWidth, sample.codedHeight]
: [sample.codedHeight, sample.codedWidth];
const scale = this._fit === 'contain'
? Math.min(this._width / sampleWidth, this._height / sampleHeight)
: Math.max(this._width / sampleWidth, this._height / sampleHeight);
newWidth = sampleWidth * scale;
newHeight = sampleHeight * scale;
dx = (this._width - newWidth) / 2;
dy = (this._height - newHeight) / 2;
} }
const aspectRatioChange = this._rotation % 180 === 0 ? 1 : newWidth / newHeight; sample.drawWithFit(context, {
context.translate(this._width / 2, this._height / 2); fit: this._fit,
context.rotate(this._rotation * Math.PI / 180); rotation: this._rotation,
// This aspect ratio compensation is done so that we can draw the sample 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(sample.toCanvasImageSource(), dx, dy, newWidth, newHeight);
const result = { const result = {
canvas, canvas,
+55 -10
View File
@@ -191,8 +191,9 @@ class VideoEncoderWrapper {
private encoder: VideoEncoder | null = null; private encoder: VideoEncoder | null = null;
private muxer: Muxer | null = null; private muxer: Muxer | null = null;
private lastMultipleOfKeyFrameInterval = -1; private lastMultipleOfKeyFrameInterval = -1;
private lastWidth: number | null = null; private codedWidth: number | null = null;
private lastHeight: number | null = null; private codedHeight: number | null = null;
private resizeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
private customEncoder: CustomVideoEncoder | null = null; private customEncoder: CustomVideoEncoder | null = null;
private customEncoderCallSerializer = new CallSerializer(); private customEncoderCallSerializer = new CallSerializer();
@@ -213,16 +214,60 @@ class VideoEncoderWrapper {
this.source._ensureValidAdd(); this.source._ensureValidAdd();
// Ensure video sample size remains constant // Ensure video sample size remains constant
if (this.lastWidth !== null && this.lastHeight !== null) { if (this.codedWidth !== null && this.codedHeight !== null) {
if (videoSample.codedWidth !== this.lastWidth || videoSample.codedHeight !== this.lastHeight) { if (videoSample.codedWidth !== this.codedWidth || videoSample.codedHeight !== this.codedHeight) {
throw new Error( const sizeChangeBehavior = this.encodingConfig.sizeChangeBehavior ?? 'deny';
`Video sample size must remain constant. Expected ${this.lastWidth}x${this.lastHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}.`, if (sizeChangeBehavior === 'passThrough') {
); // Do nada
} else if (sizeChangeBehavior === 'deny') {
throw new Error(
`Video sample size must remain constant. Expected ${this.codedWidth}x${this.codedHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}. To allow the sample size to`
+ ` change over time, set \`sizeChangeBehavior\` to a value other than 'strict' in the`
+ ` encoding options.`,
);
} else {
let canvasIsNew = false;
if (!this.resizeCanvas) {
if (typeof document !== 'undefined') {
// Prefer an HTMLCanvasElement
this.resizeCanvas = document.createElement('canvas');
this.resizeCanvas.width = this.codedWidth;
this.resizeCanvas.height = this.codedHeight;
} else {
this.resizeCanvas = new OffscreenCanvas(this.codedWidth, this.codedHeight);
}
canvasIsNew = true;
}
const context = this.resizeCanvas.getContext('2d', { alpha: false }) as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
if (!canvasIsNew) {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
}
videoSample.drawWithFit(context, { fit: sizeChangeBehavior });
if (shouldClose) {
videoSample.close();
}
videoSample = new VideoSample(this.resizeCanvas, {
timestamp: videoSample.timestamp,
duration: videoSample.duration,
rotation: videoSample.rotation,
});
shouldClose = true;
}
} }
} else { } else {
this.lastWidth = videoSample.codedWidth; this.codedWidth = videoSample.codedWidth;
this.lastHeight = videoSample.codedHeight; this.codedHeight = videoSample.codedHeight;
} }
if (!this.encoderInitialized) { if (!this.encoderInitialized) {
+58
View File
@@ -534,6 +534,64 @@ export class VideoSample {
context.restore(); context.restore();
} }
/**
* Draws the sample in the middle of the canvas corresponding to the context with the specified fit behavior.
*/
drawWithFit(context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, options: {
/**
* Controls the fitting algorithm.
*
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
* - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation;
}) {
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation;
// These variables specify where the final sample will be drawn on the canvas
let dx: number;
let dy: number;
let newWidth: number;
let newHeight: number;
if (options.fit === 'fill') {
dx = 0;
dy = 0;
newWidth = canvasWidth;
newHeight = canvasHeight;
} else {
const [sampleWidth, sampleHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
const scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
: Math.max(canvasWidth / sampleWidth, canvasHeight / sampleHeight);
newWidth = sampleWidth * scale;
newHeight = sampleHeight * scale;
dx = (canvasWidth - newWidth) / 2;
dy = (canvasHeight - newHeight) / 2;
}
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
context.translate(canvasWidth / 2, canvasHeight / 2);
context.rotate(rotation * Math.PI / 180);
// This aspect ratio compensation is done so that we can draw the sample 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(-canvasWidth / 2, -canvasHeight / 2);
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here
context.drawImage(this.toCanvasImageSource(), dx, dy, newWidth, newHeight);
}
/** /**
* Converts this video sample to a CanvasImageSource for drawing to a canvas. * Converts this video sample to a CanvasImageSource for drawing to a canvas.
* *