mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-01 21:03:46 +02:00
Merge main into release for tag v1.46.0
This commit is contained in:
@@ -35,6 +35,22 @@ registerMediabunnyServer();
|
||||
|
||||
That's it - you now have access to the full Mediabunny feature set on the server.
|
||||
|
||||
---
|
||||
|
||||
An optional `options` parameter is available for further configuration:
|
||||
```ts
|
||||
import { registerMediabunnyServer } from '@mediabunny/server';
|
||||
import * as NodeAv from 'node-av';
|
||||
|
||||
registerMediabunnyServer({
|
||||
// Use a specific hardware rendering device:
|
||||
hardwareContext: NodeAv.HardwareContext.create(
|
||||
NodeAv.AV_HWDEVICE_TYPE_VAAPI,
|
||||
'/dev/dri/renderD128',
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## Upload media compression example
|
||||
|
||||
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
|
||||
|
||||
@@ -330,9 +330,12 @@ videoSample.microsecondDuration; // => Duration in microseconds
|
||||
videoSample.colorSpace; // => VideoColorSpace
|
||||
|
||||
videoSample.visibleRect; // Rectangle
|
||||
|
||||
// Encode options used when this sample is passed to an encoder
|
||||
videoSample.encodeOptions; // => VideoEncoderEncodeOptions (defaults to {})
|
||||
```
|
||||
|
||||
While all of these properties are read-only, you can use the `setTimestamp`, `setDuration` and `setRotation` methods to modify some of the metadata of the video sample.
|
||||
While all of these properties are read-only, you can use the `setTimestamp`, `setDuration`, `setRotation` and `setEncodeOptions` methods to modify some of the metadata of the video sample.
|
||||
|
||||
::: warning
|
||||
Timestamps can be [negative](#negative-timestamps).
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -12806,7 +12806,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12821,7 +12821,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12836,7 +12836,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12851,7 +12851,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12866,7 +12866,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.4"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/ac3",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
"module": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -39,6 +39,22 @@ registerMediabunnyServer();
|
||||
|
||||
That's it - you now have access to the full Mediabunny feature set on the server.
|
||||
|
||||
---
|
||||
|
||||
An optional `options` parameter is available for further configuration:
|
||||
```ts
|
||||
import { registerMediabunnyServer } from '@mediabunny/server';
|
||||
import * as NodeAv from 'node-av';
|
||||
|
||||
registerMediabunnyServer({
|
||||
// Use a specific hardware rendering device:
|
||||
hardwareContext: NodeAv.HardwareContext.create(
|
||||
NodeAv.AV_HWDEVICE_TYPE_VAAPI,
|
||||
'/dev/dri/renderD128',
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## Upload media compression example
|
||||
|
||||
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.5",
|
||||
"version": "1.46.0",
|
||||
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
|
||||
@@ -42,6 +42,9 @@ export class AvFrameAudioSampleResource extends AudioSampleResource {
|
||||
constructor(frame: NodeAv.Frame) {
|
||||
super();
|
||||
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a NodeAv.Frame.');
|
||||
}
|
||||
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_AUDIO) {
|
||||
throw new Error('AvFrameAudioSampleResource must be initialized with an audio frame.');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { AudioSample, registerDecoder, registerEncoder, registerVideoSampleTransformer, VideoSample } from 'mediabunny';
|
||||
import {
|
||||
AudioSample,
|
||||
MaybePromise,
|
||||
registerDecoder,
|
||||
registerEncoder,
|
||||
registerVideoSampleTransformer,
|
||||
VideoSample,
|
||||
} from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { NodeAvVideoDecoder } from './video-decoder';
|
||||
import { NodeAvVideoEncoder } from './video-encoder';
|
||||
@@ -28,6 +35,29 @@ if ((globalThis as Record<symbol, unknown>)[SERVER_LOADED_SYMBOL]) {
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Options for configuring Mediabunny's server-side polyfills.
|
||||
* @group \@mediabunny/server
|
||||
* @public
|
||||
*/
|
||||
type MediabunnyServerOptions = {
|
||||
/**
|
||||
* The hardware context to use for hardware-accelerated decoding and encoding. When set to a
|
||||
* `NodeAv.HardwareContext`, that context is used directly. When set to a function, the function is invoked (with
|
||||
* no caching) every time a hardware decoder or encoder codec is resolved, receiving the codec ID and returning the
|
||||
* context to use for it (or `null` for no hardware acceleration).
|
||||
*
|
||||
* When not set, a context is automatically detected on first use and then cached.
|
||||
*/
|
||||
hardwareContext?:
|
||||
| NodeAv.HardwareContext
|
||||
| null
|
||||
| ((codecId: NodeAv.AVCodecID) => MaybePromise<NodeAv.HardwareContext | null>);
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export let _serverOptions: MediabunnyServerOptions = {};
|
||||
|
||||
/**
|
||||
* Registers video and audio decoders and encoders for all codecs, using FFmpeg's libavcodec under the hood.
|
||||
* Additionally, a custom `VideoSample` transformer based on libavfilter is registered to enable resizing, rotation and
|
||||
@@ -38,14 +68,32 @@ let registered = false;
|
||||
* The decoders and encoders will automatically detect hardware acceleration support for each codec and platform and
|
||||
* make use of it if applicable.
|
||||
*
|
||||
* You can pass optional {@link MediabunnyServerOptions} for additional configuration.
|
||||
*
|
||||
* @group \@mediabunny/server
|
||||
* @public
|
||||
*/
|
||||
export const registerMediabunnyServer = () => {
|
||||
export const registerMediabunnyServer = (options: MediabunnyServerOptions = {}) => {
|
||||
if (typeof options !== 'object' || !options) {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (
|
||||
options.hardwareContext != null
|
||||
&& !(
|
||||
options.hardwareContext instanceof NodeAv.HardwareContext || typeof options.hardwareContext === 'function'
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'options.hardwareContext, when provided, must be a NodeAv.HardwareContext, a function, or null.',
|
||||
);
|
||||
}
|
||||
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered = true;
|
||||
_serverOptions = options;
|
||||
|
||||
NodeAv.Log.setLevel(NodeAv.AV_LOG_ERROR);
|
||||
|
||||
@@ -60,6 +108,8 @@ export const registerMediabunnyServer = () => {
|
||||
registerVideoSampleTransformer(transformVideoSample);
|
||||
};
|
||||
|
||||
export type { MediabunnyServerOptions };
|
||||
|
||||
export { AvFrameVideoSampleResource } from './video-sample';
|
||||
export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
|
||||
@@ -76,6 +126,13 @@ export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
* @public
|
||||
*/
|
||||
export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => {
|
||||
if (!(sample instanceof VideoSample) && !(sample instanceof AudioSample)) {
|
||||
throw new TypeError('sample must be a VideoSample or an AudioSample.');
|
||||
}
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a NodeAv.Frame.');
|
||||
}
|
||||
|
||||
if (sample instanceof VideoSample) {
|
||||
if (sample._data instanceof AvFrameVideoSampleResource) {
|
||||
// We're overriding the frame, so release whatever it referenced before, otherwise av_frame_ref leaks it
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { VideoSamplePixelFormat, MediaCodec } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { _serverOptions } from './index';
|
||||
|
||||
export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> = {
|
||||
avc: NodeAv.AV_CODEC_ID_H264,
|
||||
@@ -27,14 +28,35 @@ export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> =
|
||||
|
||||
let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined;
|
||||
export const getHardwareContext = (): NodeAv.HardwareContext | null => {
|
||||
if (_serverOptions.hardwareContext !== undefined && typeof _serverOptions.hardwareContext !== 'function') {
|
||||
// Return the user-provided one
|
||||
return _serverOptions.hardwareContext;
|
||||
}
|
||||
|
||||
if (cachedHardwareContext === undefined) {
|
||||
cachedHardwareContext = NodeAv.HardwareContext.auto();
|
||||
}
|
||||
return cachedHardwareContext;
|
||||
};
|
||||
|
||||
const validateHwContext = (hw: NodeAv.HardwareContext | null) => {
|
||||
if (hw !== null && !(hw instanceof NodeAv.HardwareContext)) {
|
||||
throw new TypeError(
|
||||
'When serverOptions.hardwareContext is a function, it must return or resolve to a'
|
||||
+ ' NodeAv.HardwareContext or null.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hardwareDecoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareDecoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
export const getHardwareDecoderCodec = async (codecId: NodeAv.AVCodecID): Promise<NodeAv.Codec | null> => {
|
||||
if (typeof _serverOptions.hardwareContext === 'function') {
|
||||
const hw = await _serverOptions.hardwareContext(codecId);
|
||||
validateHwContext(hw);
|
||||
|
||||
return hw?.getDecoderCodec(codecId) ?? null;
|
||||
}
|
||||
|
||||
if (!hardwareDecoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareDecoderCodecCache.set(codecId, hw?.getDecoderCodec(codecId) ?? null);
|
||||
@@ -43,7 +65,14 @@ export const getHardwareDecoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec
|
||||
};
|
||||
|
||||
const hardwareEncoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareEncoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
export const getHardwareEncoderCodec = async (codecId: NodeAv.AVCodecID): Promise<NodeAv.Codec | null> => {
|
||||
if (typeof _serverOptions.hardwareContext === 'function') {
|
||||
const hw = await _serverOptions.hardwareContext(codecId);
|
||||
validateHwContext(hw);
|
||||
|
||||
return hw?.getEncoderCodec(codecId) ?? null;
|
||||
}
|
||||
|
||||
if (!hardwareEncoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareEncoderCodecCache.set(codecId, hw?.getEncoderCodec(codecId) ?? null);
|
||||
|
||||
@@ -58,7 +58,7 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
) {
|
||||
codec = NodeAv.Codec.findDecoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareDecoderCodec(codecId) ?? NodeAv.Codec.findDecoder(codecId);
|
||||
codec = (await getHardwareDecoderCodec(codecId)) ?? NodeAv.Codec.findDecoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
|
||||
@@ -83,7 +83,7 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
} else if (this.config.hardwareAcceleration === 'prefer-software') {
|
||||
codec = NodeAv.Codec.findEncoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareEncoderCodec(codecId) ?? NodeAv.Codec.findEncoder(codecId);
|
||||
codec = (await getHardwareEncoderCodec(codecId)) ?? NodeAv.Codec.findEncoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
|
||||
@@ -73,6 +73,9 @@ export class AvFrameVideoSampleResource extends VideoSampleResource {
|
||||
constructor(frame: NodeAv.Frame) {
|
||||
super();
|
||||
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a NodeAv.Frame.');
|
||||
}
|
||||
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_VIDEO) {
|
||||
throw new Error('AvFrameVideoSampleResource must be initialized with a video frame.');
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ export {
|
||||
export {
|
||||
type AnyIterable,
|
||||
ConcurrentRunner,
|
||||
type DeepReadonly,
|
||||
EventEmitter,
|
||||
type EventListenerOptions,
|
||||
type FilePath,
|
||||
|
||||
+10
-7
@@ -460,14 +460,17 @@ class VideoEncoderWrapper {
|
||||
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
|
||||
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
|
||||
|
||||
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks
|
||||
// follow the same "key frame" rhythm, because aligned key frames are required to start new
|
||||
// fragments in ISOBMFF or clusters in Matroska (or at least desirable).
|
||||
const mergedEncodeOptions = { ...sampleToEncode.encodeOptions, ...encodeOptions };
|
||||
|
||||
const finalEncodeOptions = {
|
||||
...encodeOptions,
|
||||
keyFrame: encodeOptions?.keyFrame
|
||||
|| keyFrameInterval === 0
|
||||
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
|
||||
...mergedEncodeOptions,
|
||||
keyFrame: mergedEncodeOptions.keyFrame !== undefined
|
||||
? mergedEncodeOptions.keyFrame
|
||||
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks
|
||||
// follow the same "key frame" rhythm, because aligned key frames are required to start new
|
||||
// fragments in ISOBMFF or clusters in Matroska (or at least desirable).
|
||||
: keyFrameInterval === 0
|
||||
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
|
||||
};
|
||||
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
||||
|
||||
|
||||
@@ -482,6 +482,15 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
||||
*/
|
||||
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Recursively makes all properties of T readonly.
|
||||
* @group Miscellaneous
|
||||
* @public
|
||||
*/
|
||||
export type DeepReadonly<T> = T extends object ? {
|
||||
readonly [K in keyof T]: DeepReadonly<T[K]>;
|
||||
} : T;
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be optional.
|
||||
* @group Miscellaneous
|
||||
|
||||
+43
-4
@@ -30,6 +30,7 @@ import {
|
||||
roundToMultiple,
|
||||
arrayArgmin,
|
||||
MaybePromise,
|
||||
DeepReadonly,
|
||||
} from './misc';
|
||||
|
||||
polyfillSymbolDispose();
|
||||
@@ -232,6 +233,11 @@ export type VideoSampleInit = {
|
||||
displayWidth?: number | undefined;
|
||||
/** Height of the frame in pixels after applying aspect ratio adjustments and rotation. */
|
||||
displayHeight?: number | undefined;
|
||||
/** The encode options to use when this sample is passed to an encoder. */
|
||||
encodeOptions?: DeepReadonly<VideoEncoderEncodeOptions>;
|
||||
|
||||
/** @internal */
|
||||
_doNotCopy?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -279,6 +285,8 @@ export class VideoSample implements Disposable {
|
||||
readonly duration!: number;
|
||||
/** The color space of the frame. */
|
||||
readonly colorSpace!: VideoSampleColorSpace;
|
||||
/** The encode options to use when this sample is passed to an encoder. */
|
||||
readonly encodeOptions!: DeepReadonly<VideoEncoderEncodeOptions>;
|
||||
|
||||
/** The width of the frame in pixels. */
|
||||
get codedWidth() {
|
||||
@@ -414,7 +422,9 @@ export class VideoSample implements Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
this._data = toUint8Array(data).slice(); // Copy it
|
||||
this._data = init._doNotCopy
|
||||
? toUint8Array(data)
|
||||
: toUint8Array(data).slice(); // Copy it
|
||||
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
|
||||
|
||||
this.format = init.format;
|
||||
@@ -553,7 +563,12 @@ export class VideoSample implements Disposable {
|
||||
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
|
||||
willReadFrequently: true,
|
||||
});
|
||||
assert(context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'OffscreenCanvas must have support for the \'2d\' context in order to create a VideoSample from'
|
||||
+ ' this data.',
|
||||
);
|
||||
}
|
||||
|
||||
// Draw it to a canvas
|
||||
context.drawImage(data, 0, 0);
|
||||
@@ -628,6 +643,8 @@ export class VideoSample implements Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
this.encodeOptions = init?.encodeOptions ?? {};
|
||||
|
||||
this.pixelAspectRatio = simplifyRational({
|
||||
num: this.squarePixelWidth * this.codedHeight,
|
||||
den: this.squarePixelHeight * this.codedWidth,
|
||||
@@ -648,12 +665,14 @@ export class VideoSample implements Disposable {
|
||||
timestamp: this.timestamp,
|
||||
duration: this.duration,
|
||||
rotation: this.rotation,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
} else if (isVideoFrame(this._data)) {
|
||||
return new VideoSample(this._data.clone(), {
|
||||
timestamp: this.timestamp,
|
||||
duration: this.duration,
|
||||
rotation: this.rotation,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
assert(this._layout);
|
||||
@@ -670,6 +689,10 @@ export class VideoSample implements Disposable {
|
||||
visibleRect: this.visibleRect,
|
||||
displayWidth: this.displayWidth,
|
||||
displayHeight: this.displayHeight,
|
||||
encodeOptions: this.encodeOptions,
|
||||
|
||||
// It's already been copied, if we copy it again we make the clone unnecessarily expensive
|
||||
_doNotCopy: true,
|
||||
});
|
||||
} else {
|
||||
return new VideoSample(this._data, {
|
||||
@@ -683,6 +706,7 @@ export class VideoSample implements Disposable {
|
||||
visibleRect: this.visibleRect,
|
||||
displayWidth: this.displayWidth,
|
||||
displayHeight: this.displayHeight,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -852,7 +876,7 @@ export class VideoSample implements Disposable {
|
||||
} else {
|
||||
const canvas = this._data;
|
||||
const context = canvas.getContext('2d');
|
||||
assert(context);
|
||||
assert(context); // We already got it earlier so it's definitely available
|
||||
|
||||
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
|
||||
|
||||
@@ -1503,7 +1527,12 @@ export class VideoSample implements Disposable {
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: true,
|
||||
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
assert(context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'The \'2d\' canvas context is required to transform VideoSamples. Register a custom transformer using'
|
||||
+ ' registerVideoSampleTransformer to work around this limitation.',
|
||||
);
|
||||
}
|
||||
|
||||
if (description.alpha === 'discard') {
|
||||
context.fillStyle = 'black';
|
||||
@@ -1556,6 +1585,16 @@ export class VideoSample implements Disposable {
|
||||
(this.duration as number) = newDuration;
|
||||
}
|
||||
|
||||
/** Sets the encode options used when this sample is passed to an encoder. */
|
||||
setEncodeOptions(newEncodeOptions: VideoEncoderEncodeOptions) {
|
||||
if (!newEncodeOptions || typeof newEncodeOptions !== 'object') {
|
||||
throw new TypeError('newEncodeOptions must be an object.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
(this.encodeOptions as DeepReadonly<VideoEncoderEncodeOptions>) = newEncodeOptions;
|
||||
}
|
||||
|
||||
/** Calls `.close()`. */
|
||||
[Symbol.dispose]() {
|
||||
this.close();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { VideoSample } from '../../src/sample.js';
|
||||
import { VideoSampleSource } from '../../src/media-source.js';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { Mp4OutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { QUALITY_MEDIUM } from '../../src/encode.js';
|
||||
import { PacketType } from '../../src/packet.js';
|
||||
|
||||
test('allocationSize', async () => {
|
||||
{
|
||||
@@ -443,3 +449,83 @@ test('null format', async () => {
|
||||
await sample.copyTo(buffer, { format: 'BGRA' });
|
||||
await sample.copyTo(buffer, { format: 'BGRX' });
|
||||
});
|
||||
|
||||
test('encodeOptions', () => {
|
||||
const canvas = new OffscreenCanvas(100, 100);
|
||||
canvas.getContext('2d');
|
||||
|
||||
// The default is an empty object
|
||||
using a = new VideoSample(canvas, { timestamp: 0 });
|
||||
expect(a.encodeOptions).toEqual({});
|
||||
|
||||
// It can be overridden via the setter
|
||||
a.setEncodeOptions({ keyFrame: true });
|
||||
expect(a.encodeOptions).toEqual({ keyFrame: true });
|
||||
|
||||
// Cloning carries the encode options over
|
||||
using aClone = a.clone();
|
||||
expect(aClone.encodeOptions).toEqual({ keyFrame: true });
|
||||
|
||||
// It can be set via the constructor
|
||||
using b = new VideoSample(canvas, { timestamp: 0, encodeOptions: { keyFrame: true } });
|
||||
expect(b.encodeOptions).toEqual({ keyFrame: true });
|
||||
});
|
||||
|
||||
test('encodeOptions, key frame forcing', async () => {
|
||||
const samples = Array.from({ length: 5 }, (_, i) => makeRedSample(i / 30, { keyFrame: true }));
|
||||
|
||||
await encodeAndAssertPacketTypes(
|
||||
samples,
|
||||
['key', 'key', 'key', 'key', 'key'],
|
||||
);
|
||||
});
|
||||
|
||||
test('encodeOptions add() override', async () => {
|
||||
const samples = Array.from({ length: 5 }, (_, i) => makeRedSample(i / 30, { keyFrame: true }));
|
||||
|
||||
await encodeAndAssertPacketTypes(
|
||||
samples,
|
||||
['key', 'delta', 'delta', 'delta', 'delta'],
|
||||
samples.map(() => ({ keyFrame: false })),
|
||||
);
|
||||
});
|
||||
|
||||
const makeRedSample = (timestamp: number, encodeOptions?: VideoEncoderEncodeOptions) => {
|
||||
const canvas = new OffscreenCanvas(100, 100);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillRect(0, 0, 100, 100);
|
||||
|
||||
return new VideoSample(canvas, {
|
||||
timestamp,
|
||||
duration: 1 / 30,
|
||||
encodeOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const encodeAndAssertPacketTypes = async (
|
||||
samples: VideoSample[],
|
||||
expectedPacketTypes: PacketType[],
|
||||
addOptions?: (VideoEncoderEncodeOptions | undefined)[],
|
||||
) => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
const videoSource = new VideoSampleSource({
|
||||
codec: 'vp8',
|
||||
bitrate: QUALITY_MEDIUM,
|
||||
onEncodedPacket: packet => expect(packet.type).toBe(expectedPacketTypes[i++]),
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
await output.start();
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
await videoSource.add(samples[i]!, addOptions?.[i]);
|
||||
samples[i]!.close();
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user