@kenzuya/mediabunny 1.26.0 → 1.28.6
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 +1 -1
- package/dist/bundles/{mediabunny.mjs → mediabunny.js} +21963 -21390
- package/dist/bundles/mediabunny.min.js +490 -0
- package/dist/modules/shared/mp3-misc.d.ts.map +1 -1
- package/dist/modules/src/adts/adts-demuxer.d.ts +6 -6
- package/dist/modules/src/adts/adts-demuxer.d.ts.map +1 -1
- package/dist/modules/src/adts/adts-muxer.d.ts +4 -4
- package/dist/modules/src/adts/adts-muxer.d.ts.map +1 -1
- package/dist/modules/src/adts/adts-reader.d.ts +1 -1
- package/dist/modules/src/adts/adts-reader.d.ts.map +1 -1
- package/dist/modules/src/avi/avi-demuxer.d.ts +44 -0
- package/dist/modules/src/avi/avi-demuxer.d.ts.map +1 -0
- package/dist/modules/src/avi/avi-misc.d.ts +88 -0
- package/dist/modules/src/avi/avi-misc.d.ts.map +1 -0
- package/dist/modules/src/avi/avi-muxer.d.ts +45 -0
- package/dist/modules/src/avi/avi-muxer.d.ts.map +1 -0
- package/dist/modules/src/avi/riff-writer.d.ts +26 -0
- package/dist/modules/src/avi/riff-writer.d.ts.map +1 -0
- package/dist/modules/src/codec-data.d.ts +8 -3
- package/dist/modules/src/codec-data.d.ts.map +1 -1
- package/dist/modules/src/codec.d.ts +10 -10
- package/dist/modules/src/codec.d.ts.map +1 -1
- package/dist/modules/src/conversion.d.ts +33 -16
- package/dist/modules/src/conversion.d.ts.map +1 -1
- package/dist/modules/src/custom-coder.d.ts +8 -8
- package/dist/modules/src/custom-coder.d.ts.map +1 -1
- package/dist/modules/src/demuxer.d.ts +3 -3
- package/dist/modules/src/demuxer.d.ts.map +1 -1
- package/dist/modules/src/encode.d.ts +8 -8
- package/dist/modules/src/encode.d.ts.map +1 -1
- package/dist/modules/src/flac/flac-demuxer.d.ts +7 -7
- package/dist/modules/src/flac/flac-demuxer.d.ts.map +1 -1
- package/dist/modules/src/flac/flac-misc.d.ts +3 -3
- package/dist/modules/src/flac/flac-misc.d.ts.map +1 -1
- package/dist/modules/src/flac/flac-muxer.d.ts +5 -5
- package/dist/modules/src/flac/flac-muxer.d.ts.map +1 -1
- package/dist/modules/src/id3.d.ts +3 -3
- package/dist/modules/src/id3.d.ts.map +1 -1
- package/dist/modules/src/index.d.ts +20 -20
- package/dist/modules/src/index.d.ts.map +1 -1
- package/dist/modules/src/input-format.d.ts +22 -0
- package/dist/modules/src/input-format.d.ts.map +1 -1
- package/dist/modules/src/input-track.d.ts +8 -8
- package/dist/modules/src/input-track.d.ts.map +1 -1
- package/dist/modules/src/input.d.ts +12 -12
- package/dist/modules/src/isobmff/isobmff-boxes.d.ts +2 -2
- package/dist/modules/src/isobmff/isobmff-boxes.d.ts.map +1 -1
- package/dist/modules/src/isobmff/isobmff-demuxer.d.ts +12 -12
- package/dist/modules/src/isobmff/isobmff-demuxer.d.ts.map +1 -1
- package/dist/modules/src/isobmff/isobmff-misc.d.ts.map +1 -1
- package/dist/modules/src/isobmff/isobmff-muxer.d.ts +11 -11
- package/dist/modules/src/isobmff/isobmff-muxer.d.ts.map +1 -1
- package/dist/modules/src/isobmff/isobmff-reader.d.ts +2 -2
- package/dist/modules/src/isobmff/isobmff-reader.d.ts.map +1 -1
- package/dist/modules/src/matroska/ebml.d.ts +3 -3
- package/dist/modules/src/matroska/ebml.d.ts.map +1 -1
- package/dist/modules/src/matroska/matroska-demuxer.d.ts +13 -13
- package/dist/modules/src/matroska/matroska-demuxer.d.ts.map +1 -1
- package/dist/modules/src/matroska/matroska-input.d.ts +33 -0
- package/dist/modules/src/matroska/matroska-input.d.ts.map +1 -0
- package/dist/modules/src/matroska/matroska-misc.d.ts.map +1 -1
- package/dist/modules/src/matroska/matroska-muxer.d.ts +5 -5
- package/dist/modules/src/matroska/matroska-muxer.d.ts.map +1 -1
- package/dist/modules/src/media-sink.d.ts +5 -5
- package/dist/modules/src/media-sink.d.ts.map +1 -1
- package/dist/modules/src/media-source.d.ts +22 -4
- package/dist/modules/src/media-source.d.ts.map +1 -1
- package/dist/modules/src/metadata.d.ts +2 -2
- package/dist/modules/src/metadata.d.ts.map +1 -1
- package/dist/modules/src/misc.d.ts +5 -4
- package/dist/modules/src/misc.d.ts.map +1 -1
- package/dist/modules/src/mp3/mp3-demuxer.d.ts +7 -7
- package/dist/modules/src/mp3/mp3-demuxer.d.ts.map +1 -1
- package/dist/modules/src/mp3/mp3-muxer.d.ts +4 -4
- package/dist/modules/src/mp3/mp3-muxer.d.ts.map +1 -1
- package/dist/modules/src/mp3/mp3-reader.d.ts +2 -2
- package/dist/modules/src/mp3/mp3-reader.d.ts.map +1 -1
- package/dist/modules/src/mp3/mp3-writer.d.ts +1 -1
- package/dist/modules/src/mp3/mp3-writer.d.ts.map +1 -1
- package/dist/modules/src/muxer.d.ts +4 -4
- package/dist/modules/src/muxer.d.ts.map +1 -1
- package/dist/modules/src/ogg/ogg-demuxer.d.ts +7 -7
- package/dist/modules/src/ogg/ogg-demuxer.d.ts.map +1 -1
- package/dist/modules/src/ogg/ogg-misc.d.ts +1 -1
- package/dist/modules/src/ogg/ogg-misc.d.ts.map +1 -1
- package/dist/modules/src/ogg/ogg-muxer.d.ts +5 -5
- package/dist/modules/src/ogg/ogg-muxer.d.ts.map +1 -1
- package/dist/modules/src/ogg/ogg-reader.d.ts +1 -1
- package/dist/modules/src/ogg/ogg-reader.d.ts.map +1 -1
- package/dist/modules/src/output-format.d.ts +51 -6
- package/dist/modules/src/output-format.d.ts.map +1 -1
- package/dist/modules/src/output.d.ts +13 -13
- package/dist/modules/src/output.d.ts.map +1 -1
- package/dist/modules/src/packet.d.ts +1 -1
- package/dist/modules/src/packet.d.ts.map +1 -1
- package/dist/modules/src/pcm.d.ts.map +1 -1
- package/dist/modules/src/reader.d.ts +2 -2
- package/dist/modules/src/reader.d.ts.map +1 -1
- package/dist/modules/src/sample.d.ts +57 -15
- package/dist/modules/src/sample.d.ts.map +1 -1
- package/dist/modules/src/source.d.ts +3 -3
- package/dist/modules/src/source.d.ts.map +1 -1
- package/dist/modules/src/subtitles.d.ts +1 -1
- package/dist/modules/src/subtitles.d.ts.map +1 -1
- package/dist/modules/src/target.d.ts +2 -2
- package/dist/modules/src/target.d.ts.map +1 -1
- package/dist/modules/src/tsconfig.tsbuildinfo +1 -1
- package/dist/modules/src/wave/riff-writer.d.ts +1 -1
- package/dist/modules/src/wave/riff-writer.d.ts.map +1 -1
- package/dist/modules/src/wave/wave-demuxer.d.ts +6 -6
- package/dist/modules/src/wave/wave-demuxer.d.ts.map +1 -1
- package/dist/modules/src/wave/wave-muxer.d.ts +4 -4
- package/dist/modules/src/wave/wave-muxer.d.ts.map +1 -1
- package/dist/modules/src/writer.d.ts +1 -1
- package/dist/modules/src/writer.d.ts.map +1 -1
- package/dist/packages/eac3/eac3.wasm +0 -0
- package/dist/packages/eac3/mediabunny-eac3.js +1058 -0
- package/dist/packages/eac3/mediabunny-eac3.min.js +44 -0
- package/dist/packages/mp3-encoder/mediabunny-mp3-encoder.js +694 -0
- package/dist/packages/mp3-encoder/mediabunny-mp3-encoder.min.js +58 -0
- package/dist/packages/mpeg4/mediabunny-mpeg4.js +1198 -0
- package/dist/packages/mpeg4/mediabunny-mpeg4.min.js +44 -0
- package/dist/packages/mpeg4/xvid.wasm +0 -0
- package/package.json +18 -57
- package/dist/bundles/mediabunny.cjs +0 -26140
- package/dist/bundles/mediabunny.min.cjs +0 -147
- package/dist/bundles/mediabunny.min.mjs +0 -146
- package/dist/mediabunny.d.ts +0 -3319
- package/dist/modules/shared/mp3-misc.js +0 -147
- package/dist/modules/src/adts/adts-demuxer.js +0 -239
- package/dist/modules/src/adts/adts-muxer.js +0 -80
- package/dist/modules/src/adts/adts-reader.js +0 -63
- package/dist/modules/src/codec-data.js +0 -1730
- package/dist/modules/src/codec.js +0 -869
- package/dist/modules/src/conversion.js +0 -1459
- package/dist/modules/src/custom-coder.js +0 -117
- package/dist/modules/src/demuxer.js +0 -12
- package/dist/modules/src/encode.js +0 -442
- package/dist/modules/src/flac/flac-demuxer.js +0 -504
- package/dist/modules/src/flac/flac-misc.js +0 -135
- package/dist/modules/src/flac/flac-muxer.js +0 -222
- package/dist/modules/src/id3.js +0 -848
- package/dist/modules/src/index.js +0 -28
- package/dist/modules/src/input-format.js +0 -480
- package/dist/modules/src/input-track.js +0 -372
- package/dist/modules/src/input.js +0 -188
- package/dist/modules/src/isobmff/isobmff-boxes.js +0 -1480
- package/dist/modules/src/isobmff/isobmff-demuxer.js +0 -2618
- package/dist/modules/src/isobmff/isobmff-misc.js +0 -20
- package/dist/modules/src/isobmff/isobmff-muxer.js +0 -966
- package/dist/modules/src/isobmff/isobmff-reader.js +0 -72
- package/dist/modules/src/matroska/ebml.js +0 -653
- package/dist/modules/src/matroska/matroska-demuxer.js +0 -2133
- package/dist/modules/src/matroska/matroska-misc.js +0 -20
- package/dist/modules/src/matroska/matroska-muxer.js +0 -1017
- package/dist/modules/src/media-sink.js +0 -1736
- package/dist/modules/src/media-source.js +0 -1825
- package/dist/modules/src/metadata.js +0 -193
- package/dist/modules/src/misc.js +0 -623
- package/dist/modules/src/mp3/mp3-demuxer.js +0 -285
- package/dist/modules/src/mp3/mp3-muxer.js +0 -123
- package/dist/modules/src/mp3/mp3-reader.js +0 -26
- package/dist/modules/src/mp3/mp3-writer.js +0 -78
- package/dist/modules/src/muxer.js +0 -50
- package/dist/modules/src/node.d.ts +0 -9
- package/dist/modules/src/node.d.ts.map +0 -1
- package/dist/modules/src/node.js +0 -9
- package/dist/modules/src/ogg/ogg-demuxer.js +0 -763
- package/dist/modules/src/ogg/ogg-misc.js +0 -78
- package/dist/modules/src/ogg/ogg-muxer.js +0 -353
- package/dist/modules/src/ogg/ogg-reader.js +0 -65
- package/dist/modules/src/output-format.js +0 -527
- package/dist/modules/src/output.js +0 -300
- package/dist/modules/src/packet.js +0 -182
- package/dist/modules/src/pcm.js +0 -85
- package/dist/modules/src/reader.js +0 -236
- package/dist/modules/src/sample.js +0 -1056
- package/dist/modules/src/source.js +0 -1182
- package/dist/modules/src/subtitles.js +0 -575
- package/dist/modules/src/target.js +0 -140
- package/dist/modules/src/wave/riff-writer.js +0 -30
- package/dist/modules/src/wave/wave-demuxer.js +0 -447
- package/dist/modules/src/wave/wave-muxer.js +0 -318
- package/dist/modules/src/writer.js +0 -370
- package/src/adts/adts-demuxer.ts +0 -331
- package/src/adts/adts-muxer.ts +0 -111
- package/src/adts/adts-reader.ts +0 -85
- package/src/codec-data.ts +0 -2078
- package/src/codec.ts +0 -1092
- package/src/conversion.ts +0 -2112
- package/src/custom-coder.ts +0 -197
- package/src/demuxer.ts +0 -24
- package/src/encode.ts +0 -739
- package/src/flac/flac-demuxer.ts +0 -730
- package/src/flac/flac-misc.ts +0 -164
- package/src/flac/flac-muxer.ts +0 -320
- package/src/id3.ts +0 -925
- package/src/index.ts +0 -221
- package/src/input-format.ts +0 -541
- package/src/input-track.ts +0 -529
- package/src/input.ts +0 -235
- package/src/isobmff/isobmff-boxes.ts +0 -1719
- package/src/isobmff/isobmff-demuxer.ts +0 -3190
- package/src/isobmff/isobmff-misc.ts +0 -29
- package/src/isobmff/isobmff-muxer.ts +0 -1348
- package/src/isobmff/isobmff-reader.ts +0 -91
- package/src/matroska/ebml.ts +0 -730
- package/src/matroska/matroska-demuxer.ts +0 -2481
- package/src/matroska/matroska-misc.ts +0 -29
- package/src/matroska/matroska-muxer.ts +0 -1276
- package/src/media-sink.ts +0 -2179
- package/src/media-source.ts +0 -2243
- package/src/metadata.ts +0 -320
- package/src/misc.ts +0 -798
- package/src/mp3/mp3-demuxer.ts +0 -383
- package/src/mp3/mp3-muxer.ts +0 -166
- package/src/mp3/mp3-reader.ts +0 -34
- package/src/mp3/mp3-writer.ts +0 -120
- package/src/muxer.ts +0 -88
- package/src/node.ts +0 -11
- package/src/ogg/ogg-demuxer.ts +0 -1053
- package/src/ogg/ogg-misc.ts +0 -116
- package/src/ogg/ogg-muxer.ts +0 -497
- package/src/ogg/ogg-reader.ts +0 -93
- package/src/output-format.ts +0 -945
- package/src/output.ts +0 -488
- package/src/packet.ts +0 -263
- package/src/pcm.ts +0 -112
- package/src/reader.ts +0 -323
- package/src/sample.ts +0 -1461
- package/src/source.ts +0 -1688
- package/src/subtitles.ts +0 -711
- package/src/target.ts +0 -204
- package/src/tsconfig.json +0 -16
- package/src/wave/riff-writer.ts +0 -36
- package/src/wave/wave-demuxer.ts +0 -529
- package/src/wave/wave-muxer.ts +0 -371
- package/src/writer.ts +0 -490
|
@@ -1,1459 +0,0 @@
|
|
|
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, NON_PCM_AUDIO_CODECS, SUBTITLE_CODECS, VIDEO_CODECS, } from './codec.js';
|
|
9
|
-
import { getEncodableAudioCodecs, getFirstEncodableVideoCodec, Quality, QUALITY_HIGH, } from './encode.js';
|
|
10
|
-
import { Input } from './input.js';
|
|
11
|
-
import { AudioSampleSink, CanvasSink, EncodedPacketSink, VideoSampleSink, } from './media-sink.js';
|
|
12
|
-
import { EncodedVideoPacketSource, EncodedAudioPacketSource, TextSubtitleSource, VideoSampleSource, AudioSampleSource, } from './media-source.js';
|
|
13
|
-
import { assert, clamp, isIso639Dash2LanguageCode, normalizeRotation, promiseWithResolvers, } from './misc.js';
|
|
14
|
-
import { Output } from './output.js';
|
|
15
|
-
import { Mp4OutputFormat } from './output-format.js';
|
|
16
|
-
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample.js';
|
|
17
|
-
import { validateMetadataTags } from './metadata.js';
|
|
18
|
-
import { formatCuesToAss, formatCuesToSrt, formatCuesToWebVTT } from './subtitles.js';
|
|
19
|
-
import { NullTarget } from './target.js';
|
|
20
|
-
const validateVideoOptions = (videoOptions) => {
|
|
21
|
-
if (videoOptions !== undefined && (!videoOptions || typeof videoOptions !== 'object')) {
|
|
22
|
-
throw new TypeError('options.video, when provided, must be an object.');
|
|
23
|
-
}
|
|
24
|
-
if (videoOptions?.discard !== undefined && typeof videoOptions.discard !== 'boolean') {
|
|
25
|
-
throw new TypeError('options.video.discard, when provided, must be a boolean.');
|
|
26
|
-
}
|
|
27
|
-
if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') {
|
|
28
|
-
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
|
|
29
|
-
}
|
|
30
|
-
if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) {
|
|
31
|
-
throw new TypeError(`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`);
|
|
32
|
-
}
|
|
33
|
-
if (videoOptions?.bitrate !== undefined
|
|
34
|
-
&& !(videoOptions.bitrate instanceof Quality)
|
|
35
|
-
&& (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0)) {
|
|
36
|
-
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
|
|
37
|
-
}
|
|
38
|
-
if (videoOptions?.width !== undefined
|
|
39
|
-
&& (!Number.isInteger(videoOptions.width) || videoOptions.width <= 0)) {
|
|
40
|
-
throw new TypeError('options.video.width, when provided, must be a positive integer.');
|
|
41
|
-
}
|
|
42
|
-
if (videoOptions?.height !== undefined
|
|
43
|
-
&& (!Number.isInteger(videoOptions.height) || videoOptions.height <= 0)) {
|
|
44
|
-
throw new TypeError('options.video.height, when provided, must be a positive integer.');
|
|
45
|
-
}
|
|
46
|
-
if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) {
|
|
47
|
-
throw new TypeError('options.video.fit, when provided, must be one of \'fill\', \'contain\', or \'cover\'.');
|
|
48
|
-
}
|
|
49
|
-
if (videoOptions?.width !== undefined
|
|
50
|
-
&& videoOptions.height !== undefined
|
|
51
|
-
&& videoOptions.fit === undefined) {
|
|
52
|
-
throw new TypeError('When both options.video.width and options.video.height are provided, options.video.fit must also be'
|
|
53
|
-
+ ' provided.');
|
|
54
|
-
}
|
|
55
|
-
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
|
|
56
|
-
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
|
57
|
-
}
|
|
58
|
-
if (videoOptions?.crop !== undefined) {
|
|
59
|
-
validateCropRectangle(videoOptions.crop, 'options.video.');
|
|
60
|
-
}
|
|
61
|
-
if (videoOptions?.frameRate !== undefined
|
|
62
|
-
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)) {
|
|
63
|
-
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
|
64
|
-
}
|
|
65
|
-
if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) {
|
|
66
|
-
throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.');
|
|
67
|
-
}
|
|
68
|
-
if (videoOptions?.keyFrameInterval !== undefined
|
|
69
|
-
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)) {
|
|
70
|
-
throw new TypeError('options.video.keyFrameInterval, when provided, must be a non-negative number.');
|
|
71
|
-
}
|
|
72
|
-
if (videoOptions?.process !== undefined && typeof videoOptions.process !== 'function') {
|
|
73
|
-
throw new TypeError('options.video.process, when provided, must be a function.');
|
|
74
|
-
}
|
|
75
|
-
if (videoOptions?.processedWidth !== undefined
|
|
76
|
-
&& (!Number.isInteger(videoOptions.processedWidth) || videoOptions.processedWidth <= 0)) {
|
|
77
|
-
throw new TypeError('options.video.processedWidth, when provided, must be a positive integer.');
|
|
78
|
-
}
|
|
79
|
-
if (videoOptions?.processedHeight !== undefined
|
|
80
|
-
&& (!Number.isInteger(videoOptions.processedHeight) || videoOptions.processedHeight <= 0)) {
|
|
81
|
-
throw new TypeError('options.video.processedHeight, when provided, must be a positive integer.');
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
const validateAudioOptions = (audioOptions) => {
|
|
85
|
-
if (audioOptions !== undefined && (!audioOptions || typeof audioOptions !== 'object')) {
|
|
86
|
-
throw new TypeError('options.audio, when provided, must be an object.');
|
|
87
|
-
}
|
|
88
|
-
if (audioOptions?.discard !== undefined && typeof audioOptions.discard !== 'boolean') {
|
|
89
|
-
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
|
90
|
-
}
|
|
91
|
-
if (audioOptions?.forceTranscode !== undefined && typeof audioOptions.forceTranscode !== 'boolean') {
|
|
92
|
-
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
|
|
93
|
-
}
|
|
94
|
-
if (audioOptions?.codec !== undefined && !AUDIO_CODECS.includes(audioOptions.codec)) {
|
|
95
|
-
throw new TypeError(`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`);
|
|
96
|
-
}
|
|
97
|
-
if (audioOptions?.bitrate !== undefined
|
|
98
|
-
&& !(audioOptions.bitrate instanceof Quality)
|
|
99
|
-
&& (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0)) {
|
|
100
|
-
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
|
|
101
|
-
}
|
|
102
|
-
if (audioOptions?.numberOfChannels !== undefined
|
|
103
|
-
&& (!Number.isInteger(audioOptions.numberOfChannels) || audioOptions.numberOfChannels <= 0)) {
|
|
104
|
-
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
|
|
105
|
-
}
|
|
106
|
-
if (audioOptions?.sampleRate !== undefined
|
|
107
|
-
&& (!Number.isInteger(audioOptions.sampleRate) || audioOptions.sampleRate <= 0)) {
|
|
108
|
-
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
|
109
|
-
}
|
|
110
|
-
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
|
|
111
|
-
throw new TypeError('options.audio.process, when provided, must be a function.');
|
|
112
|
-
}
|
|
113
|
-
if (audioOptions?.processedNumberOfChannels !== undefined
|
|
114
|
-
&& (!Number.isInteger(audioOptions.processedNumberOfChannels) || audioOptions.processedNumberOfChannels <= 0)) {
|
|
115
|
-
throw new TypeError('options.audio.processedNumberOfChannels, when provided, must be a positive integer.');
|
|
116
|
-
}
|
|
117
|
-
if (audioOptions?.processedSampleRate !== undefined
|
|
118
|
-
&& (!Number.isInteger(audioOptions.processedSampleRate) || audioOptions.processedSampleRate <= 0)) {
|
|
119
|
-
throw new TypeError('options.audio.processedSampleRate, when provided, must be a positive integer.');
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
const validateSubtitleOptions = (subtitleOptions) => {
|
|
123
|
-
if (subtitleOptions !== undefined && (!subtitleOptions || typeof subtitleOptions !== 'object')) {
|
|
124
|
-
throw new TypeError('options.subtitle, when provided, must be an object.');
|
|
125
|
-
}
|
|
126
|
-
if (subtitleOptions?.discard !== undefined && typeof subtitleOptions.discard !== 'boolean') {
|
|
127
|
-
throw new TypeError('options.subtitle.discard, when provided, must be a boolean.');
|
|
128
|
-
}
|
|
129
|
-
if (subtitleOptions?.codec !== undefined && !SUBTITLE_CODECS.includes(subtitleOptions.codec)) {
|
|
130
|
-
throw new TypeError(`options.subtitle.codec, when provided, must be one of: ${SUBTITLE_CODECS.join(', ')}.`);
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
|
134
|
-
const FALLBACK_SAMPLE_RATE = 48000;
|
|
135
|
-
/**
|
|
136
|
-
* Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
|
|
137
|
-
* this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
|
|
138
|
-
* @group Conversion
|
|
139
|
-
* @public
|
|
140
|
-
*/
|
|
141
|
-
export class Conversion {
|
|
142
|
-
/** Initializes a new conversion process without starting the conversion. */
|
|
143
|
-
static async init(options) {
|
|
144
|
-
const conversion = new Conversion(options);
|
|
145
|
-
await conversion._init();
|
|
146
|
-
return conversion;
|
|
147
|
-
}
|
|
148
|
-
/** Creates a new Conversion instance (duh). */
|
|
149
|
-
constructor(options) {
|
|
150
|
-
/** @internal */
|
|
151
|
-
this._addedCounts = {
|
|
152
|
-
video: 0,
|
|
153
|
-
audio: 0,
|
|
154
|
-
subtitle: 0,
|
|
155
|
-
};
|
|
156
|
-
/** @internal */
|
|
157
|
-
this._totalTrackCount = 0;
|
|
158
|
-
/** @internal */
|
|
159
|
-
this._trackPromises = [];
|
|
160
|
-
/** @internal */
|
|
161
|
-
this._executed = false;
|
|
162
|
-
/** @internal */
|
|
163
|
-
this._synchronizer = new TrackSynchronizer();
|
|
164
|
-
/** @internal */
|
|
165
|
-
this._totalDuration = null;
|
|
166
|
-
/** @internal */
|
|
167
|
-
this._maxTimestamps = new Map(); // Track ID -> timestamp
|
|
168
|
-
/** @internal */
|
|
169
|
-
this._canceled = false;
|
|
170
|
-
/** @internal */
|
|
171
|
-
this._externalSubtitleSources = [];
|
|
172
|
-
/**
|
|
173
|
-
* A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the
|
|
174
|
-
* completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete;
|
|
175
|
-
* the conversion is complete once `execute()` resolves.
|
|
176
|
-
*
|
|
177
|
-
* In order for progress to be computed, this property must be set before `execute` is called.
|
|
178
|
-
*/
|
|
179
|
-
this.onProgress = undefined;
|
|
180
|
-
/** @internal */
|
|
181
|
-
this._computeProgress = false;
|
|
182
|
-
/** @internal */
|
|
183
|
-
this._lastProgress = 0;
|
|
184
|
-
/**
|
|
185
|
-
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
|
|
186
|
-
* the `discardedTracks` field for reasons.
|
|
187
|
-
*/
|
|
188
|
-
this.isValid = false;
|
|
189
|
-
/** The list of tracks that are included in the output file. */
|
|
190
|
-
this.utilizedTracks = [];
|
|
191
|
-
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */
|
|
192
|
-
this.discardedTracks = [];
|
|
193
|
-
if (!options || typeof options !== 'object') {
|
|
194
|
-
throw new TypeError('options must be an object.');
|
|
195
|
-
}
|
|
196
|
-
if (!(options.input instanceof Input)) {
|
|
197
|
-
throw new TypeError('options.input must be an Input.');
|
|
198
|
-
}
|
|
199
|
-
if (!(options.output instanceof Output)) {
|
|
200
|
-
throw new TypeError('options.output must be an Output.');
|
|
201
|
-
}
|
|
202
|
-
if (options.output._tracks.length > 0
|
|
203
|
-
|| Object.keys(options.output._metadataTags).length > 0
|
|
204
|
-
|| options.output.state !== 'pending') {
|
|
205
|
-
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
|
|
206
|
-
}
|
|
207
|
-
if (typeof options.video !== 'function') {
|
|
208
|
-
validateVideoOptions(options.video);
|
|
209
|
-
}
|
|
210
|
-
if (typeof options.audio !== 'function') {
|
|
211
|
-
validateAudioOptions(options.audio);
|
|
212
|
-
}
|
|
213
|
-
if (typeof options.subtitle !== 'function') {
|
|
214
|
-
validateSubtitleOptions(options.subtitle);
|
|
215
|
-
}
|
|
216
|
-
if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
|
|
217
|
-
throw new TypeError('options.trim, when provided, must be an object.');
|
|
218
|
-
}
|
|
219
|
-
if (options.trim?.start !== undefined && (!Number.isFinite(options.trim.start) || options.trim.start < 0)) {
|
|
220
|
-
throw new TypeError('options.trim.start, when provided, must be a non-negative number.');
|
|
221
|
-
}
|
|
222
|
-
if (options.trim?.end !== undefined && (!Number.isFinite(options.trim.end) || options.trim.end < 0)) {
|
|
223
|
-
throw new TypeError('options.trim.end, when provided, must be a non-negative number.');
|
|
224
|
-
}
|
|
225
|
-
if (options.trim?.start !== undefined
|
|
226
|
-
&& options.trim.end !== undefined
|
|
227
|
-
&& options.trim.start >= options.trim.end) {
|
|
228
|
-
throw new TypeError('options.trim.start must be less than options.trim.end.');
|
|
229
|
-
}
|
|
230
|
-
if (options.tags !== undefined
|
|
231
|
-
&& (typeof options.tags !== 'object' || !options.tags)
|
|
232
|
-
&& typeof options.tags !== 'function') {
|
|
233
|
-
throw new TypeError('options.tags, when provided, must be an object or a function.');
|
|
234
|
-
}
|
|
235
|
-
if (typeof options.tags === 'object') {
|
|
236
|
-
validateMetadataTags(options.tags);
|
|
237
|
-
}
|
|
238
|
-
if (options.showWarnings !== undefined && typeof options.showWarnings !== 'boolean') {
|
|
239
|
-
throw new TypeError('options.showWarnings, when provided, must be a boolean.');
|
|
240
|
-
}
|
|
241
|
-
this._options = options;
|
|
242
|
-
this.input = options.input;
|
|
243
|
-
this.output = options.output;
|
|
244
|
-
this._startTimestamp = options.trim?.start ?? 0;
|
|
245
|
-
this._endTimestamp = options.trim?.end ?? Infinity;
|
|
246
|
-
const { promise: started, resolve: start } = promiseWithResolvers();
|
|
247
|
-
this._started = started;
|
|
248
|
-
this._start = start;
|
|
249
|
-
}
|
|
250
|
-
/** @internal */
|
|
251
|
-
async _init() {
|
|
252
|
-
const inputTracks = await this.input.getTracks();
|
|
253
|
-
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
|
|
254
|
-
let nVideo = 1;
|
|
255
|
-
let nAudio = 1;
|
|
256
|
-
let nSubtitle = 1;
|
|
257
|
-
for (const track of inputTracks) {
|
|
258
|
-
let trackOptions = undefined;
|
|
259
|
-
if (track.isVideoTrack()) {
|
|
260
|
-
if (this._options.video) {
|
|
261
|
-
if (typeof this._options.video === 'function') {
|
|
262
|
-
trackOptions = await this._options.video(track, nVideo);
|
|
263
|
-
validateVideoOptions(trackOptions);
|
|
264
|
-
nVideo++;
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
trackOptions = this._options.video;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
else if (track.isAudioTrack()) {
|
|
272
|
-
if (this._options.audio) {
|
|
273
|
-
if (typeof this._options.audio === 'function') {
|
|
274
|
-
trackOptions = await this._options.audio(track, nAudio);
|
|
275
|
-
validateAudioOptions(trackOptions);
|
|
276
|
-
nAudio++;
|
|
277
|
-
}
|
|
278
|
-
else {
|
|
279
|
-
trackOptions = this._options.audio;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
else if (track.isSubtitleTrack()) {
|
|
284
|
-
if (this._options.subtitle) {
|
|
285
|
-
if (typeof this._options.subtitle === 'function') {
|
|
286
|
-
trackOptions = await this._options.subtitle(track, nSubtitle);
|
|
287
|
-
validateSubtitleOptions(trackOptions);
|
|
288
|
-
nSubtitle++;
|
|
289
|
-
}
|
|
290
|
-
else {
|
|
291
|
-
trackOptions = this._options.subtitle;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
assert(false);
|
|
297
|
-
}
|
|
298
|
-
if (trackOptions?.discard) {
|
|
299
|
-
this.discardedTracks.push({
|
|
300
|
-
track,
|
|
301
|
-
reason: 'discarded_by_user',
|
|
302
|
-
});
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
|
306
|
-
this.discardedTracks.push({
|
|
307
|
-
track,
|
|
308
|
-
reason: 'max_track_count_reached',
|
|
309
|
-
});
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) {
|
|
313
|
-
this.discardedTracks.push({
|
|
314
|
-
track,
|
|
315
|
-
reason: 'max_track_count_of_type_reached',
|
|
316
|
-
});
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
if (track.isVideoTrack()) {
|
|
320
|
-
await this._processVideoTrack(track, (trackOptions ?? {}));
|
|
321
|
-
}
|
|
322
|
-
else if (track.isAudioTrack()) {
|
|
323
|
-
await this._processAudioTrack(track, (trackOptions ?? {}));
|
|
324
|
-
}
|
|
325
|
-
else if (track.isSubtitleTrack()) {
|
|
326
|
-
await this._processSubtitleTrack(track, (trackOptions ?? {}));
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// Now, let's deal with metadata tags
|
|
330
|
-
const inputTags = await this.input.getMetadataTags();
|
|
331
|
-
let outputTags;
|
|
332
|
-
if (this._options.tags) {
|
|
333
|
-
const result = typeof this._options.tags === 'function'
|
|
334
|
-
? await this._options.tags(inputTags)
|
|
335
|
-
: this._options.tags;
|
|
336
|
-
validateMetadataTags(result);
|
|
337
|
-
outputTags = result;
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
outputTags = inputTags;
|
|
341
|
-
}
|
|
342
|
-
// Somewhat dirty but pragmatic
|
|
343
|
-
const inputAndOutputFormatMatch = (await this.input.getFormat()).mimeType === this.output.format.mimeType;
|
|
344
|
-
const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
|
|
345
|
-
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
|
|
346
|
-
// If the input and output formats aren't the same, copying over raw metadata tags makes no sense and only
|
|
347
|
-
// results in junk tags, so let's cut them out.
|
|
348
|
-
delete outputTags.raw;
|
|
349
|
-
}
|
|
350
|
-
this.output.setMetadataTags(outputTags);
|
|
351
|
-
// Let's check if the conversion can actually be executed
|
|
352
|
-
this.isValid = this._totalTrackCount >= outputTrackCounts.total.min
|
|
353
|
-
&& this._addedCounts.video >= outputTrackCounts.video.min
|
|
354
|
-
&& this._addedCounts.audio >= outputTrackCounts.audio.min
|
|
355
|
-
&& this._addedCounts.subtitle >= outputTrackCounts.subtitle.min;
|
|
356
|
-
if (this._options.showWarnings ?? true) {
|
|
357
|
-
const warnElements = [];
|
|
358
|
-
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
|
|
359
|
-
if (unintentionallyDiscardedTracks.length > 0) {
|
|
360
|
-
// Let's give the user a notice/warning about discarded tracks so they aren't confused
|
|
361
|
-
warnElements.push('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
|
|
362
|
-
}
|
|
363
|
-
if (!this.isValid) {
|
|
364
|
-
warnElements.push('\n\n' + this._getInvalidityExplanation().join(''));
|
|
365
|
-
}
|
|
366
|
-
if (warnElements.length > 0) {
|
|
367
|
-
console.warn(...warnElements);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
/** @internal */
|
|
372
|
-
_getInvalidityExplanation() {
|
|
373
|
-
const elements = [];
|
|
374
|
-
if (this.discardedTracks.length === 0) {
|
|
375
|
-
elements.push('Due to missing tracks, this conversion cannot be executed.');
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
const encodabilityIsTheProblem = this.discardedTracks.every(x => x.reason === 'discarded_by_user' || x.reason === 'no_encodable_target_codec');
|
|
379
|
-
elements.push('Due to discarded tracks, this conversion cannot be executed.');
|
|
380
|
-
if (encodabilityIsTheProblem) {
|
|
381
|
-
const codecs = this.discardedTracks.flatMap((x) => {
|
|
382
|
-
if (x.reason === 'discarded_by_user')
|
|
383
|
-
return [];
|
|
384
|
-
if (x.track.type === 'video') {
|
|
385
|
-
return this.output.format.getSupportedVideoCodecs();
|
|
386
|
-
}
|
|
387
|
-
else if (x.track.type === 'audio') {
|
|
388
|
-
return this.output.format.getSupportedAudioCodecs();
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
return this.output.format.getSupportedSubtitleCodecs();
|
|
392
|
-
}
|
|
393
|
-
});
|
|
394
|
-
if (codecs.length === 1) {
|
|
395
|
-
elements.push(`\nTracks were discarded because your environment is not able to encode '${codecs[0]}'.`);
|
|
396
|
-
}
|
|
397
|
-
else {
|
|
398
|
-
elements.push('\nTracks were discarded because your environment is not able to encode any of the following'
|
|
399
|
-
+ ` codecs: ${codecs.map(x => `'${x}'`).join(', ')}.`);
|
|
400
|
-
}
|
|
401
|
-
if (codecs.includes('mp3')) {
|
|
402
|
-
elements.push(`\nThe @mediabunny/mp3-encoder extension package provides support for encoding MP3.`);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
else {
|
|
406
|
-
elements.push('\nCheck the discardedTracks field for more info.');
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return elements;
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Adds an external subtitle track to the output. This can be called after `init()` but before `execute()`.
|
|
413
|
-
* This is useful for adding subtitle tracks from separate files that are not part of the input video.
|
|
414
|
-
*
|
|
415
|
-
* @param source - The subtitle source to add
|
|
416
|
-
* @param metadata - Optional metadata for the subtitle track
|
|
417
|
-
* @param contentProvider - Optional async function that will be called after the output starts to add content to the subtitle source
|
|
418
|
-
*/
|
|
419
|
-
addExternalSubtitleTrack(source, metadata = {}, contentProvider) {
|
|
420
|
-
if (this._executed) {
|
|
421
|
-
throw new Error('Cannot add subtitle tracks after conversion has been executed.');
|
|
422
|
-
}
|
|
423
|
-
if (this.output.state !== 'pending') {
|
|
424
|
-
throw new Error('Cannot add subtitle tracks after output has been started.');
|
|
425
|
-
}
|
|
426
|
-
// Check track count limits
|
|
427
|
-
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
|
|
428
|
-
const currentSubtitleCount = this._addedCounts.subtitle + this._externalSubtitleSources.length;
|
|
429
|
-
if (currentSubtitleCount >= outputTrackCounts.subtitle.max) {
|
|
430
|
-
throw new Error(`Cannot add more subtitle tracks. Maximum of ${outputTrackCounts.subtitle.max} subtitle track(s) allowed.`);
|
|
431
|
-
}
|
|
432
|
-
const totalTrackCount = this._totalTrackCount + this._externalSubtitleSources.length + 1;
|
|
433
|
-
if (totalTrackCount > outputTrackCounts.total.max) {
|
|
434
|
-
throw new Error(`Cannot add more tracks. Maximum of ${outputTrackCounts.total.max} total track(s) allowed.`);
|
|
435
|
-
}
|
|
436
|
-
this._externalSubtitleSources.push({ source, metadata, contentProvider });
|
|
437
|
-
// Update validity check to include external subtitles
|
|
438
|
-
this.isValid = this._totalTrackCount + this._externalSubtitleSources.length >= outputTrackCounts.total.min
|
|
439
|
-
&& this._addedCounts.video >= outputTrackCounts.video.min
|
|
440
|
-
&& this._addedCounts.audio >= outputTrackCounts.audio.min
|
|
441
|
-
&& this._addedCounts.subtitle + this._externalSubtitleSources.length >= outputTrackCounts.subtitle.min;
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Executes the conversion process. Resolves once conversion is complete.
|
|
445
|
-
*
|
|
446
|
-
* Will throw if `isValid` is `false`.
|
|
447
|
-
*/
|
|
448
|
-
async execute() {
|
|
449
|
-
if (!this.isValid) {
|
|
450
|
-
throw new Error('Cannot execute this conversion because its output configuration is invalid. Make sure to always check'
|
|
451
|
-
+ ' the isValid field before executing a conversion.\n'
|
|
452
|
-
+ this._getInvalidityExplanation().join(''));
|
|
453
|
-
}
|
|
454
|
-
if (this._executed) {
|
|
455
|
-
throw new Error('Conversion cannot be executed twice.');
|
|
456
|
-
}
|
|
457
|
-
this._executed = true;
|
|
458
|
-
if (this.onProgress) {
|
|
459
|
-
this._computeProgress = true;
|
|
460
|
-
this._totalDuration = Math.min((await this.input.computeDuration()) - this._startTimestamp, this._endTimestamp - this._startTimestamp);
|
|
461
|
-
for (const track of this.utilizedTracks) {
|
|
462
|
-
this._maxTimestamps.set(track.id, 0);
|
|
463
|
-
}
|
|
464
|
-
this.onProgress?.(0);
|
|
465
|
-
}
|
|
466
|
-
// Add external subtitle tracks before starting the output
|
|
467
|
-
for (const { source, metadata } of this._externalSubtitleSources) {
|
|
468
|
-
this.output.addSubtitleTrack(source, metadata);
|
|
469
|
-
}
|
|
470
|
-
await this.output.start();
|
|
471
|
-
this._start();
|
|
472
|
-
// Now that output has started and tracks are connected, run content providers
|
|
473
|
-
const contentProviderPromises = this._externalSubtitleSources
|
|
474
|
-
.filter(s => s.contentProvider)
|
|
475
|
-
.map(s => s.contentProvider());
|
|
476
|
-
if (contentProviderPromises.length > 0) {
|
|
477
|
-
this._trackPromises.push(...contentProviderPromises);
|
|
478
|
-
}
|
|
479
|
-
try {
|
|
480
|
-
await Promise.all(this._trackPromises);
|
|
481
|
-
}
|
|
482
|
-
catch (error) {
|
|
483
|
-
if (!this._canceled) {
|
|
484
|
-
// Make sure to cancel to stop other encoding processes and clean up resources
|
|
485
|
-
void this.cancel();
|
|
486
|
-
}
|
|
487
|
-
throw error;
|
|
488
|
-
}
|
|
489
|
-
if (this._canceled) {
|
|
490
|
-
await new Promise(() => { }); // Never resolve
|
|
491
|
-
}
|
|
492
|
-
await this.output.finalize();
|
|
493
|
-
if (this._computeProgress) {
|
|
494
|
-
this.onProgress?.(1);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
/** Cancels the conversion process. Does nothing if the conversion is already complete. */
|
|
498
|
-
async cancel() {
|
|
499
|
-
if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
if (this._canceled) {
|
|
503
|
-
console.warn('Conversion already canceled.');
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
this._canceled = true;
|
|
507
|
-
await this.output.cancel();
|
|
508
|
-
}
|
|
509
|
-
/** @internal */
|
|
510
|
-
async _processVideoTrack(track, trackOptions) {
|
|
511
|
-
const sourceCodec = track.codec;
|
|
512
|
-
if (!sourceCodec) {
|
|
513
|
-
this.discardedTracks.push({
|
|
514
|
-
track,
|
|
515
|
-
reason: 'unknown_source_codec',
|
|
516
|
-
});
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
let videoSource;
|
|
520
|
-
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
|
|
521
|
-
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
|
|
522
|
-
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
|
|
523
|
-
? [track.codedWidth, track.codedHeight]
|
|
524
|
-
: [track.codedHeight, track.codedWidth];
|
|
525
|
-
const crop = trackOptions.crop;
|
|
526
|
-
if (crop) {
|
|
527
|
-
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
|
|
528
|
-
}
|
|
529
|
-
const [originalWidth, originalHeight] = crop
|
|
530
|
-
? [crop.width, crop.height]
|
|
531
|
-
: [rotatedWidth, rotatedHeight];
|
|
532
|
-
let width = originalWidth;
|
|
533
|
-
let height = originalHeight;
|
|
534
|
-
const aspectRatio = width / height;
|
|
535
|
-
// A lot of video encoders require that the dimensions be multiples of 2
|
|
536
|
-
const ceilToMultipleOfTwo = (value) => Math.ceil(value / 2) * 2;
|
|
537
|
-
if (trackOptions.width !== undefined && trackOptions.height === undefined) {
|
|
538
|
-
width = ceilToMultipleOfTwo(trackOptions.width);
|
|
539
|
-
height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
|
|
540
|
-
}
|
|
541
|
-
else if (trackOptions.width === undefined && trackOptions.height !== undefined) {
|
|
542
|
-
height = ceilToMultipleOfTwo(trackOptions.height);
|
|
543
|
-
width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
|
|
544
|
-
}
|
|
545
|
-
else if (trackOptions.width !== undefined && trackOptions.height !== undefined) {
|
|
546
|
-
width = ceilToMultipleOfTwo(trackOptions.width);
|
|
547
|
-
height = ceilToMultipleOfTwo(trackOptions.height);
|
|
548
|
-
}
|
|
549
|
-
const firstTimestamp = await track.getFirstTimestamp();
|
|
550
|
-
const needsTranscode = !!trackOptions.forceTranscode
|
|
551
|
-
|| this._startTimestamp > 0
|
|
552
|
-
|| firstTimestamp < 0
|
|
553
|
-
|| !!trackOptions.frameRate
|
|
554
|
-
|| trackOptions.keyFrameInterval !== undefined
|
|
555
|
-
|| trackOptions.process !== undefined;
|
|
556
|
-
let needsRerender = width !== originalWidth
|
|
557
|
-
|| height !== originalHeight
|
|
558
|
-
// TODO This is suboptimal: Forcing a rerender when both rotation and process are set is not
|
|
559
|
-
// performance-optimal, but right now there's no other way because we can't change the track rotation
|
|
560
|
-
// metadata after the output has already started. Should be possible with API changes in v2, though!
|
|
561
|
-
|| (totalRotation !== 0 && (!outputSupportsRotation || trackOptions.process !== undefined))
|
|
562
|
-
|| !!crop;
|
|
563
|
-
const alpha = trackOptions.alpha ?? 'discard';
|
|
564
|
-
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
|
565
|
-
if (!needsTranscode
|
|
566
|
-
&& !trackOptions.bitrate
|
|
567
|
-
&& !needsRerender
|
|
568
|
-
&& videoCodecs.includes(sourceCodec)
|
|
569
|
-
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)) {
|
|
570
|
-
// Fast path, we can simply copy over the encoded packets
|
|
571
|
-
const source = new EncodedVideoPacketSource(sourceCodec);
|
|
572
|
-
videoSource = source;
|
|
573
|
-
this._trackPromises.push((async () => {
|
|
574
|
-
await this._started;
|
|
575
|
-
const sink = new EncodedPacketSink(track);
|
|
576
|
-
const decoderConfig = await track.getDecoderConfig();
|
|
577
|
-
const meta = { decoderConfig: decoderConfig ?? undefined };
|
|
578
|
-
const endPacket = Number.isFinite(this._endTimestamp)
|
|
579
|
-
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
|
|
580
|
-
: undefined;
|
|
581
|
-
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
|
|
582
|
-
if (this._canceled) {
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
if (alpha === 'discard') {
|
|
586
|
-
// Feels hacky given that the rest of the packet is readonly. But, works for now.
|
|
587
|
-
delete packet.sideData.alpha;
|
|
588
|
-
delete packet.sideData.alphaByteLength;
|
|
589
|
-
}
|
|
590
|
-
this._reportProgress(track.id, packet.timestamp);
|
|
591
|
-
await source.add(packet, meta);
|
|
592
|
-
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
593
|
-
await this._synchronizer.wait(packet.timestamp);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
source.close();
|
|
597
|
-
this._synchronizer.closeTrack(track.id);
|
|
598
|
-
})());
|
|
599
|
-
}
|
|
600
|
-
else {
|
|
601
|
-
// We need to decode & reencode the video
|
|
602
|
-
const canDecode = await track.canDecode();
|
|
603
|
-
if (!canDecode) {
|
|
604
|
-
this.discardedTracks.push({
|
|
605
|
-
track,
|
|
606
|
-
reason: 'undecodable_source_codec',
|
|
607
|
-
});
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
if (trackOptions.codec) {
|
|
611
|
-
videoCodecs = videoCodecs.filter(codec => codec === trackOptions.codec);
|
|
612
|
-
}
|
|
613
|
-
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
|
614
|
-
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, {
|
|
615
|
-
width: trackOptions.process && trackOptions.processedWidth
|
|
616
|
-
? trackOptions.processedWidth
|
|
617
|
-
: width,
|
|
618
|
-
height: trackOptions.process && trackOptions.processedHeight
|
|
619
|
-
? trackOptions.processedHeight
|
|
620
|
-
: height,
|
|
621
|
-
bitrate,
|
|
622
|
-
});
|
|
623
|
-
if (!encodableCodec) {
|
|
624
|
-
this.discardedTracks.push({
|
|
625
|
-
track,
|
|
626
|
-
reason: 'no_encodable_target_codec',
|
|
627
|
-
});
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
const encodingConfig = {
|
|
631
|
-
codec: encodableCodec,
|
|
632
|
-
bitrate,
|
|
633
|
-
keyFrameInterval: trackOptions.keyFrameInterval,
|
|
634
|
-
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
|
635
|
-
alpha,
|
|
636
|
-
};
|
|
637
|
-
const source = new VideoSampleSource(encodingConfig);
|
|
638
|
-
videoSource = source;
|
|
639
|
-
if (!needsRerender) {
|
|
640
|
-
// If we're directly passing decoded samples back to the encoder, sometimes the encoder may error due
|
|
641
|
-
// to lack of support of certain video frame formats, like when HDR is at play. To check for this, we
|
|
642
|
-
// first try to pass a single frame to the encoder to see how it behaves. If it throws, we then fall
|
|
643
|
-
// back to the rerender path.
|
|
644
|
-
//
|
|
645
|
-
// Creating a new temporary Output is sort of hacky, but due to a lack of an isolated encoder API right
|
|
646
|
-
// now, this is the simplest way. Will refactor in the future! TODO
|
|
647
|
-
const tempOutput = new Output({
|
|
648
|
-
format: new Mp4OutputFormat(), // Supports all video codecs
|
|
649
|
-
target: new NullTarget(),
|
|
650
|
-
});
|
|
651
|
-
const tempSource = new VideoSampleSource(encodingConfig);
|
|
652
|
-
tempOutput.addVideoTrack(tempSource);
|
|
653
|
-
await tempOutput.start();
|
|
654
|
-
const sink = new VideoSampleSink(track);
|
|
655
|
-
const firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
|
|
656
|
-
if (firstSample) {
|
|
657
|
-
try {
|
|
658
|
-
await tempSource.add(firstSample);
|
|
659
|
-
firstSample.close();
|
|
660
|
-
await tempOutput.finalize();
|
|
661
|
-
}
|
|
662
|
-
catch (error) {
|
|
663
|
-
console.info('Error when probing encoder support. Falling back to rerender path.', error);
|
|
664
|
-
needsRerender = true;
|
|
665
|
-
void tempOutput.cancel();
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
else {
|
|
669
|
-
await tempOutput.cancel();
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
if (needsRerender) {
|
|
673
|
-
this._trackPromises.push((async () => {
|
|
674
|
-
await this._started;
|
|
675
|
-
const sink = new CanvasSink(track, {
|
|
676
|
-
width,
|
|
677
|
-
height,
|
|
678
|
-
fit: trackOptions.fit ?? 'fill',
|
|
679
|
-
rotation: totalRotation, // Bake the rotation into the output
|
|
680
|
-
crop: trackOptions.crop,
|
|
681
|
-
poolSize: 1,
|
|
682
|
-
alpha: alpha === 'keep',
|
|
683
|
-
});
|
|
684
|
-
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
|
685
|
-
const frameRate = trackOptions.frameRate;
|
|
686
|
-
let lastCanvas = null;
|
|
687
|
-
let lastCanvasTimestamp = null;
|
|
688
|
-
let lastCanvasEndTimestamp = null;
|
|
689
|
-
/** Repeats the last sample to pad out the time until the specified timestamp. */
|
|
690
|
-
const padFrames = async (until) => {
|
|
691
|
-
assert(lastCanvas);
|
|
692
|
-
assert(frameRate !== undefined);
|
|
693
|
-
const frameDifference = Math.round((until - lastCanvasTimestamp) * frameRate);
|
|
694
|
-
for (let i = 1; i < frameDifference; i++) {
|
|
695
|
-
const sample = new VideoSample(lastCanvas, {
|
|
696
|
-
timestamp: lastCanvasTimestamp + i / frameRate,
|
|
697
|
-
duration: 1 / frameRate,
|
|
698
|
-
});
|
|
699
|
-
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
700
|
-
sample.close();
|
|
701
|
-
}
|
|
702
|
-
};
|
|
703
|
-
for await (const { canvas, timestamp, duration } of iterator) {
|
|
704
|
-
if (this._canceled) {
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
|
|
708
|
-
lastCanvasEndTimestamp = adjustedSampleTimestamp + duration;
|
|
709
|
-
if (frameRate !== undefined) {
|
|
710
|
-
// Logic for skipping/repeating frames when a frame rate is set
|
|
711
|
-
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
|
712
|
-
if (lastCanvas !== null) {
|
|
713
|
-
if (alignedTimestamp <= lastCanvasTimestamp) {
|
|
714
|
-
lastCanvas = canvas;
|
|
715
|
-
lastCanvasTimestamp = alignedTimestamp;
|
|
716
|
-
// Skip this sample, since we already added one for this frame
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
else {
|
|
720
|
-
// Check if we may need to repeat the previous frame
|
|
721
|
-
await padFrames(alignedTimestamp);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
adjustedSampleTimestamp = alignedTimestamp;
|
|
725
|
-
}
|
|
726
|
-
const sample = new VideoSample(canvas, {
|
|
727
|
-
timestamp: adjustedSampleTimestamp,
|
|
728
|
-
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
|
729
|
-
});
|
|
730
|
-
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
731
|
-
sample.close();
|
|
732
|
-
if (frameRate !== undefined) {
|
|
733
|
-
lastCanvas = canvas;
|
|
734
|
-
lastCanvasTimestamp = adjustedSampleTimestamp;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
if (lastCanvas) {
|
|
738
|
-
assert(lastCanvasEndTimestamp !== null);
|
|
739
|
-
assert(frameRate !== undefined);
|
|
740
|
-
// If necessary, pad until the end timestamp of the last sample
|
|
741
|
-
await padFrames(Math.floor(lastCanvasEndTimestamp * frameRate) / frameRate);
|
|
742
|
-
}
|
|
743
|
-
source.close();
|
|
744
|
-
this._synchronizer.closeTrack(track.id);
|
|
745
|
-
})());
|
|
746
|
-
}
|
|
747
|
-
else {
|
|
748
|
-
this._trackPromises.push((async () => {
|
|
749
|
-
await this._started;
|
|
750
|
-
const sink = new VideoSampleSink(track);
|
|
751
|
-
const frameRate = trackOptions.frameRate;
|
|
752
|
-
let lastSample = null;
|
|
753
|
-
let lastSampleTimestamp = null;
|
|
754
|
-
let lastSampleEndTimestamp = null;
|
|
755
|
-
/** Repeats the last sample to pad out the time until the specified timestamp. */
|
|
756
|
-
const padFrames = async (until) => {
|
|
757
|
-
assert(lastSample);
|
|
758
|
-
assert(frameRate !== undefined);
|
|
759
|
-
const frameDifference = Math.round((until - lastSampleTimestamp) * frameRate);
|
|
760
|
-
for (let i = 1; i < frameDifference; i++) {
|
|
761
|
-
lastSample.setTimestamp(lastSampleTimestamp + i / frameRate);
|
|
762
|
-
lastSample.setDuration(1 / frameRate);
|
|
763
|
-
await this._registerVideoSample(track, trackOptions, source, lastSample);
|
|
764
|
-
}
|
|
765
|
-
lastSample.close();
|
|
766
|
-
};
|
|
767
|
-
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
|
768
|
-
if (this._canceled) {
|
|
769
|
-
lastSample?.close();
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
|
773
|
-
lastSampleEndTimestamp = adjustedSampleTimestamp + sample.duration;
|
|
774
|
-
if (frameRate !== undefined) {
|
|
775
|
-
// Logic for skipping/repeating frames when a frame rate is set
|
|
776
|
-
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
|
777
|
-
if (lastSample !== null) {
|
|
778
|
-
if (alignedTimestamp <= lastSampleTimestamp) {
|
|
779
|
-
lastSample.close();
|
|
780
|
-
lastSample = sample;
|
|
781
|
-
lastSampleTimestamp = alignedTimestamp;
|
|
782
|
-
// Skip this sample, since we already added one for this frame
|
|
783
|
-
continue;
|
|
784
|
-
}
|
|
785
|
-
else {
|
|
786
|
-
// Check if we may need to repeat the previous frame
|
|
787
|
-
await padFrames(alignedTimestamp);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
adjustedSampleTimestamp = alignedTimestamp;
|
|
791
|
-
sample.setDuration(1 / frameRate);
|
|
792
|
-
}
|
|
793
|
-
sample.setTimestamp(adjustedSampleTimestamp);
|
|
794
|
-
await this._registerVideoSample(track, trackOptions, source, sample);
|
|
795
|
-
if (frameRate !== undefined) {
|
|
796
|
-
lastSample = sample;
|
|
797
|
-
lastSampleTimestamp = adjustedSampleTimestamp;
|
|
798
|
-
}
|
|
799
|
-
else {
|
|
800
|
-
sample.close();
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
if (lastSample) {
|
|
804
|
-
assert(lastSampleEndTimestamp !== null);
|
|
805
|
-
assert(frameRate !== undefined);
|
|
806
|
-
// If necessary, pad until the end timestamp of the last sample
|
|
807
|
-
await padFrames(Math.floor(lastSampleEndTimestamp * frameRate) / frameRate);
|
|
808
|
-
}
|
|
809
|
-
source.close();
|
|
810
|
-
this._synchronizer.closeTrack(track.id);
|
|
811
|
-
})());
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
this.output.addVideoTrack(videoSource, {
|
|
815
|
-
frameRate: trackOptions.frameRate,
|
|
816
|
-
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
|
817
|
-
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
|
|
818
|
-
name: track.name ?? undefined,
|
|
819
|
-
disposition: track.disposition,
|
|
820
|
-
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
|
821
|
-
});
|
|
822
|
-
this._addedCounts.video++;
|
|
823
|
-
this._totalTrackCount++;
|
|
824
|
-
this.utilizedTracks.push(track);
|
|
825
|
-
}
|
|
826
|
-
/** @internal */
|
|
827
|
-
async _registerVideoSample(track, trackOptions, source, sample) {
|
|
828
|
-
if (this._canceled) {
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
this._reportProgress(track.id, sample.timestamp);
|
|
832
|
-
let finalSamples;
|
|
833
|
-
if (!trackOptions.process) {
|
|
834
|
-
finalSamples = [sample];
|
|
835
|
-
}
|
|
836
|
-
else {
|
|
837
|
-
let processed = trackOptions.process(sample);
|
|
838
|
-
if (processed instanceof Promise)
|
|
839
|
-
processed = await processed;
|
|
840
|
-
if (!Array.isArray(processed)) {
|
|
841
|
-
processed = processed === null ? [] : [processed];
|
|
842
|
-
}
|
|
843
|
-
finalSamples = processed.map((x) => {
|
|
844
|
-
if (x instanceof VideoSample) {
|
|
845
|
-
return x;
|
|
846
|
-
}
|
|
847
|
-
if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) {
|
|
848
|
-
return new VideoSample(x);
|
|
849
|
-
}
|
|
850
|
-
// Calling the VideoSample constructor here will automatically handle input validation for us
|
|
851
|
-
// (it throws for any non-legal argument).
|
|
852
|
-
return new VideoSample(x, {
|
|
853
|
-
timestamp: sample.timestamp,
|
|
854
|
-
duration: sample.duration,
|
|
855
|
-
});
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
for (const finalSample of finalSamples) {
|
|
859
|
-
if (this._canceled) {
|
|
860
|
-
break;
|
|
861
|
-
}
|
|
862
|
-
await source.add(finalSample);
|
|
863
|
-
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
|
864
|
-
await this._synchronizer.wait(finalSample.timestamp);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
for (const finalSample of finalSamples) {
|
|
868
|
-
if (finalSample !== sample) {
|
|
869
|
-
finalSample.close();
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
/** @internal */
|
|
874
|
-
async _processAudioTrack(track, trackOptions) {
|
|
875
|
-
const sourceCodec = track.codec;
|
|
876
|
-
if (!sourceCodec) {
|
|
877
|
-
this.discardedTracks.push({
|
|
878
|
-
track,
|
|
879
|
-
reason: 'unknown_source_codec',
|
|
880
|
-
});
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
let audioSource;
|
|
884
|
-
const originalNumberOfChannels = track.numberOfChannels;
|
|
885
|
-
const originalSampleRate = track.sampleRate;
|
|
886
|
-
const firstTimestamp = await track.getFirstTimestamp();
|
|
887
|
-
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
|
888
|
-
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
|
889
|
-
let needsResample = numberOfChannels !== originalNumberOfChannels
|
|
890
|
-
|| sampleRate !== originalSampleRate
|
|
891
|
-
|| this._startTimestamp > 0
|
|
892
|
-
|| firstTimestamp < 0;
|
|
893
|
-
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
|
894
|
-
if (!trackOptions.forceTranscode
|
|
895
|
-
&& !trackOptions.bitrate
|
|
896
|
-
&& !needsResample
|
|
897
|
-
&& audioCodecs.includes(sourceCodec)
|
|
898
|
-
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
|
899
|
-
&& !trackOptions.process) {
|
|
900
|
-
// Fast path, we can simply copy over the encoded packets
|
|
901
|
-
const source = new EncodedAudioPacketSource(sourceCodec);
|
|
902
|
-
audioSource = source;
|
|
903
|
-
this._trackPromises.push((async () => {
|
|
904
|
-
await this._started;
|
|
905
|
-
const sink = new EncodedPacketSink(track);
|
|
906
|
-
const decoderConfig = await track.getDecoderConfig();
|
|
907
|
-
const meta = { decoderConfig: decoderConfig ?? undefined };
|
|
908
|
-
const endPacket = Number.isFinite(this._endTimestamp)
|
|
909
|
-
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
|
|
910
|
-
: undefined;
|
|
911
|
-
for await (const packet of sink.packets(undefined, endPacket)) {
|
|
912
|
-
if (this._canceled) {
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
this._reportProgress(track.id, packet.timestamp);
|
|
916
|
-
await source.add(packet, meta);
|
|
917
|
-
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
918
|
-
await this._synchronizer.wait(packet.timestamp);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
source.close();
|
|
922
|
-
this._synchronizer.closeTrack(track.id);
|
|
923
|
-
})());
|
|
924
|
-
}
|
|
925
|
-
else {
|
|
926
|
-
// We need to decode & reencode the audio
|
|
927
|
-
const canDecode = await track.canDecode();
|
|
928
|
-
if (!canDecode) {
|
|
929
|
-
this.discardedTracks.push({
|
|
930
|
-
track,
|
|
931
|
-
reason: 'undecodable_source_codec',
|
|
932
|
-
});
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
let codecOfChoice = null;
|
|
936
|
-
if (trackOptions.codec) {
|
|
937
|
-
audioCodecs = audioCodecs.filter(codec => codec === trackOptions.codec);
|
|
938
|
-
}
|
|
939
|
-
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
|
940
|
-
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
|
941
|
-
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels
|
|
942
|
-
? trackOptions.processedNumberOfChannels
|
|
943
|
-
: numberOfChannels,
|
|
944
|
-
sampleRate: trackOptions.process && trackOptions.processedSampleRate
|
|
945
|
-
? trackOptions.processedSampleRate
|
|
946
|
-
: sampleRate,
|
|
947
|
-
bitrate,
|
|
948
|
-
});
|
|
949
|
-
if (!encodableCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
|
|
950
|
-
&& audioCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
|
|
951
|
-
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)) {
|
|
952
|
-
// We could not find a compatible non-PCM codec despite the container supporting them. This can be
|
|
953
|
-
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with
|
|
954
|
-
// fallback parameters.
|
|
955
|
-
const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, {
|
|
956
|
-
numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS,
|
|
957
|
-
sampleRate: FALLBACK_SAMPLE_RATE,
|
|
958
|
-
bitrate,
|
|
959
|
-
});
|
|
960
|
-
const nonPcmCodec = encodableCodecsWithDefaultParams
|
|
961
|
-
.find(codec => NON_PCM_AUDIO_CODECS.includes(codec));
|
|
962
|
-
if (nonPcmCodec) {
|
|
963
|
-
// We are able to encode using a non-PCM codec, but it'll require resampling
|
|
964
|
-
needsResample = true;
|
|
965
|
-
codecOfChoice = nonPcmCodec;
|
|
966
|
-
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
|
967
|
-
sampleRate = FALLBACK_SAMPLE_RATE;
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
else {
|
|
971
|
-
codecOfChoice = encodableCodecs[0] ?? null;
|
|
972
|
-
}
|
|
973
|
-
if (codecOfChoice === null) {
|
|
974
|
-
this.discardedTracks.push({
|
|
975
|
-
track,
|
|
976
|
-
reason: 'no_encodable_target_codec',
|
|
977
|
-
});
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
if (needsResample) {
|
|
981
|
-
audioSource = this._resampleAudio(track, trackOptions, codecOfChoice, numberOfChannels, sampleRate, bitrate);
|
|
982
|
-
}
|
|
983
|
-
else {
|
|
984
|
-
const source = new AudioSampleSource({
|
|
985
|
-
codec: codecOfChoice,
|
|
986
|
-
bitrate,
|
|
987
|
-
});
|
|
988
|
-
audioSource = source;
|
|
989
|
-
this._trackPromises.push((async () => {
|
|
990
|
-
await this._started;
|
|
991
|
-
const sink = new AudioSampleSink(track);
|
|
992
|
-
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
|
993
|
-
if (this._canceled) {
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
await this._registerAudioSample(track, trackOptions, source, sample);
|
|
997
|
-
sample.close();
|
|
998
|
-
}
|
|
999
|
-
source.close();
|
|
1000
|
-
this._synchronizer.closeTrack(track.id);
|
|
1001
|
-
})());
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
this.output.addAudioTrack(audioSource, {
|
|
1005
|
-
// TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
|
|
1006
|
-
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
|
|
1007
|
-
name: track.name ?? undefined,
|
|
1008
|
-
disposition: track.disposition,
|
|
1009
|
-
});
|
|
1010
|
-
this._addedCounts.audio++;
|
|
1011
|
-
this._totalTrackCount++;
|
|
1012
|
-
this.utilizedTracks.push(track);
|
|
1013
|
-
}
|
|
1014
|
-
/** @internal */
|
|
1015
|
-
async _registerAudioSample(track, trackOptions, source, sample) {
|
|
1016
|
-
if (this._canceled) {
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
this._reportProgress(track.id, sample.timestamp);
|
|
1020
|
-
let finalSamples;
|
|
1021
|
-
if (!trackOptions.process) {
|
|
1022
|
-
finalSamples = [sample];
|
|
1023
|
-
}
|
|
1024
|
-
else {
|
|
1025
|
-
let processed = trackOptions.process(sample);
|
|
1026
|
-
if (processed instanceof Promise)
|
|
1027
|
-
processed = await processed;
|
|
1028
|
-
if (!Array.isArray(processed)) {
|
|
1029
|
-
processed = processed === null ? [] : [processed];
|
|
1030
|
-
}
|
|
1031
|
-
if (!processed.every(x => x instanceof AudioSample)) {
|
|
1032
|
-
throw new TypeError('The audio process function must return an AudioSample, null, or an array of AudioSamples.');
|
|
1033
|
-
}
|
|
1034
|
-
finalSamples = processed;
|
|
1035
|
-
}
|
|
1036
|
-
for (const finalSample of finalSamples) {
|
|
1037
|
-
if (this._canceled) {
|
|
1038
|
-
break;
|
|
1039
|
-
}
|
|
1040
|
-
await source.add(finalSample);
|
|
1041
|
-
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
|
1042
|
-
await this._synchronizer.wait(finalSample.timestamp);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
for (const finalSample of finalSamples) {
|
|
1046
|
-
if (finalSample !== sample) {
|
|
1047
|
-
finalSample.close();
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
/** @internal */
|
|
1052
|
-
async _processSubtitleTrack(track, trackOptions) {
|
|
1053
|
-
const sourceCodec = track.codec;
|
|
1054
|
-
if (!sourceCodec) {
|
|
1055
|
-
this.discardedTracks.push({
|
|
1056
|
-
track,
|
|
1057
|
-
reason: 'unknown_source_codec',
|
|
1058
|
-
});
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
// Determine target codec
|
|
1062
|
-
let targetCodec = trackOptions.codec ?? sourceCodec;
|
|
1063
|
-
const supportedCodecs = this.output.format.getSupportedSubtitleCodecs();
|
|
1064
|
-
// Check if target codec is supported by output format
|
|
1065
|
-
if (!supportedCodecs.includes(targetCodec)) {
|
|
1066
|
-
// Try to use source codec if no specific codec was requested
|
|
1067
|
-
if (!trackOptions.codec && supportedCodecs.includes(sourceCodec)) {
|
|
1068
|
-
targetCodec = sourceCodec;
|
|
1069
|
-
}
|
|
1070
|
-
else {
|
|
1071
|
-
// If a specific codec was requested but not supported, or source codec not supported, discard
|
|
1072
|
-
this.discardedTracks.push({
|
|
1073
|
-
track,
|
|
1074
|
-
reason: 'no_encodable_target_codec',
|
|
1075
|
-
});
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
// Create subtitle source
|
|
1080
|
-
const subtitleSource = new TextSubtitleSource(targetCodec);
|
|
1081
|
-
// Add track promise to extract and add subtitle cues
|
|
1082
|
-
this._trackPromises.push((async () => {
|
|
1083
|
-
await this._started;
|
|
1084
|
-
let subtitleText;
|
|
1085
|
-
// If no trim or codec conversion needed, use the efficient export method
|
|
1086
|
-
if (this._startTimestamp === 0 && !Number.isFinite(this._endTimestamp) && targetCodec === sourceCodec) {
|
|
1087
|
-
subtitleText = await track.exportToText();
|
|
1088
|
-
}
|
|
1089
|
-
else {
|
|
1090
|
-
// Extract and adjust cues for trim/conversion
|
|
1091
|
-
const cues = [];
|
|
1092
|
-
for await (const cue of track.getCues()) {
|
|
1093
|
-
const cueEndTime = cue.timestamp + cue.duration;
|
|
1094
|
-
// Apply trim if needed
|
|
1095
|
-
if (this._startTimestamp > 0 || Number.isFinite(this._endTimestamp)) {
|
|
1096
|
-
// Skip cues completely outside trim range
|
|
1097
|
-
if (cueEndTime <= this._startTimestamp || cue.timestamp >= this._endTimestamp) {
|
|
1098
|
-
continue;
|
|
1099
|
-
}
|
|
1100
|
-
// Adjust cue timing
|
|
1101
|
-
const adjustedTimestamp = Math.max(cue.timestamp - this._startTimestamp, 0);
|
|
1102
|
-
const adjustedEndTime = Math.min(cueEndTime - this._startTimestamp, this._endTimestamp - this._startTimestamp);
|
|
1103
|
-
cues.push({
|
|
1104
|
-
...cue,
|
|
1105
|
-
timestamp: adjustedTimestamp,
|
|
1106
|
-
duration: adjustedEndTime - adjustedTimestamp,
|
|
1107
|
-
});
|
|
1108
|
-
}
|
|
1109
|
-
else {
|
|
1110
|
-
cues.push(cue);
|
|
1111
|
-
}
|
|
1112
|
-
if (this._canceled) {
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
// Convert to target format
|
|
1117
|
-
if (targetCodec === 'srt') {
|
|
1118
|
-
subtitleText = formatCuesToSrt(cues);
|
|
1119
|
-
}
|
|
1120
|
-
else if (targetCodec === 'webvtt') {
|
|
1121
|
-
subtitleText = formatCuesToWebVTT(cues);
|
|
1122
|
-
}
|
|
1123
|
-
else if (targetCodec === 'ass' || targetCodec === 'ssa') {
|
|
1124
|
-
subtitleText = formatCuesToAss(cues, '');
|
|
1125
|
-
}
|
|
1126
|
-
else {
|
|
1127
|
-
// For other formats (tx3g, ttml), export from track
|
|
1128
|
-
subtitleText = await track.exportToText(targetCodec);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
await subtitleSource.add(subtitleText);
|
|
1132
|
-
subtitleSource.close();
|
|
1133
|
-
})());
|
|
1134
|
-
this.output.addSubtitleTrack(subtitleSource, {
|
|
1135
|
-
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
|
|
1136
|
-
name: track.name ?? undefined,
|
|
1137
|
-
});
|
|
1138
|
-
this._addedCounts.subtitle++;
|
|
1139
|
-
this._totalTrackCount++;
|
|
1140
|
-
this.utilizedTracks.push(track);
|
|
1141
|
-
}
|
|
1142
|
-
/** @internal */
|
|
1143
|
-
_resampleAudio(track, trackOptions, codec, targetNumberOfChannels, targetSampleRate, bitrate) {
|
|
1144
|
-
const source = new AudioSampleSource({
|
|
1145
|
-
codec,
|
|
1146
|
-
bitrate,
|
|
1147
|
-
});
|
|
1148
|
-
this._trackPromises.push((async () => {
|
|
1149
|
-
await this._started;
|
|
1150
|
-
const resampler = new AudioResampler({
|
|
1151
|
-
targetNumberOfChannels,
|
|
1152
|
-
targetSampleRate,
|
|
1153
|
-
startTime: this._startTimestamp,
|
|
1154
|
-
endTime: this._endTimestamp,
|
|
1155
|
-
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
|
1156
|
-
});
|
|
1157
|
-
const sink = new AudioSampleSink(track);
|
|
1158
|
-
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
|
1159
|
-
for await (const sample of iterator) {
|
|
1160
|
-
if (this._canceled) {
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
await resampler.add(sample);
|
|
1164
|
-
}
|
|
1165
|
-
await resampler.finalize();
|
|
1166
|
-
source.close();
|
|
1167
|
-
this._synchronizer.closeTrack(track.id);
|
|
1168
|
-
})());
|
|
1169
|
-
return source;
|
|
1170
|
-
}
|
|
1171
|
-
/** @internal */
|
|
1172
|
-
_reportProgress(trackId, endTimestamp) {
|
|
1173
|
-
if (!this._computeProgress) {
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
assert(this._totalDuration !== null);
|
|
1177
|
-
this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId)));
|
|
1178
|
-
const minTimestamp = Math.min(...this._maxTimestamps.values());
|
|
1179
|
-
const newProgress = clamp(minTimestamp / this._totalDuration, 0, 1);
|
|
1180
|
-
if (newProgress !== this._lastProgress) {
|
|
1181
|
-
this._lastProgress = newProgress;
|
|
1182
|
-
this.onProgress?.(newProgress);
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
const MAX_TIMESTAMP_GAP = 5;
|
|
1187
|
-
/**
|
|
1188
|
-
* Utility class for synchronizing multiple track packet consumers with one another. We don't want one consumer to get
|
|
1189
|
-
* too out-of-sync with the others, as that may lead to a large number of packets that need to be internally buffered
|
|
1190
|
-
* before they can be written. Therefore, we use this class to slow down a consumer if it is too far ahead of the
|
|
1191
|
-
* slowest consumer.
|
|
1192
|
-
*/
|
|
1193
|
-
class TrackSynchronizer {
|
|
1194
|
-
constructor() {
|
|
1195
|
-
this.maxTimestamps = new Map(); // Track ID -> timestamp
|
|
1196
|
-
this.resolvers = [];
|
|
1197
|
-
}
|
|
1198
|
-
computeMinAndMaybeResolve() {
|
|
1199
|
-
let newMin = Infinity;
|
|
1200
|
-
for (const [, timestamp] of this.maxTimestamps) {
|
|
1201
|
-
newMin = Math.min(newMin, timestamp);
|
|
1202
|
-
}
|
|
1203
|
-
for (let i = 0; i < this.resolvers.length; i++) {
|
|
1204
|
-
const entry = this.resolvers[i];
|
|
1205
|
-
if (entry.timestamp - newMin < MAX_TIMESTAMP_GAP) {
|
|
1206
|
-
// The gap has gotten small enough again, the consumer can continue again
|
|
1207
|
-
entry.resolve();
|
|
1208
|
-
this.resolvers.splice(i, 1);
|
|
1209
|
-
i--;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
return newMin;
|
|
1213
|
-
}
|
|
1214
|
-
shouldWait(trackId, timestamp) {
|
|
1215
|
-
this.maxTimestamps.set(trackId, Math.max(timestamp, this.maxTimestamps.get(trackId) ?? -Infinity));
|
|
1216
|
-
const newMin = this.computeMinAndMaybeResolve();
|
|
1217
|
-
return timestamp - newMin >= MAX_TIMESTAMP_GAP; // Should wait if it is too far ahead of the slowest consumer
|
|
1218
|
-
}
|
|
1219
|
-
wait(timestamp) {
|
|
1220
|
-
const { promise, resolve } = promiseWithResolvers();
|
|
1221
|
-
this.resolvers.push({
|
|
1222
|
-
timestamp,
|
|
1223
|
-
resolve,
|
|
1224
|
-
});
|
|
1225
|
-
return promise;
|
|
1226
|
-
}
|
|
1227
|
-
closeTrack(trackId) {
|
|
1228
|
-
this.maxTimestamps.delete(trackId);
|
|
1229
|
-
this.computeMinAndMaybeResolve();
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Utility class to handle audio resampling, handling both sample rate resampling as well as channel up/downmixing.
|
|
1234
|
-
* The advantage over doing this manually rather than using OfflineAudioContext to do it for us is the artifact-free
|
|
1235
|
-
* handling of putting multiple resampled audio samples back to back, which produces flaky results using
|
|
1236
|
-
* OfflineAudioContext.
|
|
1237
|
-
*/
|
|
1238
|
-
export class AudioResampler {
|
|
1239
|
-
constructor(options) {
|
|
1240
|
-
this.sourceSampleRate = null;
|
|
1241
|
-
this.sourceNumberOfChannels = null;
|
|
1242
|
-
this.targetSampleRate = options.targetSampleRate;
|
|
1243
|
-
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
|
1244
|
-
this.startTime = options.startTime;
|
|
1245
|
-
this.endTime = options.endTime;
|
|
1246
|
-
this.onSample = options.onSample;
|
|
1247
|
-
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
|
|
1248
|
-
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
|
1249
|
-
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
|
1250
|
-
this.bufferStartFrame = 0;
|
|
1251
|
-
this.maxWrittenFrame = -1;
|
|
1252
|
-
}
|
|
1253
|
-
/**
|
|
1254
|
-
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
|
|
1255
|
-
*/
|
|
1256
|
-
doChannelMixerSetup() {
|
|
1257
|
-
assert(this.sourceNumberOfChannels !== null);
|
|
1258
|
-
const sourceNum = this.sourceNumberOfChannels;
|
|
1259
|
-
const targetNum = this.targetNumberOfChannels;
|
|
1260
|
-
// Logic taken from
|
|
1261
|
-
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API
|
|
1262
|
-
// Most of the mapping functions are branchless.
|
|
1263
|
-
if (sourceNum === 1 && targetNum === 2) {
|
|
1264
|
-
// Mono to Stereo: M -> L, M -> R
|
|
1265
|
-
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
1266
|
-
return sourceData[sourceFrameIndex * sourceNum];
|
|
1267
|
-
};
|
|
1268
|
-
}
|
|
1269
|
-
else if (sourceNum === 1 && targetNum === 4) {
|
|
1270
|
-
// Mono to Quad: M -> L, M -> R, 0 -> SL, 0 -> SR
|
|
1271
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1272
|
-
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex < 2);
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
else if (sourceNum === 1 && targetNum === 6) {
|
|
1276
|
-
// Mono to 5.1: 0 -> L, 0 -> R, M -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
|
1277
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1278
|
-
return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex === 2);
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
else if (sourceNum === 2 && targetNum === 1) {
|
|
1282
|
-
// Stereo to Mono: 0.5 * (L + R)
|
|
1283
|
-
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
1284
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1285
|
-
return 0.5 * (sourceData[baseIdx] + sourceData[baseIdx + 1]);
|
|
1286
|
-
};
|
|
1287
|
-
}
|
|
1288
|
-
else if (sourceNum === 2 && targetNum === 4) {
|
|
1289
|
-
// Stereo to Quad: L -> L, R -> R, 0 -> SL, 0 -> SR
|
|
1290
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1291
|
-
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1294
|
-
else if (sourceNum === 2 && targetNum === 6) {
|
|
1295
|
-
// Stereo to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, 0 -> SL, 0 -> SR
|
|
1296
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1297
|
-
return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
|
|
1298
|
-
};
|
|
1299
|
-
}
|
|
1300
|
-
else if (sourceNum === 4 && targetNum === 1) {
|
|
1301
|
-
// Quad to Mono: 0.25 * (L + R + SL + SR)
|
|
1302
|
-
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
1303
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1304
|
-
return 0.25 * (sourceData[baseIdx] + sourceData[baseIdx + 1]
|
|
1305
|
-
+ sourceData[baseIdx + 2] + sourceData[baseIdx + 3]);
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
else if (sourceNum === 4 && targetNum === 2) {
|
|
1309
|
-
// Quad to Stereo: 0.5 * (L + SL), 0.5 * (R + SR)
|
|
1310
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1311
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1312
|
-
return 0.5 * (sourceData[baseIdx + targetChannelIndex]
|
|
1313
|
-
+ sourceData[baseIdx + targetChannelIndex + 2]);
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
else if (sourceNum === 4 && targetNum === 6) {
|
|
1317
|
-
// Quad to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, SL -> SL, SR -> SR
|
|
1318
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1319
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1320
|
-
// It's a bit harder to do this one branchlessly
|
|
1321
|
-
if (targetChannelIndex < 2)
|
|
1322
|
-
return sourceData[baseIdx + targetChannelIndex]; // L, R
|
|
1323
|
-
if (targetChannelIndex === 2 || targetChannelIndex === 3)
|
|
1324
|
-
return 0; // C, LFE
|
|
1325
|
-
return sourceData[baseIdx + targetChannelIndex - 2]; // SL, SR
|
|
1326
|
-
};
|
|
1327
|
-
}
|
|
1328
|
-
else if (sourceNum === 6 && targetNum === 1) {
|
|
1329
|
-
// 5.1 to Mono: sqrt(1/2) * (L + R) + C + 0.5 * (SL + SR)
|
|
1330
|
-
this.channelMixer = (sourceData, sourceFrameIndex) => {
|
|
1331
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1332
|
-
return Math.SQRT1_2 * (sourceData[baseIdx] + sourceData[baseIdx + 1])
|
|
1333
|
-
+ sourceData[baseIdx + 2]
|
|
1334
|
-
+ 0.5 * (sourceData[baseIdx + 4] + sourceData[baseIdx + 5]);
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
else if (sourceNum === 6 && targetNum === 2) {
|
|
1338
|
-
// 5.1 to Stereo: L + sqrt(1/2) * (C + SL), R + sqrt(1/2) * (C + SR)
|
|
1339
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1340
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1341
|
-
return sourceData[baseIdx + targetChannelIndex]
|
|
1342
|
-
+ Math.SQRT1_2 * (sourceData[baseIdx + 2] + sourceData[baseIdx + targetChannelIndex + 4]);
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
else if (sourceNum === 6 && targetNum === 4) {
|
|
1346
|
-
// 5.1 to Quad: L + sqrt(1/2) * C, R + sqrt(1/2) * C, SL, SR
|
|
1347
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1348
|
-
const baseIdx = sourceFrameIndex * sourceNum;
|
|
1349
|
-
// It's a bit harder to do this one branchlessly
|
|
1350
|
-
if (targetChannelIndex < 2) {
|
|
1351
|
-
return sourceData[baseIdx + targetChannelIndex] + Math.SQRT1_2 * sourceData[baseIdx + 2];
|
|
1352
|
-
}
|
|
1353
|
-
return sourceData[baseIdx + targetChannelIndex + 2]; // SL, SR
|
|
1354
|
-
};
|
|
1355
|
-
}
|
|
1356
|
-
else {
|
|
1357
|
-
// Discrete fallback: direct mapping with zero-fill or drop
|
|
1358
|
-
this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
|
|
1359
|
-
return targetChannelIndex < sourceNum
|
|
1360
|
-
? sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]
|
|
1361
|
-
: 0;
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
ensureTempBufferSize(requiredSamples) {
|
|
1366
|
-
let length = this.tempSourceBuffer.length;
|
|
1367
|
-
while (length < requiredSamples) {
|
|
1368
|
-
length *= 2;
|
|
1369
|
-
}
|
|
1370
|
-
if (length !== this.tempSourceBuffer.length) {
|
|
1371
|
-
const newBuffer = new Float32Array(length);
|
|
1372
|
-
newBuffer.set(this.tempSourceBuffer);
|
|
1373
|
-
this.tempSourceBuffer = newBuffer;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
async add(audioSample) {
|
|
1377
|
-
if (this.sourceSampleRate === null) {
|
|
1378
|
-
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
|
|
1379
|
-
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
|
|
1380
|
-
// they see fit.
|
|
1381
|
-
this.sourceSampleRate = audioSample.sampleRate;
|
|
1382
|
-
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
|
1383
|
-
// Pre-allocate temporary buffer for source data
|
|
1384
|
-
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
|
1385
|
-
this.doChannelMixerSetup();
|
|
1386
|
-
}
|
|
1387
|
-
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
|
1388
|
-
this.ensureTempBufferSize(requiredSamples);
|
|
1389
|
-
// Copy the audio data to the temp buffer
|
|
1390
|
-
const sourceDataSize = audioSample.allocationSize({ planeIndex: 0, format: 'f32' });
|
|
1391
|
-
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
|
1392
|
-
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
|
1393
|
-
const inputStartTime = audioSample.timestamp - this.startTime;
|
|
1394
|
-
const inputDuration = audioSample.numberOfFrames / this.sourceSampleRate;
|
|
1395
|
-
const inputEndTime = Math.min(inputStartTime + inputDuration, this.endTime - this.startTime);
|
|
1396
|
-
// Compute which output frames are affected by this sample
|
|
1397
|
-
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
|
1398
|
-
const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
|
|
1399
|
-
for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
|
|
1400
|
-
if (outputFrame < this.bufferStartFrame) {
|
|
1401
|
-
continue; // Skip writes to the past
|
|
1402
|
-
}
|
|
1403
|
-
while (outputFrame >= this.bufferStartFrame + this.bufferSizeInFrames) {
|
|
1404
|
-
// The write is after the current buffer, so finalize it
|
|
1405
|
-
await this.finalizeCurrentBuffer();
|
|
1406
|
-
this.bufferStartFrame += this.bufferSizeInFrames;
|
|
1407
|
-
}
|
|
1408
|
-
const bufferFrameIndex = outputFrame - this.bufferStartFrame;
|
|
1409
|
-
assert(bufferFrameIndex < this.bufferSizeInFrames);
|
|
1410
|
-
const outputTime = outputFrame / this.targetSampleRate;
|
|
1411
|
-
const inputTime = outputTime - inputStartTime;
|
|
1412
|
-
const sourcePosition = inputTime * this.sourceSampleRate;
|
|
1413
|
-
const sourceLowerFrame = Math.floor(sourcePosition);
|
|
1414
|
-
const sourceUpperFrame = Math.ceil(sourcePosition);
|
|
1415
|
-
const fraction = sourcePosition - sourceLowerFrame;
|
|
1416
|
-
// Process each output channel
|
|
1417
|
-
for (let targetChannel = 0; targetChannel < this.targetNumberOfChannels; targetChannel++) {
|
|
1418
|
-
let lowerSample = 0;
|
|
1419
|
-
let upperSample = 0;
|
|
1420
|
-
if (sourceLowerFrame >= 0 && sourceLowerFrame < audioSample.numberOfFrames) {
|
|
1421
|
-
lowerSample = this.channelMixer(sourceView, sourceLowerFrame, targetChannel);
|
|
1422
|
-
}
|
|
1423
|
-
if (sourceUpperFrame >= 0 && sourceUpperFrame < audioSample.numberOfFrames) {
|
|
1424
|
-
upperSample = this.channelMixer(sourceView, sourceUpperFrame, targetChannel);
|
|
1425
|
-
}
|
|
1426
|
-
// For resampling, we do naive linear interpolation to find the in-between sample. This produces
|
|
1427
|
-
// suboptimal results especially for downsampling (for which a low-pass filter would first need to be
|
|
1428
|
-
// applied), but AudioContext doesn't do this either, so, whatever, for now.
|
|
1429
|
-
const outputSample = lowerSample + fraction * (upperSample - lowerSample);
|
|
1430
|
-
// Write to output buffer (interleaved)
|
|
1431
|
-
const outputIndex = bufferFrameIndex * this.targetNumberOfChannels + targetChannel;
|
|
1432
|
-
this.outputBuffer[outputIndex] += outputSample; // Add in case of overlapping samples
|
|
1433
|
-
}
|
|
1434
|
-
this.maxWrittenFrame = Math.max(this.maxWrittenFrame, bufferFrameIndex);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
async finalizeCurrentBuffer() {
|
|
1438
|
-
if (this.maxWrittenFrame < 0) {
|
|
1439
|
-
return; // Nothing to finalize
|
|
1440
|
-
}
|
|
1441
|
-
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
|
1442
|
-
const outputData = new Float32Array(samplesWritten);
|
|
1443
|
-
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
|
1444
|
-
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
|
|
1445
|
-
const audioSample = new AudioSample({
|
|
1446
|
-
format: 'f32',
|
|
1447
|
-
sampleRate: this.targetSampleRate,
|
|
1448
|
-
numberOfChannels: this.targetNumberOfChannels,
|
|
1449
|
-
timestamp: timestampSeconds,
|
|
1450
|
-
data: outputData,
|
|
1451
|
-
});
|
|
1452
|
-
await this.onSample(audioSample);
|
|
1453
|
-
this.outputBuffer.fill(0);
|
|
1454
|
-
this.maxWrittenFrame = -1;
|
|
1455
|
-
}
|
|
1456
|
-
finalize() {
|
|
1457
|
-
return this.finalizeCurrentBuffer();
|
|
1458
|
-
}
|
|
1459
|
-
}
|