mediabunny 1.0.2 → 1.0.4
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/README.md +4 -4
- package/dist/mediabunny.d.ts +4 -2
- package/dist/modules/codec-data.d.ts +95 -0
- package/dist/modules/codec-data.d.ts.map +1 -0
- package/dist/modules/codec-data.js +1103 -0
- package/dist/modules/codec.d.ts +250 -0
- package/dist/modules/codec.d.ts.map +1 -0
- package/dist/modules/codec.js +1100 -0
- package/dist/modules/conversion.d.ts +158 -0
- package/dist/modules/conversion.d.ts.map +1 -0
- package/dist/modules/conversion.js +866 -0
- package/dist/modules/custom-coder.d.ts +119 -0
- package/dist/modules/custom-coder.d.ts.map +1 -0
- package/dist/modules/custom-coder.js +91 -0
- package/dist/modules/demuxer.d.ts +17 -0
- package/dist/modules/demuxer.d.ts.map +1 -0
- package/dist/modules/demuxer.js +12 -0
- package/dist/modules/index.d.ts +25 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +24 -0
- package/dist/modules/input-format.d.ts +121 -0
- package/dist/modules/input-format.d.ts.map +1 -0
- package/dist/modules/input-format.js +333 -0
- package/dist/modules/input-track.d.ts +138 -0
- package/dist/modules/input-track.d.ts.map +1 -0
- package/dist/modules/input-track.js +230 -0
- package/dist/modules/input.d.ts +55 -0
- package/dist/modules/input.d.ts.map +1 -0
- package/dist/modules/input.js +104 -0
- package/dist/modules/isobmff/isobmff-boxes.d.ts +202 -0
- package/dist/modules/isobmff/isobmff-boxes.d.ts.map +1 -0
- package/dist/modules/isobmff/isobmff-boxes.js +1111 -0
- package/dist/modules/isobmff/isobmff-demuxer.d.ts +163 -0
- package/dist/modules/isobmff/isobmff-demuxer.d.ts.map +1 -0
- package/dist/modules/isobmff/isobmff-demuxer.js +2080 -0
- package/dist/modules/isobmff/isobmff-misc.d.ts +14 -0
- package/dist/modules/isobmff/isobmff-misc.d.ts.map +1 -0
- package/dist/modules/isobmff/isobmff-misc.js +20 -0
- package/dist/modules/isobmff/isobmff-muxer.d.ts +142 -0
- package/dist/modules/isobmff/isobmff-muxer.d.ts.map +1 -0
- package/dist/modules/isobmff/isobmff-muxer.js +858 -0
- package/dist/modules/isobmff/isobmff-reader.d.ts +36 -0
- package/dist/modules/isobmff/isobmff-reader.d.ts.map +1 -0
- package/dist/modules/isobmff/isobmff-reader.js +105 -0
- package/dist/modules/matroska/ebml.d.ts +156 -0
- package/dist/modules/matroska/ebml.d.ts.map +1 -0
- package/dist/modules/matroska/ebml.js +529 -0
- package/dist/modules/matroska/matroska-demuxer.d.ts +119 -0
- package/dist/modules/matroska/matroska-demuxer.d.ts.map +1 -0
- package/dist/modules/matroska/matroska-demuxer.js +1330 -0
- package/dist/modules/matroska/matroska-misc.d.ts +14 -0
- package/dist/modules/matroska/matroska-misc.d.ts.map +1 -0
- package/dist/modules/matroska/matroska-misc.js +20 -0
- package/dist/modules/matroska/matroska-muxer.d.ts +71 -0
- package/dist/modules/matroska/matroska-muxer.d.ts.map +1 -0
- package/dist/modules/matroska/matroska-muxer.js +729 -0
- package/dist/modules/media-sink.d.ts +272 -0
- package/dist/modules/media-sink.d.ts.map +1 -0
- package/dist/modules/media-sink.js +1264 -0
- package/dist/modules/media-source.d.ts +230 -0
- package/dist/modules/media-source.d.ts.map +1 -0
- package/dist/modules/media-source.js +1095 -0
- package/dist/modules/misc.d.ts +127 -0
- package/dist/modules/misc.d.ts.map +1 -0
- package/dist/modules/misc.js +462 -0
- package/dist/modules/mp3/mp3-demuxer.d.ts +32 -0
- package/dist/modules/mp3/mp3-demuxer.d.ts.map +1 -0
- package/dist/modules/mp3/mp3-demuxer.js +160 -0
- package/dist/modules/mp3/mp3-misc.d.ts +37 -0
- package/dist/modules/mp3/mp3-misc.d.ts.map +1 -0
- package/dist/modules/mp3/mp3-misc.js +122 -0
- package/dist/modules/mp3/mp3-muxer.d.ts +27 -0
- package/dist/modules/mp3/mp3-muxer.d.ts.map +1 -0
- package/dist/modules/mp3/mp3-muxer.js +112 -0
- package/dist/modules/mp3/mp3-reader.d.ts +25 -0
- package/dist/modules/mp3/mp3-reader.d.ts.map +1 -0
- package/dist/modules/mp3/mp3-reader.js +73 -0
- package/dist/modules/mp3/mp3-writer.d.ts +30 -0
- package/dist/modules/mp3/mp3-writer.d.ts.map +1 -0
- package/dist/modules/mp3/mp3-writer.js +72 -0
- package/dist/modules/muxer.d.ts +32 -0
- package/dist/modules/muxer.d.ts.map +1 -0
- package/dist/modules/muxer.js +50 -0
- package/dist/modules/ogg/ogg-demuxer.d.ts +53 -0
- package/dist/modules/ogg/ogg-demuxer.d.ts.map +1 -0
- package/dist/modules/ogg/ogg-demuxer.js +701 -0
- package/dist/modules/ogg/ogg-misc.d.ts +27 -0
- package/dist/modules/ogg/ogg-misc.d.ts.map +1 -0
- package/dist/modules/ogg/ogg-misc.js +78 -0
- package/dist/modules/ogg/ogg-muxer.d.ts +58 -0
- package/dist/modules/ogg/ogg-muxer.d.ts.map +1 -0
- package/dist/modules/ogg/ogg-muxer.js +345 -0
- package/dist/modules/ogg/ogg-reader.d.ts +37 -0
- package/dist/modules/ogg/ogg-reader.d.ts.map +1 -0
- package/dist/modules/ogg/ogg-reader.js +104 -0
- package/dist/modules/output-format.d.ts +285 -0
- package/dist/modules/output-format.d.ts.map +1 -0
- package/dist/modules/output-format.js +385 -0
- package/dist/modules/output.d.ts +134 -0
- package/dist/modules/output.d.ts.map +1 -0
- package/dist/modules/output.js +269 -0
- package/dist/modules/packet.d.ts +86 -0
- package/dist/modules/packet.d.ts.map +1 -0
- package/dist/modules/packet.js +133 -0
- package/dist/modules/pcm.d.ts +12 -0
- package/dist/modules/pcm.d.ts.map +1 -0
- package/dist/modules/pcm.js +85 -0
- package/dist/modules/reader.d.ts +40 -0
- package/dist/modules/reader.d.ts.map +1 -0
- package/dist/modules/reader.js +153 -0
- package/dist/modules/sample.d.ts +186 -0
- package/dist/modules/sample.d.ts.map +1 -0
- package/dist/modules/sample.js +798 -0
- package/dist/modules/source.d.ts +76 -0
- package/dist/modules/source.d.ts.map +1 -0
- package/dist/modules/source.js +183 -0
- package/dist/modules/subtitles.d.ts +37 -0
- package/dist/modules/subtitles.d.ts.map +1 -0
- package/dist/modules/subtitles.js +89 -0
- package/dist/modules/target.d.ts +58 -0
- package/dist/modules/target.d.ts.map +1 -0
- package/dist/modules/target.js +63 -0
- package/dist/modules/wave/riff-reader.d.ts +19 -0
- package/dist/modules/wave/riff-reader.d.ts.map +1 -0
- package/dist/modules/wave/riff-reader.js +38 -0
- package/dist/modules/wave/riff-writer.d.ts +18 -0
- package/dist/modules/wave/riff-writer.d.ts.map +1 -0
- package/dist/modules/wave/riff-writer.js +25 -0
- package/dist/modules/wave/wave-demuxer.d.ts +42 -0
- package/dist/modules/wave/wave-demuxer.d.ts.map +1 -0
- package/dist/modules/wave/wave-demuxer.js +239 -0
- package/dist/modules/wave/wave-muxer.d.ts +27 -0
- package/dist/modules/wave/wave-muxer.d.ts.map +1 -0
- package/dist/modules/wave/wave-muxer.js +108 -0
- package/dist/modules/writer.d.ts +85 -0
- package/dist/modules/writer.d.ts.map +1 -0
- package/dist/modules/writer.js +347 -0
- package/package.json +14 -10
- package/src/codec-data.ts +1394 -0
- package/src/codec.ts +1392 -0
- package/src/conversion.ts +1199 -0
- package/src/custom-coder.ts +162 -0
- package/src/demuxer.ts +22 -0
- package/src/index.ts +148 -0
- package/src/input-format.ts +386 -0
- package/src/input-track.ts +341 -0
- package/src/input.ts +138 -0
- package/src/isobmff/isobmff-boxes.ts +1365 -0
- package/src/isobmff/isobmff-demuxer.ts +2646 -0
- package/src/isobmff/isobmff-misc.ts +29 -0
- package/src/isobmff/isobmff-muxer.ts +1210 -0
- package/src/isobmff/isobmff-reader.ts +137 -0
- package/src/matroska/ebml.ts +595 -0
- package/src/matroska/matroska-demuxer.ts +1677 -0
- package/src/matroska/matroska-misc.ts +29 -0
- package/src/matroska/matroska-muxer.ts +999 -0
- package/src/media-sink.ts +1577 -0
- package/src/media-source.ts +1406 -0
- package/src/misc.ts +584 -0
- package/src/mp3/mp3-demuxer.ts +228 -0
- package/src/mp3/mp3-misc.ts +154 -0
- package/src/mp3/mp3-muxer.ts +153 -0
- package/src/mp3/mp3-reader.ts +93 -0
- package/src/mp3/mp3-writer.ts +112 -0
- package/src/muxer.ts +88 -0
- package/src/ogg/ogg-demuxer.ts +985 -0
- package/src/ogg/ogg-misc.ts +116 -0
- package/src/ogg/ogg-muxer.ts +480 -0
- package/src/ogg/ogg-reader.ts +143 -0
- package/src/output-format.ts +689 -0
- package/src/output.ts +427 -0
- package/src/packet.ts +180 -0
- package/src/pcm.ts +112 -0
- package/src/reader.ts +205 -0
- package/src/sample.ts +1029 -0
- package/src/source.ts +279 -0
- package/src/subtitles.ts +131 -0
- package/src/target.ts +106 -0
- package/src/tsconfig.json +13 -0
- package/src/wave/riff-reader.ts +48 -0
- package/src/wave/riff-writer.ts +30 -0
- package/src/wave/wave-demuxer.ts +317 -0
- package/src/wave/wave-muxer.ts +145 -0
- package/src/writer.ts +461 -0
- /package/dist/{mediabunny.js → bundles/mediabunny.cjs} +0 -0
- /package/dist/{mediabunny.min.js → bundles/mediabunny.min.cjs} +0 -0
- /package/dist/{mediabunny.min.mjs → bundles/mediabunny.min.mjs} +0 -0
- /package/dist/{mediabunny.mjs → bundles/mediabunny.mjs} +0 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025-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
|
+
import { AUDIO_CODECS, getFirstEncodableVideoCodec, getEncodableAudioCodecs, NON_PCM_AUDIO_CODECS, Quality, QUALITY_HIGH, VIDEO_CODECS, } from './codec.js';
|
|
9
|
+
import { Input } from './input.js';
|
|
10
|
+
import { AudioSampleSink, CanvasSink, EncodedPacketSink, VideoSampleSink, } from './media-sink.js';
|
|
11
|
+
import { EncodedVideoPacketSource, EncodedAudioPacketSource, VideoSampleSource, AudioSampleSource, } from './media-source.js';
|
|
12
|
+
import { assert, clamp, normalizeRotation, promiseWithResolvers } from './misc.js';
|
|
13
|
+
import { Output } from './output.js';
|
|
14
|
+
import { AudioSample, VideoSample } from './sample.js';
|
|
15
|
+
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
|
16
|
+
const FALLBACK_SAMPLE_RATE = 48000;
|
|
17
|
+
/**
|
|
18
|
+
* Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
|
|
19
|
+
* this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export class Conversion {
|
|
23
|
+
/** Initializes a new conversion process without starting the conversion. */
|
|
24
|
+
static async init(options) {
|
|
25
|
+
const conversion = new Conversion(options);
|
|
26
|
+
await conversion._init();
|
|
27
|
+
return conversion;
|
|
28
|
+
}
|
|
29
|
+
constructor(options) {
|
|
30
|
+
/** @internal */
|
|
31
|
+
this._addedCounts = {
|
|
32
|
+
video: 0,
|
|
33
|
+
audio: 0,
|
|
34
|
+
subtitle: 0,
|
|
35
|
+
};
|
|
36
|
+
/** @internal */
|
|
37
|
+
this._totalTrackCount = 0;
|
|
38
|
+
/** @internal */
|
|
39
|
+
this._trackPromises = [];
|
|
40
|
+
/** @internal */
|
|
41
|
+
this._executed = false;
|
|
42
|
+
/** @internal */
|
|
43
|
+
this._synchronizer = new TrackSynchronizer();
|
|
44
|
+
/** @internal */
|
|
45
|
+
this._totalDuration = null;
|
|
46
|
+
/** @internal */
|
|
47
|
+
this._maxTimestamps = new Map(); // Track ID -> timestamp
|
|
48
|
+
/** @internal */
|
|
49
|
+
this._canceled = false;
|
|
50
|
+
/**
|
|
51
|
+
* A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the
|
|
52
|
+
* completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete;
|
|
53
|
+
* the conversion is complete once `execute` resolves.
|
|
54
|
+
*
|
|
55
|
+
* In order for progress to be computed, this property must be set before `execute` is called.
|
|
56
|
+
*/
|
|
57
|
+
this.onProgress = undefined;
|
|
58
|
+
/** @internal */
|
|
59
|
+
this._computeProgress = false;
|
|
60
|
+
/** @internal */
|
|
61
|
+
this._lastProgress = 0;
|
|
62
|
+
/** The list of tracks that are included in the output file. */
|
|
63
|
+
this.utilizedTracks = [];
|
|
64
|
+
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */
|
|
65
|
+
this.discardedTracks = [];
|
|
66
|
+
if (!options || typeof options !== 'object') {
|
|
67
|
+
throw new TypeError('options must be an object.');
|
|
68
|
+
}
|
|
69
|
+
if (!(options.input instanceof Input)) {
|
|
70
|
+
throw new TypeError('options.input must be an Input.');
|
|
71
|
+
}
|
|
72
|
+
if (!(options.output instanceof Output)) {
|
|
73
|
+
throw new TypeError('options.output must be an Output.');
|
|
74
|
+
}
|
|
75
|
+
if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
|
|
76
|
+
throw new TypeError('options.output must be fresh: no tracks added and not started.');
|
|
77
|
+
}
|
|
78
|
+
if (options.video !== undefined && (!options.video || typeof options.video !== 'object')) {
|
|
79
|
+
throw new TypeError('options.video, when provided, must be an object.');
|
|
80
|
+
}
|
|
81
|
+
if (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') {
|
|
82
|
+
throw new TypeError('options.video.discard, when provided, must be a boolean.');
|
|
83
|
+
}
|
|
84
|
+
if (options.video?.forceTranscode !== undefined && typeof options.video.forceTranscode !== 'boolean') {
|
|
85
|
+
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
|
|
86
|
+
}
|
|
87
|
+
if (options.video?.codec !== undefined && !VIDEO_CODECS.includes(options.video.codec)) {
|
|
88
|
+
throw new TypeError(`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`);
|
|
89
|
+
}
|
|
90
|
+
if (options.video?.bitrate !== undefined
|
|
91
|
+
&& !(options.video.bitrate instanceof Quality)
|
|
92
|
+
&& (!Number.isInteger(options.video.bitrate) || options.video.bitrate <= 0)) {
|
|
93
|
+
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
|
|
94
|
+
}
|
|
95
|
+
if (options.video?.width !== undefined
|
|
96
|
+
&& (!Number.isInteger(options.video.width) || options.video.width <= 0)) {
|
|
97
|
+
throw new TypeError('options.video.width, when provided, must be a positive integer.');
|
|
98
|
+
}
|
|
99
|
+
if (options.video?.height !== undefined
|
|
100
|
+
&& (!Number.isInteger(options.video.height) || options.video.height <= 0)) {
|
|
101
|
+
throw new TypeError('options.video.height, when provided, must be a positive integer.');
|
|
102
|
+
}
|
|
103
|
+
if (options.video?.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.video.fit)) {
|
|
104
|
+
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
|
|
105
|
+
}
|
|
106
|
+
if (options.video?.width !== undefined
|
|
107
|
+
&& options.video.height !== undefined
|
|
108
|
+
&& options.video.fit === undefined) {
|
|
109
|
+
throw new TypeError('When both options.video.width and options.video.height are provided, options.video.fit must also be'
|
|
110
|
+
+ ' provided.');
|
|
111
|
+
}
|
|
112
|
+
if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
|
|
113
|
+
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
|
114
|
+
}
|
|
115
|
+
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
|
|
116
|
+
throw new TypeError('options.video, when provided, must be an object.');
|
|
117
|
+
}
|
|
118
|
+
if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
|
|
119
|
+
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
|
120
|
+
}
|
|
121
|
+
if (options.audio?.forceTranscode !== undefined && typeof options.audio.forceTranscode !== 'boolean') {
|
|
122
|
+
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
|
|
123
|
+
}
|
|
124
|
+
if (options.audio?.codec !== undefined && !AUDIO_CODECS.includes(options.audio.codec)) {
|
|
125
|
+
throw new TypeError(`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`);
|
|
126
|
+
}
|
|
127
|
+
if (options.audio?.bitrate !== undefined
|
|
128
|
+
&& !(options.audio.bitrate instanceof Quality)
|
|
129
|
+
&& (!Number.isInteger(options.audio.bitrate) || options.audio.bitrate <= 0)) {
|
|
130
|
+
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
|
|
131
|
+
}
|
|
132
|
+
if (options.audio?.numberOfChannels !== undefined
|
|
133
|
+
&& (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0)) {
|
|
134
|
+
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
|
|
135
|
+
}
|
|
136
|
+
if (options.audio?.sampleRate !== undefined
|
|
137
|
+
&& (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0)) {
|
|
138
|
+
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
|
139
|
+
}
|
|
140
|
+
if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
|
|
141
|
+
throw new TypeError('options.trim, when provided, must be an object.');
|
|
142
|
+
}
|
|
143
|
+
if (options.trim?.start !== undefined && (!Number.isFinite(options.trim.start) || options.trim.start < 0)) {
|
|
144
|
+
throw new TypeError('options.trim.start, when provided, must be a non-negative number.');
|
|
145
|
+
}
|
|
146
|
+
if (options.trim?.end !== undefined && (!Number.isFinite(options.trim.end) || options.trim.end < 0)) {
|
|
147
|
+
throw new TypeError('options.trim.end, when provided, must be a non-negative number.');
|
|
148
|
+
}
|
|
149
|
+
if (options.trim?.start !== undefined
|
|
150
|
+
&& options.trim.end !== undefined
|
|
151
|
+
&& options.trim.start >= options.trim.end) {
|
|
152
|
+
throw new TypeError('options.trim.start must be less than options.trim.end.');
|
|
153
|
+
}
|
|
154
|
+
this._options = options;
|
|
155
|
+
this.input = options.input;
|
|
156
|
+
this.output = options.output;
|
|
157
|
+
this._startTimestamp = options.trim?.start ?? 0;
|
|
158
|
+
this._endTimestamp = options.trim?.end ?? Infinity;
|
|
159
|
+
const { promise: started, resolve: start } = promiseWithResolvers();
|
|
160
|
+
this._started = started;
|
|
161
|
+
this._start = start;
|
|
162
|
+
}
|
|
163
|
+
/** @internal */
|
|
164
|
+
async _init() {
|
|
165
|
+
const inputTracks = await this.input.getTracks();
|
|
166
|
+
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
|
|
167
|
+
for (const track of inputTracks) {
|
|
168
|
+
if (track.isVideoTrack() && this._options.video?.discard) {
|
|
169
|
+
this.discardedTracks.push({
|
|
170
|
+
track,
|
|
171
|
+
reason: 'discarded_by_user',
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (track.isAudioTrack() && this._options.audio?.discard) {
|
|
176
|
+
this.discardedTracks.push({
|
|
177
|
+
track,
|
|
178
|
+
reason: 'discarded_by_user',
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
|
183
|
+
this.discardedTracks.push({
|
|
184
|
+
track,
|
|
185
|
+
reason: 'max_track_count_reached',
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) {
|
|
190
|
+
this.discardedTracks.push({
|
|
191
|
+
track,
|
|
192
|
+
reason: 'max_track_count_of_type_reached',
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (track.isVideoTrack()) {
|
|
197
|
+
await this._processVideoTrack(track);
|
|
198
|
+
}
|
|
199
|
+
else if (track.isAudioTrack()) {
|
|
200
|
+
await this._processAudioTrack(track);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
|
|
204
|
+
if (unintentionallyDiscardedTracks.length > 0) {
|
|
205
|
+
// Let's give the user a notice/warning about discarded tracks so they aren't confused
|
|
206
|
+
console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Executes the conversion process. Resolves once conversion is complete. */
|
|
210
|
+
async execute() {
|
|
211
|
+
if (this._executed) {
|
|
212
|
+
throw new Error('Conversion cannot be executed twice.');
|
|
213
|
+
}
|
|
214
|
+
this._executed = true;
|
|
215
|
+
if (this.onProgress) {
|
|
216
|
+
this._computeProgress = true;
|
|
217
|
+
this._totalDuration = Math.min(await this.input.computeDuration() - this._startTimestamp, this._endTimestamp - this._startTimestamp);
|
|
218
|
+
this.onProgress?.(0);
|
|
219
|
+
}
|
|
220
|
+
await this.output.start();
|
|
221
|
+
this._start();
|
|
222
|
+
try {
|
|
223
|
+
await Promise.all(this._trackPromises);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!this._canceled) {
|
|
227
|
+
// Make sure to cancel to stop other encoding processes and clean up resources
|
|
228
|
+
await this.cancel();
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
if (this._canceled) {
|
|
233
|
+
await new Promise(() => { }); // Never resolve
|
|
234
|
+
}
|
|
235
|
+
await this.output.finalize();
|
|
236
|
+
if (this._computeProgress) {
|
|
237
|
+
this.onProgress?.(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Cancels the conversion process. Does nothing if the conversion is already complete. */
|
|
241
|
+
async cancel() {
|
|
242
|
+
if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (this._canceled) {
|
|
246
|
+
console.warn('Conversion already canceled.');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this._canceled = true;
|
|
250
|
+
await this.output.cancel();
|
|
251
|
+
}
|
|
252
|
+
/** @internal */
|
|
253
|
+
async _processVideoTrack(track) {
|
|
254
|
+
const sourceCodec = track.codec;
|
|
255
|
+
if (!sourceCodec) {
|
|
256
|
+
this.discardedTracks.push({
|
|
257
|
+
track,
|
|
258
|
+
reason: 'unknown_source_codec',
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let videoSource;
|
|
263
|
+
const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0));
|
|
264
|
+
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
|
|
265
|
+
const [originalWidth, originalHeight] = totalRotation % 180 === 0
|
|
266
|
+
? [track.codedWidth, track.codedHeight]
|
|
267
|
+
: [track.codedHeight, track.codedWidth];
|
|
268
|
+
let width = originalWidth;
|
|
269
|
+
let height = originalHeight;
|
|
270
|
+
const aspectRatio = width / height;
|
|
271
|
+
// A lot of video encoders require that the dimensions be multiples of 2
|
|
272
|
+
const ceilToMultipleOfTwo = (value) => Math.ceil(value / 2) * 2;
|
|
273
|
+
if (this._options.video?.width !== undefined && this._options.video.height === undefined) {
|
|
274
|
+
width = ceilToMultipleOfTwo(this._options.video.width);
|
|
275
|
+
height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
|
|
276
|
+
}
|
|
277
|
+
else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) {
|
|
278
|
+
height = ceilToMultipleOfTwo(this._options.video.height);
|
|
279
|
+
width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
|
|
280
|
+
}
|
|
281
|
+
else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) {
|
|
282
|
+
width = ceilToMultipleOfTwo(this._options.video.width);
|
|
283
|
+
height = ceilToMultipleOfTwo(this._options.video.height);
|
|
284
|
+
}
|
|
285
|
+
const firstTimestamp = await track.getFirstTimestamp();
|
|
286
|
+
const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0;
|
|
287
|
+
const needsRerender = width !== originalWidth
|
|
288
|
+
|| height !== originalHeight
|
|
289
|
+
|| (totalRotation !== 0 && !outputSupportsRotation);
|
|
290
|
+
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
|
291
|
+
if (!needsTranscode
|
|
292
|
+
&& !this._options.video?.bitrate
|
|
293
|
+
&& !needsRerender
|
|
294
|
+
&& videoCodecs.includes(sourceCodec)
|
|
295
|
+
&& (!this._options.video?.codec || this._options.video?.codec === sourceCodec)) {
|
|
296
|
+
// Fast path, we can simply copy over the encoded packets
|
|
297
|
+
const source = new EncodedVideoPacketSource(sourceCodec);
|
|
298
|
+
videoSource = source;
|
|
299
|
+
this._trackPromises.push((async () => {
|
|
300
|
+
await this._started;
|
|
301
|
+
const sink = new EncodedPacketSink(track);
|
|
302
|
+
const decoderConfig = await track.getDecoderConfig();
|
|
303
|
+
const meta = { decoderConfig: decoderConfig ?? undefined };
|
|
304
|
+
const endPacket = Number.isFinite(this._endTimestamp)
|
|
305
|
+
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
|
|
306
|
+
: undefined;
|
|
307
|
+
for await (const packet of sink.packets(undefined, endPacket)) {
|
|
308
|
+
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
309
|
+
await this._synchronizer.wait(packet.timestamp);
|
|
310
|
+
}
|
|
311
|
+
if (this._canceled) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
await source.add(packet, meta);
|
|
315
|
+
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
|
316
|
+
}
|
|
317
|
+
source.close();
|
|
318
|
+
this._synchronizer.closeTrack(track.id);
|
|
319
|
+
})());
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// We need to decode & reencode the video
|
|
323
|
+
const canDecode = await track.canDecode();
|
|
324
|
+
if (!canDecode) {
|
|
325
|
+
this.discardedTracks.push({
|
|
326
|
+
track,
|
|
327
|
+
reason: 'undecodable_source_codec',
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (this._options.video?.codec) {
|
|
332
|
+
videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec);
|
|
333
|
+
}
|
|
334
|
+
const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH;
|
|
335
|
+
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
|
|
336
|
+
if (!encodableCodec) {
|
|
337
|
+
this.discardedTracks.push({
|
|
338
|
+
track,
|
|
339
|
+
reason: 'no_encodable_target_codec',
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const encodingConfig = {
|
|
344
|
+
codec: encodableCodec,
|
|
345
|
+
bitrate,
|
|
346
|
+
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
|
347
|
+
};
|
|
348
|
+
if (needsRerender) {
|
|
349
|
+
const source = new VideoSampleSource(encodingConfig);
|
|
350
|
+
videoSource = source;
|
|
351
|
+
this._trackPromises.push((async () => {
|
|
352
|
+
await this._started;
|
|
353
|
+
const sink = new CanvasSink(track, {
|
|
354
|
+
width,
|
|
355
|
+
height,
|
|
356
|
+
fit: this._options.video?.fit ?? 'fill',
|
|
357
|
+
rotation: totalRotation, // Bake the rotation into the output
|
|
358
|
+
poolSize: 1,
|
|
359
|
+
});
|
|
360
|
+
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
|
361
|
+
for await (const { canvas, timestamp, duration } of iterator) {
|
|
362
|
+
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
|
363
|
+
await this._synchronizer.wait(timestamp);
|
|
364
|
+
}
|
|
365
|
+
if (this._canceled) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const sample = new VideoSample(canvas, {
|
|
369
|
+
timestamp: Math.max(timestamp - this._startTimestamp, 0),
|
|
370
|
+
duration,
|
|
371
|
+
});
|
|
372
|
+
await source.add(sample);
|
|
373
|
+
sample.close();
|
|
374
|
+
}
|
|
375
|
+
})());
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const source = new VideoSampleSource(encodingConfig);
|
|
379
|
+
videoSource = source;
|
|
380
|
+
this._trackPromises.push((async () => {
|
|
381
|
+
await this._started;
|
|
382
|
+
const sink = new VideoSampleSink(track);
|
|
383
|
+
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
|
384
|
+
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
|
385
|
+
await this._synchronizer.wait(sample.timestamp);
|
|
386
|
+
}
|
|
387
|
+
sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0));
|
|
388
|
+
if (this._canceled) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await source.add(sample);
|
|
392
|
+
sample.close();
|
|
393
|
+
}
|
|
394
|
+
source.close();
|
|
395
|
+
this._synchronizer.closeTrack(track.id);
|
|
396
|
+
})());
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
this.output.addVideoTrack(videoSource, {
|
|
400
|
+
languageCode: track.languageCode,
|
|
401
|
+
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
|
402
|
+
});
|
|
403
|
+
this._addedCounts.video++;
|
|
404
|
+
this._totalTrackCount++;
|
|
405
|
+
this.utilizedTracks.push(track);
|
|
406
|
+
}
|
|
407
|
+
/** @internal */
|
|
408
|
+
async _processAudioTrack(track) {
|
|
409
|
+
const sourceCodec = track.codec;
|
|
410
|
+
if (!sourceCodec) {
|
|
411
|
+
this.discardedTracks.push({
|
|
412
|
+
track,
|
|
413
|
+
reason: 'unknown_source_codec',
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
let audioSource;
|
|
418
|
+
const originalNumberOfChannels = track.numberOfChannels;
|
|
419
|
+
const originalSampleRate = track.sampleRate;
|
|
420
|
+
const firstTimestamp = await track.getFirstTimestamp();
|
|
421
|
+
let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels;
|
|
422
|
+
let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate;
|
|
423
|
+
let needsResample = numberOfChannels !== originalNumberOfChannels
|
|
424
|
+
|| sampleRate !== originalSampleRate
|
|
425
|
+
|| this._startTimestamp > 0
|
|
426
|
+
|| firstTimestamp < 0;
|
|
427
|
+
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
|
428
|
+
if (!this._options.audio?.forceTranscode
|
|
429
|
+
&& !this._options.audio?.bitrate
|
|
430
|
+
&& !needsResample
|
|
431
|
+
&& audioCodecs.includes(sourceCodec)
|
|
432
|
+
&& (!this._options.audio?.codec || this._options.audio.codec === sourceCodec)) {
|
|
433
|
+
// Fast path, we can simply copy over the encoded packets
|
|
434
|
+
const source = new EncodedAudioPacketSource(sourceCodec);
|
|
435
|
+
audioSource = source;
|
|
436
|
+
this._trackPromises.push((async () => {
|
|
437
|
+
await this._started;
|
|
438
|
+
const sink = new EncodedPacketSink(track);
|
|
439
|
+
const decoderConfig = await track.getDecoderConfig();
|
|
440
|
+
const meta = { decoderConfig: decoderConfig ?? undefined };
|
|
441
|
+
const endPacket = Number.isFinite(this._endTimestamp)
|
|
442
|
+
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
|
|
443
|
+
: undefined;
|
|
444
|
+
for await (const packet of sink.packets(undefined, endPacket)) {
|
|
445
|
+
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
446
|
+
await this._synchronizer.wait(packet.timestamp);
|
|
447
|
+
}
|
|
448
|
+
if (this._canceled) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await source.add(packet, meta);
|
|
452
|
+
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
|
453
|
+
}
|
|
454
|
+
source.close();
|
|
455
|
+
this._synchronizer.closeTrack(track.id);
|
|
456
|
+
})());
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
// We need to decode & reencode the audio
|
|
460
|
+
const canDecode = await track.canDecode();
|
|
461
|
+
if (!canDecode) {
|
|
462
|
+
this.discardedTracks.push({
|
|
463
|
+
track,
|
|
464
|
+
reason: 'undecodable_source_codec',
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let codecOfChoice = null;
|
|
469
|
+
if (this._options.audio?.codec) {
|
|
470
|
+
audioCodecs = audioCodecs.filter(codec => codec === this._options.audio.codec);
|
|
471
|
+
}
|
|
472
|
+
const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH;
|
|
473
|
+
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
|
474
|
+
numberOfChannels,
|
|
475
|
+
sampleRate,
|
|
476
|
+
bitrate,
|
|
477
|
+
});
|
|
478
|
+
if (!encodableCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
|
|
479
|
+
&& audioCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
|
|
480
|
+
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)) {
|
|
481
|
+
// We could not find a compatible non-PCM codec despite the container supporting them. This can be
|
|
482
|
+
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with
|
|
483
|
+
// fallback parameters.
|
|
484
|
+
const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, {
|
|
485
|
+
numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS,
|
|
486
|
+
sampleRate: FALLBACK_SAMPLE_RATE,
|
|
487
|
+
bitrate,
|
|
488
|
+
});
|
|
489
|
+
const nonPcmCodec = encodableCodecsWithDefaultParams
|
|
490
|
+
.find(codec => NON_PCM_AUDIO_CODECS.includes(codec));
|
|
491
|
+
if (nonPcmCodec) {
|
|
492
|
+
// We are able to encode using a non-PCM codec, but it'll require resampling
|
|
493
|
+
needsResample = true;
|
|
494
|
+
codecOfChoice = nonPcmCodec;
|
|
495
|
+
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
|
496
|
+
sampleRate = FALLBACK_SAMPLE_RATE;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
codecOfChoice = encodableCodecs[0] ?? null;
|
|
501
|
+
}
|
|
502
|
+
if (codecOfChoice === null) {
|
|
503
|
+
this.discardedTracks.push({
|
|
504
|
+
track,
|
|
505
|
+
reason: 'no_encodable_target_codec',
|
|
506
|
+
});
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (needsResample) {
|
|
510
|
+
audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
const source = new AudioSampleSource({
|
|
514
|
+
codec: codecOfChoice,
|
|
515
|
+
bitrate,
|
|
516
|
+
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
|
517
|
+
});
|
|
518
|
+
audioSource = source;
|
|
519
|
+
this._trackPromises.push((async () => {
|
|
520
|
+
await this._started;
|
|
521
|
+
const sink = new AudioSampleSink(track);
|
|
522
|
+
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
|
523
|
+
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
|
524
|
+
await this._synchronizer.wait(sample.timestamp);
|
|
525
|
+
}
|
|
526
|
+
if (this._canceled) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
await source.add(sample);
|
|
530
|
+
sample.close();
|
|
531
|
+
}
|
|
532
|
+
source.close();
|
|
533
|
+
this._synchronizer.closeTrack(track.id);
|
|
534
|
+
})());
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
this.output.addAudioTrack(audioSource, {
|
|
538
|
+
languageCode: track.languageCode,
|
|
539
|
+
});
|
|
540
|
+
this._addedCounts.audio++;
|
|
541
|
+
this._totalTrackCount++;
|
|
542
|
+
this.utilizedTracks.push(track);
|
|
543
|
+
}
|
|
544
|
+
/** @internal */
|
|
545
|
+
_resampleAudio(track, codec, targetNumberOfChannels, targetSampleRate, bitrate) {
|
|
546
|
+
const source = new AudioSampleSource({
|
|
547
|
+
codec,
|
|
548
|
+
bitrate,
|
|
549
|
+
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
|
550
|
+
});
|
|
551
|
+
this._trackPromises.push((async () => {
|
|
552
|
+
await this._started;
|
|
553
|
+
const resampler = new AudioResampler({
|
|
554
|
+
sourceNumberOfChannels: track.numberOfChannels,
|
|
555
|
+
sourceSampleRate: track.sampleRate,
|
|
556
|
+
targetNumberOfChannels,
|
|
557
|
+
targetSampleRate,
|
|
558
|
+
startTime: this._startTimestamp,
|
|
559
|
+
endTime: this._endTimestamp,
|
|
560
|
+
onSample: sample => source.add(sample),
|
|
561
|
+
});
|
|
562
|
+
const sink = new AudioSampleSink(track);
|
|
563
|
+
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
|
564
|
+
for await (const sample of iterator) {
|
|
565
|
+
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
|
566
|
+
await this._synchronizer.wait(sample.timestamp);
|
|
567
|
+
}
|
|
568
|
+
if (this._canceled) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
await resampler.add(sample);
|
|
572
|
+
}
|
|
573
|
+
await resampler.finalize();
|
|
574
|
+
source.close();
|
|
575
|
+
this._synchronizer.closeTrack(track.id);
|
|
576
|
+
})());
|
|
577
|
+
return source;
|
|
578
|
+
}
|
|
579
|
+
/** @internal */
|
|
580
|
+
_reportProgress(trackId, endTimestamp) {
|
|
581
|
+
if (!this._computeProgress) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
assert(this._totalDuration !== null);
|
|
585
|
+
this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId) ?? -Infinity));
|
|
586
|
+
let totalTimestamps = 0;
|
|
587
|
+
for (const [, timestamp] of this._maxTimestamps) {
|
|
588
|
+
totalTimestamps += timestamp;
|
|
589
|
+
}
|
|
590
|
+
const averageTimestamp = totalTimestamps / this._totalTrackCount;
|
|
591
|
+
const newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1);
|
|
592
|
+
if (newProgress !== this._lastProgress) {
|
|
593
|
+
this._lastProgress = newProgress;
|
|
594
|
+
this.onProgress?.(newProgress);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const MAX_TIMESTAMP_GAP = 5;
|
|
599
|
+
/**
|
|
600
|
+
* Utility class for synchronizing multiple track packet consumers with one another. We don't want one consumer to get
|
|
601
|
+
* too out-of-sync with the others, as that may lead to a large number of packets that need to be internally buffered
|
|
602
|
+
* before they can be written. Therefore, we use this class to slow down a consumer if it is too far ahead of the
|
|
603
|
+
* slowest consumer.
|
|
604
|
+
*/
|
|
605
|
+
class TrackSynchronizer {
|
|
606
|
+
constructor() {
|
|
607
|
+
this.maxTimestamps = new Map(); // Track ID -> timestamp
|
|
608
|
+
this.resolvers = [];
|
|
609
|
+
}
|
|
610
|
+
computeMinAndMaybeResolve() {
|
|
611
|
+
let newMin = Infinity;
|
|
612
|
+
for (const [, timestamp] of this.maxTimestamps) {
|
|
613
|
+
newMin = Math.min(newMin, timestamp);
|
|
614
|
+
}
|
|
615
|
+
for (let i = 0; i < this.resolvers.length; i++) {
|
|
616
|
+
const entry = this.resolvers[i];
|
|
617
|
+
if (entry.timestamp - newMin < MAX_TIMESTAMP_GAP) {
|
|
618
|
+
// The gap has gotten small enough again, the consumer can continue again
|
|
619
|
+
entry.resolve();
|
|
620
|
+
this.resolvers.splice(i, 1);
|
|
621
|
+
i--;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return newMin;
|
|
625
|
+
}
|
|
626
|
+
shouldWait(trackId, timestamp) {
|
|
627
|
+
this.maxTimestamps.set(trackId, Math.max(timestamp, this.maxTimestamps.get(trackId) ?? -Infinity));
|
|
628
|
+
const newMin = this.computeMinAndMaybeResolve();
|
|
629
|
+
return timestamp - newMin >= MAX_TIMESTAMP_GAP; // Should wait if it is too far ahead of the slowest consumer
|
|
630
|
+
}
|
|
631
|
+
wait(timestamp) {
|
|
632
|
+
const { promise, resolve } = promiseWithResolvers();
|
|
633
|
+
this.resolvers.push({
|
|
634
|
+
timestamp,
|
|
635
|
+
resolve,
|
|
636
|
+
});
|
|
637
|
+
return promise;
|
|
638
|
+
}
|
|
639
|
+
closeTrack(trackId) {
|
|
640
|
+
this.maxTimestamps.delete(trackId);
|
|
641
|
+
this.computeMinAndMaybeResolve();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Utility class to handle audio resampling, handling both sample rate resampling as well as channel up/downmixing.
|
|
646
|
+
* The advantage over doing this manually rather than using OfflineAudioContext to do it for us is the artifact-free
|
|
647
|
+
* handling of putting multiple resampled audio samples back to back, which produces flaky results using
|
|
648
|
+
* OfflineAudioContext.
|
|
649
|
+
*/
|
|
650
|
+
export class AudioResampler {
|
|
651
|
+
constructor(options) {
|
|
652
|
+
this.sourceSampleRate = options.sourceSampleRate;
|
|
653
|
+
this.targetSampleRate = options.targetSampleRate;
|
|
654
|
+
this.sourceNumberOfChannels = options.sourceNumberOfChannels;
|
|
655
|
+
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
|
656
|
+
this.startTime = options.startTime;
|
|
657
|
+
this.endTime = options.endTime;
|
|
658
|
+
this.onSample = options.onSample;
|
|
659
|
+
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
|
|
660
|
+
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
|
661
|
+
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
|
662
|
+
this.bufferStartFrame = 0;
|
|
663
|
+
this.maxWrittenFrame = -1;
|
|
664
|
+
this.setupChannelMixer();
|
|
665
|
+
// Pre-allocate temporary buffer for source data
|
|
666
|
+
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
|
|
670
|
+
*/
|
|
671
|
+
setupChannelMixer() {
|
|
672
|
+
const sourceNum = this.sourceNumberOfChannels;
|
|
673
|
+
const targetNum = this.targetNumberOfChannels;
|
|
674
|
+
// Logic taken from
|
|
675
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API
|
|
676
|
+
// Most of the mapping functions are branchless.
|
|
677
|
+
if (sourceNum === 1 && targetNum === 2) {
|
|
678
|
+
// Mono to Stereo: M -> L, M -> R
|
|
679
|
+
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
680
|
+
return sourceData[sourceFrameIndex * sourceNum];
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
else if (sourceNum === 1 && targetNum === 4) {
|
|
684
|
+
// Mono to Quad: M -> L, M -> R, 0 -> SL, 0 -> SR
|
|
685
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
686
|
+
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex < 2);
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
else if (sourceNum === 1 && targetNum === 6) {
|
|
690
|
+
// Mono to 5.1: 0 -> L, 0 -> R, M -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
|
691
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
692
|
+
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex === 2);
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
else if (sourceNum === 2 && targetNum === 1) {
|
|
696
|
+
// Stereo to Mono: 0.5 * (L + R)
|
|
697
|
+
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
698
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
699
|
+
return 0.5 * (sourceData[baseIdx] + sourceData[baseIdx + 1]);
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
else if (sourceNum === 2 && targetNum === 4) {
|
|
703
|
+
// Stereo to Quad: L -> L, R -> R, 0 -> SL, 0 -> SR
|
|
704
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
705
|
+
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
else if (sourceNum === 2 && targetNum === 6) {
|
|
709
|
+
// Stereo to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
|
710
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
711
|
+
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
else if (sourceNum === 4 && targetNum === 1) {
|
|
715
|
+
// Quad to Mono: 0.25 * (L + R + SL + SR)
|
|
716
|
+
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
717
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
718
|
+
return 0.25 * (sourceData[baseIdx] + sourceData[baseIdx + 1]
|
|
719
|
+
+ sourceData[baseIdx + 2] + sourceData[baseIdx + 3]);
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
else if (sourceNum === 4 && targetNum === 2) {
|
|
723
|
+
// Quad to Stereo: 0.5 * (L + SL), 0.5 * (R + SR)
|
|
724
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
725
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
726
|
+
return 0.5 * (sourceData[baseIdx + targetChannelIndex]
|
|
727
|
+
+ sourceData[baseIdx + targetChannelIndex + 2]);
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
else if (sourceNum === 4 && targetNum === 6) {
|
|
731
|
+
// Quad to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, SL -> SL, SR -> SR
|
|
732
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
733
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
734
|
+
// It's a bit harder to do this one branchlessly
|
|
735
|
+
if (targetChannelIndex < 2)
|
|
736
|
+
return sourceData[baseIdx + targetChannelIndex]; // L, R
|
|
737
|
+
if (targetChannelIndex === 2 || targetChannelIndex === 3)
|
|
738
|
+
return 0; // C, LFE
|
|
739
|
+
return sourceData[baseIdx + targetChannelIndex - 2]; // SL, SR
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
else if (sourceNum === 6 && targetNum === 1) {
|
|
743
|
+
// 5.1 to Mono: sqrt(1/2) * (L + R) + C + 0.5 * (SL + SR)
|
|
744
|
+
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
745
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
746
|
+
return Math.SQRT1_2 * (sourceData[baseIdx] + sourceData[baseIdx + 1])
|
|
747
|
+
+ sourceData[baseIdx + 2]
|
|
748
|
+
+ 0.5 * (sourceData[baseIdx + 4] + sourceData[baseIdx + 5]);
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
else if (sourceNum === 6 && targetNum === 2) {
|
|
752
|
+
// 5.1 to Stereo: L + sqrt(1/2) * (C + SL), R + sqrt(1/2) * (C + SR)
|
|
753
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
754
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
755
|
+
return sourceData[baseIdx + targetChannelIndex]
|
|
756
|
+
+ Math.SQRT1_2 * (sourceData[baseIdx + 2] + sourceData[baseIdx + targetChannelIndex + 4]);
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
else if (sourceNum === 6 && targetNum === 4) {
|
|
760
|
+
// 5.1 to Quad: L + sqrt(1/2) * C, R + sqrt(1/2) * C, SL, SR
|
|
761
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
762
|
+
const baseIdx = sourceFrameIndex * sourceNum;
|
|
763
|
+
// It's a bit harder to do this one branchlessly
|
|
764
|
+
if (targetChannelIndex < 2) {
|
|
765
|
+
return sourceData[baseIdx + targetChannelIndex] + Math.SQRT1_2 * sourceData[baseIdx + 2];
|
|
766
|
+
}
|
|
767
|
+
return sourceData[baseIdx + targetChannelIndex + 2]; // SL, SR
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
// Discrete fallback: direct mapping with zero-fill or drop
|
|
772
|
+
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
773
|
+
return targetChannelIndex < sourceNum
|
|
774
|
+
? sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]
|
|
775
|
+
: 0;
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
ensureTempBufferSize(requiredSamples) {
|
|
780
|
+
let length = this.tempSourceBuffer.length;
|
|
781
|
+
while (length < requiredSamples) {
|
|
782
|
+
length *= 2;
|
|
783
|
+
}
|
|
784
|
+
if (length !== this.tempSourceBuffer.length) {
|
|
785
|
+
const newBuffer = new Float32Array(length);
|
|
786
|
+
newBuffer.set(this.tempSourceBuffer);
|
|
787
|
+
this.tempSourceBuffer = newBuffer;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
async add(audioSample) {
|
|
791
|
+
if (!audioSample || audioSample._closed) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
|
795
|
+
this.ensureTempBufferSize(requiredSamples);
|
|
796
|
+
// Copy the audio data to the temp buffer
|
|
797
|
+
const sourceDataSize = audioSample.allocationSize({ planeIndex: 0, format: 'f32' });
|
|
798
|
+
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
|
799
|
+
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
|
800
|
+
const inputStartTime = audioSample.timestamp - this.startTime;
|
|
801
|
+
const inputDuration = audioSample.numberOfFrames / this.sourceSampleRate;
|
|
802
|
+
const inputEndTime = Math.min(inputStartTime + inputDuration, this.endTime - this.startTime);
|
|
803
|
+
// Compute which output frames are affected by this sample
|
|
804
|
+
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
|
805
|
+
const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
|
|
806
|
+
for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
|
|
807
|
+
if (outputFrame < this.bufferStartFrame) {
|
|
808
|
+
continue; // Skip writes to the past
|
|
809
|
+
}
|
|
810
|
+
while (outputFrame >= this.bufferStartFrame + this.bufferSizeInFrames) {
|
|
811
|
+
// The write is after the current buffer, so finalize it
|
|
812
|
+
await this.finalizeCurrentBuffer();
|
|
813
|
+
this.bufferStartFrame += this.bufferSizeInFrames;
|
|
814
|
+
}
|
|
815
|
+
const bufferFrameIndex = outputFrame - this.bufferStartFrame;
|
|
816
|
+
assert(bufferFrameIndex < this.bufferSizeInFrames);
|
|
817
|
+
const outputTime = outputFrame / this.targetSampleRate;
|
|
818
|
+
const inputTime = outputTime - inputStartTime;
|
|
819
|
+
const sourcePosition = inputTime * this.sourceSampleRate;
|
|
820
|
+
const sourceLowerFrame = Math.floor(sourcePosition);
|
|
821
|
+
const sourceUpperFrame = Math.ceil(sourcePosition);
|
|
822
|
+
const fraction = sourcePosition - sourceLowerFrame;
|
|
823
|
+
// Process each output channel
|
|
824
|
+
for (let targetChannel = 0; targetChannel < this.targetNumberOfChannels; targetChannel++) {
|
|
825
|
+
let lowerSample = 0;
|
|
826
|
+
let upperSample = 0;
|
|
827
|
+
if (sourceLowerFrame >= 0 && sourceLowerFrame < audioSample.numberOfFrames) {
|
|
828
|
+
lowerSample = this.channelMixer(sourceView, sourceLowerFrame, targetChannel);
|
|
829
|
+
}
|
|
830
|
+
if (sourceUpperFrame >= 0 && sourceUpperFrame < audioSample.numberOfFrames) {
|
|
831
|
+
upperSample = this.channelMixer(sourceView, sourceUpperFrame, targetChannel);
|
|
832
|
+
}
|
|
833
|
+
// For resampling, we do naive linear interpolation to find the in-between sample. This produces
|
|
834
|
+
// suboptimal results especially for downsampling (for which a low-pass filter would first need to be
|
|
835
|
+
// applied), but AudioContext doesn't do this either, so, whatever, for now.
|
|
836
|
+
const outputSample = lowerSample + fraction * (upperSample - lowerSample);
|
|
837
|
+
// Write to output buffer (interleaved)
|
|
838
|
+
const outputIndex = bufferFrameIndex * this.targetNumberOfChannels + targetChannel;
|
|
839
|
+
this.outputBuffer[outputIndex] += outputSample; // Add in case of overlapping samples
|
|
840
|
+
}
|
|
841
|
+
this.maxWrittenFrame = Math.max(this.maxWrittenFrame, bufferFrameIndex);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async finalizeCurrentBuffer() {
|
|
845
|
+
if (this.maxWrittenFrame < 0) {
|
|
846
|
+
return; // Nothing to finalize
|
|
847
|
+
}
|
|
848
|
+
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
|
849
|
+
const outputData = new Float32Array(samplesWritten);
|
|
850
|
+
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
|
851
|
+
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
|
|
852
|
+
const audioSample = new AudioSample({
|
|
853
|
+
format: 'f32',
|
|
854
|
+
sampleRate: this.targetSampleRate,
|
|
855
|
+
numberOfChannels: this.targetNumberOfChannels,
|
|
856
|
+
timestamp: timestampSeconds,
|
|
857
|
+
data: outputData,
|
|
858
|
+
});
|
|
859
|
+
await this.onSample(audioSample);
|
|
860
|
+
this.outputBuffer.fill(0);
|
|
861
|
+
this.maxWrittenFrame = -1;
|
|
862
|
+
}
|
|
863
|
+
finalize() {
|
|
864
|
+
return this.finalizeCurrentBuffer();
|
|
865
|
+
}
|
|
866
|
+
}
|