@mediabunny/server 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +276 -0
- package/dist/bundles/mediabunny-server.cjs +3361 -0
- package/dist/bundles/mediabunny-server.min.cjs +10 -0
- package/dist/bundles/mediabunny-server.min.mjs +9 -0
- package/dist/bundles/mediabunny-server.mjs +3334 -0
- package/dist/mediabunny-server.d.ts +105 -0
- package/dist/modules/src/audio-decoder.d.ts +21 -0
- package/dist/modules/src/audio-decoder.d.ts.map +1 -0
- package/dist/modules/src/audio-decoder.js +132 -0
- package/dist/modules/src/audio-encoder.d.ts +35 -0
- package/dist/modules/src/audio-encoder.d.ts.map +1 -0
- package/dist/modules/src/audio-encoder.js +329 -0
- package/dist/modules/src/audio-sample.d.ts +38 -0
- package/dist/modules/src/audio-sample.d.ts.map +1 -0
- package/dist/modules/src/audio-sample.js +119 -0
- package/dist/modules/src/index.d.ts +39 -0
- package/dist/modules/src/index.d.ts.map +1 -0
- package/dist/modules/src/index.js +132 -0
- package/dist/modules/src/misc.d.ts +26 -0
- package/dist/modules/src/misc.d.ts.map +1 -0
- package/dist/modules/src/misc.js +255 -0
- package/dist/modules/src/video-decoder.d.ts +30 -0
- package/dist/modules/src/video-decoder.d.ts.map +1 -0
- package/dist/modules/src/video-decoder.js +214 -0
- package/dist/modules/src/video-encoder.d.ts +35 -0
- package/dist/modules/src/video-encoder.d.ts.map +1 -0
- package/dist/modules/src/video-encoder.js +474 -0
- package/dist/modules/src/video-sample.d.ts +45 -0
- package/dist/modules/src/video-sample.d.ts.map +1 -0
- package/dist/modules/src/video-sample.js +276 -0
- package/dist/modules/tsconfig.tsbuildinfo +1 -0
- package/package.json +59 -0
- package/src/audio-decoder.ts +120 -0
- package/src/audio-encoder.ts +396 -0
- package/src/audio-sample.ts +101 -0
- package/src/index.ts +104 -0
- package/src/misc.ts +242 -0
- package/src/video-decoder.ts +224 -0
- package/src/video-encoder.ts +568 -0
- package/src/video-sample.ts +313 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026-present, Vanilagy and contributors
|
|
3
|
+
*
|
|
4
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type MaybePromise,
|
|
11
|
+
VideoSamplePixelFormat,
|
|
12
|
+
VideoSampleResource,
|
|
13
|
+
VideoSampleColorSpace,
|
|
14
|
+
SetRequired,
|
|
15
|
+
VideoSampleInit,
|
|
16
|
+
VideoSample,
|
|
17
|
+
VideoDataPlane,
|
|
18
|
+
VideoSampleTransformationDescription,
|
|
19
|
+
} from 'mediabunny';
|
|
20
|
+
import * as NodeAv from 'node-av';
|
|
21
|
+
import { assert, toUint8Array } from '../../../src/misc';
|
|
22
|
+
import {
|
|
23
|
+
toPixelFormat,
|
|
24
|
+
unmapColorPrimaries,
|
|
25
|
+
unmapTransferCharacteristics,
|
|
26
|
+
unmapMatrixCoefficients,
|
|
27
|
+
fromPixelFormat,
|
|
28
|
+
mapColorPrimaries,
|
|
29
|
+
mapMatrixCoefficients,
|
|
30
|
+
mapTransferCharacteristics,
|
|
31
|
+
} from './misc';
|
|
32
|
+
|
|
33
|
+
const JPEG_RANGE_PIX_FORMATS = new Set([
|
|
34
|
+
NodeAv.AV_PIX_FMT_YUVJ411P,
|
|
35
|
+
NodeAv.AV_PIX_FMT_YUVJ420P,
|
|
36
|
+
NodeAv.AV_PIX_FMT_YUVJ422P,
|
|
37
|
+
NodeAv.AV_PIX_FMT_YUVJ440P,
|
|
38
|
+
NodeAv.AV_PIX_FMT_YUVJ444P,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A custom `VideoSampleResource` backed by NodeAV's
|
|
43
|
+
* [`Frame`](https://seydx.github.io/node-av/api/lib/classes/Frame.html), which in turn is backed by FFmpeg's
|
|
44
|
+
* [`AVFrame`](https://ffmpeg.org/doxygen/2.7/structAVFrame.html). You can use this resource to create `VideoSample`
|
|
45
|
+
* instances that are directly backed by FFmpeg's `AVFrame` without data having to be copied. Since `AVFrame`s can
|
|
46
|
+
* themselves be backed by data on the GPU, this enables zero-copy hardware-accelerated decode and encode paths.
|
|
47
|
+
*
|
|
48
|
+
* When using Electron, you can directly create `Frame` instances without the data having to leave the GPU. For more,
|
|
49
|
+
* see [NodeAV's docs](https://seydx.github.io/node-av/api/lib/classes/Frame.html).
|
|
50
|
+
*
|
|
51
|
+
* When passed, the `Frame` is now owned by resource, meaning it takes care of closing the frame later. If you want to
|
|
52
|
+
* keep a copy for your own use, clone the frame first.
|
|
53
|
+
*
|
|
54
|
+
* @group \@mediabunny/server
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
export class AvFrameVideoSampleResource extends VideoSampleResource {
|
|
58
|
+
/** @internal */
|
|
59
|
+
_frame: NodeAv.Frame | null;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The NodeAV [`Frame`](https://seydx.github.io/node-av/api/lib/classes/Frame.html) instance backing this resource.
|
|
63
|
+
* Access throws if the resource has already been closed.
|
|
64
|
+
*/
|
|
65
|
+
get frame() {
|
|
66
|
+
if (!this._frame) {
|
|
67
|
+
throw new Error('AvFrameVideoSampleResource has been closed.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return this._frame;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
constructor(frame: NodeAv.Frame) {
|
|
74
|
+
super();
|
|
75
|
+
|
|
76
|
+
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_VIDEO) {
|
|
77
|
+
throw new Error('AvFrameVideoSampleResource must be initialized with a video frame.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._frame = frame;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getFormat(): VideoSamplePixelFormat | null {
|
|
84
|
+
return toPixelFormat(this.frame.format as NodeAv.AVPixelFormat);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getCodedWidth(): number {
|
|
88
|
+
return this.frame.width;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getCodedHeight(): number {
|
|
92
|
+
return this.frame.height;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getSquarePixelWidth(): number {
|
|
96
|
+
if (this.frame.sampleAspectRatio.num > this.frame.sampleAspectRatio.den) {
|
|
97
|
+
return Math.round(this.frame.width * this.frame.sampleAspectRatio.num / this.frame.sampleAspectRatio.den);
|
|
98
|
+
} else {
|
|
99
|
+
return this.frame.width;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getSquarePixelHeight(): number {
|
|
104
|
+
if (this.frame.sampleAspectRatio.num > this.frame.sampleAspectRatio.den) {
|
|
105
|
+
return this.frame.height;
|
|
106
|
+
} else {
|
|
107
|
+
return Math.round(this.frame.height * this.frame.sampleAspectRatio.den / this.frame.sampleAspectRatio.num);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getColorSpace(): VideoSampleColorSpace {
|
|
112
|
+
return new VideoSampleColorSpace({
|
|
113
|
+
primaries: unmapColorPrimaries(this.frame.colorPrimaries) as VideoColorPrimaries | null,
|
|
114
|
+
transfer: unmapTransferCharacteristics(this.frame.colorTrc) as VideoTransferCharacteristics | null,
|
|
115
|
+
matrix: unmapMatrixCoefficients(this.frame.colorSpace) as VideoMatrixCoefficients | null,
|
|
116
|
+
fullRange: this.frame.colorRange === NodeAv.AVCOL_RANGE_JPEG
|
|
117
|
+
|| JPEG_RANGE_PIX_FORMATS.has(this.frame.format as NodeAv.AVPixelFormat)
|
|
118
|
+
? true
|
|
119
|
+
: this.frame.colorRange === NodeAv.AVCOL_RANGE_MPEG
|
|
120
|
+
? false
|
|
121
|
+
: null,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close(): void {
|
|
126
|
+
this.frame.free();
|
|
127
|
+
this._frame = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getDataPlanes(): MaybePromise<VideoDataPlane[]> {
|
|
131
|
+
assert(this.frame.data);
|
|
132
|
+
|
|
133
|
+
return this.frame.data.map((data, i) => ({
|
|
134
|
+
data: toUint8Array(data),
|
|
135
|
+
stride: this.frame.linesize[i]!,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async toRgbSample(
|
|
140
|
+
init: SetRequired<VideoSampleInit, 'timestamp'>,
|
|
141
|
+
// Will respect it when somebody complains
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
143
|
+
colorSpace: PredefinedColorSpace,
|
|
144
|
+
): Promise<VideoSample> {
|
|
145
|
+
const width = this.frame.width;
|
|
146
|
+
const height = this.frame.height;
|
|
147
|
+
|
|
148
|
+
const scaler = new NodeAv.SoftwareScaleContext();
|
|
149
|
+
const srcFmt = this.frame.format as NodeAv.AVPixelFormat;
|
|
150
|
+
const dstFmt = fromPixelFormat('RGBA');
|
|
151
|
+
|
|
152
|
+
scaler.getContext(
|
|
153
|
+
width, height, srcFmt,
|
|
154
|
+
width, height, dstFmt,
|
|
155
|
+
NodeAv.SWS_BILINEAR,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const dstFrame = new NodeAv.Frame();
|
|
159
|
+
dstFrame.width = width;
|
|
160
|
+
dstFrame.height = height;
|
|
161
|
+
dstFrame.format = dstFmt;
|
|
162
|
+
dstFrame.alloc();
|
|
163
|
+
dstFrame.allocBuffer();
|
|
164
|
+
|
|
165
|
+
const srcFrame = this.frame;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await scaler.scaleFrame(dstFrame, srcFrame);
|
|
169
|
+
} finally {
|
|
170
|
+
scaler.freeContext();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
dstFrame.sampleAspectRatio = srcFrame.sampleAspectRatio;
|
|
174
|
+
|
|
175
|
+
return new VideoSample(new AvFrameVideoSampleResource(dstFrame), init);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const copyVideoSampleToAvFrame = async (sample: VideoSample, frame: NodeAv.Frame, lastBuffer: Buffer | null) => {
|
|
180
|
+
assert(sample.format !== null);
|
|
181
|
+
|
|
182
|
+
frame.format = fromPixelFormat(sample.format);
|
|
183
|
+
frame.width = sample.codedWidth;
|
|
184
|
+
frame.height = sample.codedHeight;
|
|
185
|
+
frame.sampleAspectRatio = new NodeAv.Rational(
|
|
186
|
+
sample.pixelAspectRatio.num,
|
|
187
|
+
sample.pixelAspectRatio.den,
|
|
188
|
+
);
|
|
189
|
+
frame.colorPrimaries = mapColorPrimaries(sample.colorSpace.primaries ?? 'unknown')
|
|
190
|
+
?? NodeAv.AVCOL_PRI_UNSPECIFIED;
|
|
191
|
+
frame.colorSpace = mapMatrixCoefficients(sample.colorSpace.matrix ?? 'unknown')
|
|
192
|
+
?? NodeAv.AVCOL_SPC_UNSPECIFIED;
|
|
193
|
+
frame.colorTrc = mapTransferCharacteristics(sample.colorSpace.transfer ?? 'unknown')
|
|
194
|
+
?? NodeAv.AVCOL_TRC_UNSPECIFIED;
|
|
195
|
+
frame.colorRange = sample.colorSpace.fullRange === false
|
|
196
|
+
? NodeAv.AVCOL_RANGE_MPEG
|
|
197
|
+
: sample.colorSpace.fullRange === true
|
|
198
|
+
? NodeAv.AVCOL_RANGE_JPEG
|
|
199
|
+
: NodeAv.AVCOL_RANGE_UNSPECIFIED;
|
|
200
|
+
|
|
201
|
+
const size = sample.allocationSize();
|
|
202
|
+
if (!lastBuffer || lastBuffer.byteLength !== size) {
|
|
203
|
+
lastBuffer = Buffer.from({ length: size });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await sample.copyTo(lastBuffer);
|
|
207
|
+
frame.fromBuffer(lastBuffer);
|
|
208
|
+
|
|
209
|
+
return lastBuffer;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const transformVideoSample = async (
|
|
213
|
+
sample: VideoSample,
|
|
214
|
+
description: VideoSampleTransformationDescription,
|
|
215
|
+
): Promise<VideoSample | null> => {
|
|
216
|
+
let srcFrame: NodeAv.Frame;
|
|
217
|
+
let srcFrameOwned = false;
|
|
218
|
+
|
|
219
|
+
if (sample._data instanceof AvFrameVideoSampleResource) {
|
|
220
|
+
srcFrame = sample._data.frame;
|
|
221
|
+
} else {
|
|
222
|
+
if (sample.format === null) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
srcFrame = new NodeAv.Frame();
|
|
227
|
+
srcFrame.alloc();
|
|
228
|
+
srcFrameOwned = true;
|
|
229
|
+
|
|
230
|
+
await copyVideoSampleToAvFrame(sample, srcFrame, null);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build the filter chain. Order: square-pixel normalize -> rotate -> crop -> resize-with-fit.
|
|
234
|
+
const chain: string[] = [];
|
|
235
|
+
|
|
236
|
+
if (sample.squarePixelWidth !== sample.codedWidth || sample.squarePixelHeight !== sample.codedHeight) {
|
|
237
|
+
chain.push(`scale=${sample.squarePixelWidth}:${sample.squarePixelHeight}`);
|
|
238
|
+
chain.push('setsar=1');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (description.rotation === 90) {
|
|
242
|
+
chain.push('transpose=1');
|
|
243
|
+
} else if (description.rotation === 180) {
|
|
244
|
+
chain.push('transpose=1,transpose=1');
|
|
245
|
+
} else if (description.rotation === 270) {
|
|
246
|
+
chain.push('transpose=2');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
chain.push(`crop=${Math.round(description.crop.width)}:${Math.round(description.crop.height)}`
|
|
250
|
+
+ `:${Math.round(description.crop.left)}:${Math.round(description.crop.top)}`);
|
|
251
|
+
|
|
252
|
+
if (description.fit === 'fill') {
|
|
253
|
+
chain.push(`scale=${description.width}:${description.height}`);
|
|
254
|
+
} else if (description.fit === 'contain') {
|
|
255
|
+
chain.push(`scale=${description.width}:${description.height}:force_original_aspect_ratio=decrease`);
|
|
256
|
+
chain.push(`pad=${description.width}:${description.height}:(ow-iw)/2:(oh-ih)/2:color=black@0`);
|
|
257
|
+
} else if (description.fit === 'cover') {
|
|
258
|
+
chain.push(`scale=${description.width}:${description.height}:force_original_aspect_ratio=increase`);
|
|
259
|
+
chain.push(`crop=${description.width}:${description.height}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
chain.push('setsar=1');
|
|
263
|
+
|
|
264
|
+
const graph = new NodeAv.FilterGraph();
|
|
265
|
+
graph.alloc();
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const srcArgs = `video_size=${srcFrame.width}x${srcFrame.height}`
|
|
269
|
+
+ `:pix_fmt=${srcFrame.format}`
|
|
270
|
+
+ `:time_base=1/1000000`
|
|
271
|
+
+ `:pixel_aspect=${sample.pixelAspectRatio.num}/${sample.pixelAspectRatio.den}`;
|
|
272
|
+
|
|
273
|
+
const bufferSrc = graph.createFilter(NodeAv.Filter.getByName('buffer')!, 'src', srcArgs);
|
|
274
|
+
const bufferSink = graph.createFilter(NodeAv.Filter.getByName('buffersink')!, 'sink');
|
|
275
|
+
assert(bufferSrc && bufferSink);
|
|
276
|
+
|
|
277
|
+
// The naming here looks inverted but matches FFmpeg's parse semantics: from the parsed chain's
|
|
278
|
+
// perspective, its inputs are fed by the graph's existing outputs (the buffer src), and its outputs
|
|
279
|
+
// feed the graph's existing inputs (the buffer sink).
|
|
280
|
+
const outputs = NodeAv.FilterInOut.createList([{ name: 'in', filterCtx: bufferSrc, padIdx: 0 }]);
|
|
281
|
+
const inputs = NodeAv.FilterInOut.createList([{ name: 'out', filterCtx: bufferSink, padIdx: 0 }]);
|
|
282
|
+
|
|
283
|
+
const parseRet = graph.parsePtr(`[in]${chain.join(',')}[out]`, inputs, outputs);
|
|
284
|
+
NodeAv.FFmpegError.throwIfError(parseRet, 'FilterGraph.parsePtr');
|
|
285
|
+
|
|
286
|
+
const configRet = await graph.config();
|
|
287
|
+
NodeAv.FFmpegError.throwIfError(configRet, 'FilterGraph.config');
|
|
288
|
+
|
|
289
|
+
const addRet = await bufferSrc.buffersrcAddFrame(srcFrame);
|
|
290
|
+
NodeAv.FFmpegError.throwIfError(addRet, 'buffersrcAddFrame');
|
|
291
|
+
|
|
292
|
+
// Flush - we only ever push a single frame through this graph.
|
|
293
|
+
await bufferSrc.buffersrcAddFrame(null);
|
|
294
|
+
|
|
295
|
+
const dstFrame = new NodeAv.Frame();
|
|
296
|
+
dstFrame.alloc();
|
|
297
|
+
|
|
298
|
+
const getRet = await bufferSink.buffersinkGetFrame(dstFrame);
|
|
299
|
+
NodeAv.FFmpegError.throwIfError(getRet, 'buffersinkGetFrame');
|
|
300
|
+
|
|
301
|
+
return new VideoSample(new AvFrameVideoSampleResource(dstFrame), {
|
|
302
|
+
timestamp: sample.timestamp,
|
|
303
|
+
duration: sample.duration,
|
|
304
|
+
rotation: 0, // baked in by the filter graph
|
|
305
|
+
});
|
|
306
|
+
} finally {
|
|
307
|
+
graph.free();
|
|
308
|
+
|
|
309
|
+
if (srcFrameOwned) {
|
|
310
|
+
srcFrame.free();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|