mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 13:53:53 +02:00
Address a bunch of TODOs and add parameter validation everywhere
This commit is contained in:
+4
-3
@@ -36,9 +36,10 @@
|
||||
canvas.height = 720;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
let format = new Metamuxer.WebMOutputFormat();
|
||||
let format = new Metamuxer.WebMOutputFormat({ streamable: true });
|
||||
format = new Metamuxer.Mp4OutputFormat({ fastStart: false }); // new Metamuxer.MkvOutputFormat();// new Metamuxer.Mp4OutputFormat({ fastStart: false });
|
||||
let target = new Metamuxer.ArrayBufferTarget();
|
||||
//target = new Metamuxer.StreamTarget({ onData: (data, pos) => console.log(data, pos), chunked: true }) ??
|
||||
|
||||
let output = new Metamuxer.Output({ format, target });
|
||||
|
||||
@@ -142,8 +143,8 @@ Testing... <00:17.350>One... <00:18.125>Two...
|
||||
9. <b>justify (bottom, right)</b>.
|
||||
`;
|
||||
|
||||
subtitleSource.digest(simpleWebvttFile);
|
||||
subtitleSource.close();
|
||||
//subtitleSource.digest(simpleWebvttFile);
|
||||
//subtitleSource.close();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
context.fillStyle = ['red', 'green', 'blue', 'yellow'][i % 4];
|
||||
|
||||
Vendored
+592
-338
File diff suppressed because it is too large
Load Diff
Vendored
+5
-5
File diff suppressed because one or more lines are too long
Vendored
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+592
-338
File diff suppressed because it is too large
Load Diff
+124
-2
@@ -1,4 +1,6 @@
|
||||
import { COLOR_PRIMARIES_MAP, isAllowSharedBufferSource, MATRIX_COEFFICIENTS_MAP, TRANSFER_CHARACTERISTICS_MAP } from "./misc";
|
||||
import { AudioCodec, VideoCodec } from "./source";
|
||||
import { SubtitleMetadata } from "./subtitles";
|
||||
|
||||
export const buildVideoCodecString = (codec: VideoCodec, width: number, height: number) => {
|
||||
if (codec === 'avc') {
|
||||
@@ -112,7 +114,7 @@ export const buildVideoCodecString = (codec: VideoCodec, width: number, height:
|
||||
return `av01.${profile}.${level}${tier}.${bitDepth}`;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled codec '${codec}'.`);
|
||||
throw new TypeError(`Unhandled codec '${codec}'.`);
|
||||
};
|
||||
|
||||
export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: number, sampleRate: number) => {
|
||||
@@ -135,5 +137,125 @@ export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: numbe
|
||||
return 'vorbis'; // Also easy, this one
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled codec '${codec}'.`);
|
||||
throw new TypeError(`Unhandled codec '${codec}'.`);
|
||||
};
|
||||
|
||||
export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata | undefined) => {
|
||||
if (!metadata) {
|
||||
throw new TypeError("Video chunk metadata must be provided.");
|
||||
}
|
||||
if (typeof metadata !== 'object') {
|
||||
throw new TypeError("Video chunk metadata must be an object.");
|
||||
}
|
||||
if (!metadata.decoderConfig) {
|
||||
throw new TypeError("Video chunk metadata must include a decoder configuration.");
|
||||
}
|
||||
if (typeof metadata.decoderConfig !== 'object') {
|
||||
throw new TypeError("Video chunk metadata decoder configuration must be an object.");
|
||||
}
|
||||
if (typeof metadata.decoderConfig.codec !== 'string') {
|
||||
throw new TypeError("Video chunk metadata decoder configuration must specify a codec string.");
|
||||
}
|
||||
if (!Number.isInteger(metadata.decoderConfig.codedWidth) || metadata.decoderConfig.codedWidth! <= 0) {
|
||||
throw new TypeError("Video chunk metadata decoder configuration must specify a valid codedWidth (positive integer).");
|
||||
}
|
||||
if (!Number.isInteger(metadata.decoderConfig.codedHeight) || metadata.decoderConfig.codedHeight! <= 0) {
|
||||
throw new TypeError("Video chunk metadata decoder configuration must specify a valid codedHeight (positive integer).");
|
||||
}
|
||||
if (metadata.decoderConfig.description !== undefined) {
|
||||
if (!isAllowSharedBufferSource(metadata.decoderConfig.description)) {
|
||||
throw new TypeError("Video chunk metadata decoder configuration description, when defined, must be an ArrayBuffer or an ArrayBuffer view.");
|
||||
}
|
||||
}
|
||||
if (metadata.decoderConfig.colorSpace !== undefined) {
|
||||
let { colorSpace } = metadata.decoderConfig;
|
||||
|
||||
if (typeof colorSpace !== 'object') {
|
||||
throw new TypeError("Video chunk metadata decoder configuration colorSpace, when provided, must be an object.");
|
||||
}
|
||||
|
||||
let primariesValues = Object.keys(COLOR_PRIMARIES_MAP);
|
||||
if (colorSpace.primaries != null && !primariesValues.includes(colorSpace.primaries)) {
|
||||
throw new TypeError(`Video chunk metadata decoder configuration colorSpace primaries, when defined, must be one of ${primariesValues.join(', ')}.`);
|
||||
}
|
||||
|
||||
let transferValues = Object.keys(TRANSFER_CHARACTERISTICS_MAP);
|
||||
if (colorSpace.transfer != null && !transferValues.includes(colorSpace.transfer)) {
|
||||
throw new TypeError(`Video chunk metadata decoder configuration colorSpace transfer, when defined, must be one of ${transferValues.join(', ')}.`);
|
||||
}
|
||||
|
||||
let matrixValues = Object.keys(MATRIX_COEFFICIENTS_MAP);
|
||||
if (colorSpace.matrix != null && !matrixValues.includes(colorSpace.matrix)) {
|
||||
throw new TypeError(`Video chunk metadata decoder configuration colorSpace matrix, when defined, must be one of ${matrixValues.join(', ')}.`);
|
||||
}
|
||||
|
||||
if (colorSpace.fullRange != null && typeof colorSpace.fullRange !== 'boolean') {
|
||||
throw new TypeError("Video chunk metadata decoder configuration colorSpace fullRange, when defined, must be a boolean.");
|
||||
}
|
||||
}
|
||||
|
||||
if ((metadata.decoderConfig.codec.startsWith('avc1') || metadata.decoderConfig.codec.startsWith('avc3')) && !metadata.decoderConfig.description) {
|
||||
// TODO Technically not correct, they can also lack a description in which case the chunks are annexb. Generally,
|
||||
// go through all the codec registrations and catch these edge cases.
|
||||
throw new TypeError("Video chunk metadata decoder configuration for AVC must include a description, which is expected to be an AVCDecoderConfigurationRecord as specified in ISO 14496-15.");
|
||||
}
|
||||
if ((metadata.decoderConfig.codec.startsWith('hev1') || metadata.decoderConfig.codec.startsWith('hvc1')) && !metadata.decoderConfig.description) {
|
||||
// TODO Same as above
|
||||
throw new TypeError("Video chunk metadata decoder configuration for HEVC must include a description, which is expected to be an HEVCDecoderConfigurationRecord as specified in ISO 14496-15.");
|
||||
}
|
||||
if ((metadata.decoderConfig.codec === 'vp8' || metadata.decoderConfig.codec.startsWith('vp09')) && metadata.decoderConfig.colorSpace === undefined) {
|
||||
throw new TypeError("Video chunk metadata decoder configuration for VP8/VP9 must include a colorSpace.");
|
||||
}
|
||||
// No added requirements for AV1 (based)
|
||||
};
|
||||
|
||||
export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | undefined) => {
|
||||
if (!metadata) {
|
||||
throw new TypeError("Audio chunk metadata must be provided.");
|
||||
}
|
||||
if (typeof metadata !== 'object') {
|
||||
throw new TypeError("Audio chunk metadata must be an object.");
|
||||
}
|
||||
if (!metadata.decoderConfig) {
|
||||
throw new TypeError("Audio chunk metadata must include a decoder configuration.");
|
||||
}
|
||||
if (typeof metadata.decoderConfig !== 'object') {
|
||||
throw new TypeError("Audio chunk metadata decoder configuration must be an object.");
|
||||
}
|
||||
if (typeof metadata.decoderConfig.codec !== 'string') {
|
||||
throw new TypeError("Audio chunk metadata decoder configuration must specify a codec string.");
|
||||
}
|
||||
if (!Number.isInteger(metadata.decoderConfig.sampleRate) || metadata.decoderConfig.sampleRate! <= 0) {
|
||||
throw new TypeError("Audio chunk metadata decoder configuration must specify a valid sampleRate (positive integer).");
|
||||
}
|
||||
if (!Number.isInteger(metadata.decoderConfig.numberOfChannels) || metadata.decoderConfig.numberOfChannels! <= 0) {
|
||||
throw new TypeError("Audio chunk metadata decoder configuration must specify a valid numberOfChannels (positive integer).");
|
||||
}
|
||||
if (metadata.decoderConfig.description !== undefined) {
|
||||
if (!isAllowSharedBufferSource(metadata.decoderConfig.description)) {
|
||||
throw new TypeError("Audio chunk metadata decoder configuration description, when defined, must be an ArrayBuffer or an ArrayBuffer view.");
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.decoderConfig.codec === 'opus' && metadata.decoderConfig.description && metadata.decoderConfig.description.byteLength < 18) {
|
||||
throw new TypeError('Invalid decoder description provided for Opus; must be at least 18 bytes long.');
|
||||
}
|
||||
};
|
||||
|
||||
export const validateSubtitleMetadata = (metadata: SubtitleMetadata | undefined) => {
|
||||
if (!metadata) {
|
||||
throw new TypeError("Subtitle metadata must be provided.");
|
||||
}
|
||||
if (typeof metadata !== 'object') {
|
||||
throw new TypeError("Subtitle metadata must be an object.");
|
||||
}
|
||||
if (!metadata.config) {
|
||||
throw new TypeError("Subtitle metadata must include a config object.");
|
||||
}
|
||||
if (typeof metadata.config !== 'object') {
|
||||
throw new TypeError("Subtitle metadata config must be an object.");
|
||||
}
|
||||
if (typeof metadata.config.description !== 'string') {
|
||||
throw new TypeError("Subtitle metadata config description must be a string.");
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,31 @@ const fixed_2_30 = (value: number) => {
|
||||
return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[];
|
||||
};
|
||||
|
||||
const variableUnsignedInt = (value: number, byteLength?: number) => {
|
||||
let bytes: number[] = [];
|
||||
let remaining = value;
|
||||
|
||||
do {
|
||||
let byte = remaining & 0x7f;
|
||||
remaining >>= 7;
|
||||
|
||||
// If this isn't the first byte we're adding (meaning there will be more bytes after it
|
||||
// when we reverse the array), set the continuation bit
|
||||
if (bytes.length > 0) {
|
||||
byte |= 0x80;
|
||||
}
|
||||
|
||||
bytes.push(byte);
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
byteLength--;
|
||||
}
|
||||
} while (remaining > 0 || byteLength);
|
||||
|
||||
// Reverse the array since we built it backwards
|
||||
return bytes.reverse();
|
||||
};
|
||||
|
||||
const ascii = (text: string, nullTerminated = false) => {
|
||||
let bytes = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i));
|
||||
if (nullTerminated) bytes.push(0x00);
|
||||
@@ -279,7 +304,7 @@ export const mvhd = (
|
||||
return lastSample.timestamp + lastSample.duration;
|
||||
})
|
||||
), GLOBAL_TIMESCALE);
|
||||
let nextTrackId = Math.max(...trackDatas.map(x => x.track.id)) + 1;
|
||||
let nextTrackId = Math.max(0, ...trackDatas.map(x => x.track.id)) + 1;
|
||||
|
||||
// Conditionally use u64 if u32 isn't enough
|
||||
let needsU64 = !isU32(creationTime) || !isU32(duration);
|
||||
@@ -538,9 +563,6 @@ export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [
|
||||
u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7) // Full range flag
|
||||
]);
|
||||
|
||||
// TODO: All muxers should ensure that the decoder config description is provided for the codecs that require it. This
|
||||
// is relevant when the user skips WebCodecs and uses their own encoder.
|
||||
|
||||
/** AVC Configuration Box: Provides additional information to the decoder. */
|
||||
export const avcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoderConfig && box('avcC', [
|
||||
// For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here
|
||||
@@ -549,7 +571,7 @@ export const avcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoder
|
||||
|
||||
/** HEVC Configuration Box: Provides additional information to the decoder. */
|
||||
export const hvcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoderConfig && box('hvcC', [
|
||||
// For HEVC, description is a HEVCDecoderConfigurationRecord, so nothing else to do here
|
||||
// For HEVC, description is an HEVCDecoderConfigurationRecord, so nothing else to do here
|
||||
...toUint8Array(trackData.info.decoderConfig.description!)
|
||||
]);
|
||||
|
||||
@@ -562,9 +584,7 @@ export const vpcC = (trackData: IsobmffVideoTrackData) => {
|
||||
}
|
||||
|
||||
let decoderConfig = trackData.info.decoderConfig;
|
||||
if (!decoderConfig.colorSpace) {
|
||||
throw new Error(`'colorSpace' is required in the decoder config for VP8/VP9.`);
|
||||
}
|
||||
assert(decoderConfig.colorSpace); // This is guaranteed by an earlier validation step
|
||||
|
||||
let parts = decoderConfig.codec.split('.');
|
||||
let profile = Number(parts[1]);
|
||||
@@ -631,28 +651,39 @@ export const soundSampleDescription = (
|
||||
export const esds = (trackData: IsobmffAudioTrackData) => {
|
||||
let description = toUint8Array(trackData.info.decoderConfig.description ?? new ArrayBuffer(0));
|
||||
|
||||
// TODO Compact the 808080 stuff, it's superfluous
|
||||
// Adapted from https://stackoverflow.com/a/54803118
|
||||
// We build up the bytes in a layered way which reflects the nested structure
|
||||
|
||||
return fullBox('esds', 0, 0, [
|
||||
// https://stackoverflow.com/a/54803118
|
||||
u32(0x03808080), // TAG(3) = Object Descriptor ([2])
|
||||
u8(0x20 + description.byteLength), // length of this OD (which includes the next 2 tags)
|
||||
u16(1), // ES_ID = 1
|
||||
u8(0x00), // flags etc = 0
|
||||
u32(0x04808080), // TAG(4) = ES Descriptor ([2]) embedded in above OD
|
||||
u8(0x12 + description.byteLength), // length of this ESD
|
||||
u8(0x40), // MPEG-4 Audio
|
||||
u8(0x15), // stream type(6bits)=5 audio, flags(2bits)=1
|
||||
u24(0), // 24bit buffer size
|
||||
u32(0x0001FC17), // max bitrate
|
||||
u32(0x0001FC17), // avg bitrate
|
||||
u32(0x05808080), // TAG(5) = ASC ([2],[3]) embedded in above OD
|
||||
u8(description.byteLength), // length
|
||||
...description,
|
||||
u32(0x06808080), // TAG(6)
|
||||
u8(0x01), // length
|
||||
u8(0x02) // data
|
||||
]);
|
||||
let bytes = [
|
||||
...description
|
||||
];
|
||||
bytes = [
|
||||
...u8(0x40), // MPEG-4 Audio
|
||||
...u8(0x15), // stream type(6bits)=5 audio, flags(2bits)=1
|
||||
...u24(0), // 24bit buffer size
|
||||
...u32(0), // max bitrate
|
||||
...u32(0), // avg bitrate
|
||||
...u8(0x05), // TAG(5) = ASC ([2],[3]) embedded in above OD
|
||||
...variableUnsignedInt(bytes.length),
|
||||
...bytes
|
||||
];
|
||||
bytes = [
|
||||
...u16(1), // ES_ID = 1
|
||||
...u8(0x00), // flags etc = 0
|
||||
...u8(0x04), // TAG(4) = ES Descriptor ([2]) embedded in above OD
|
||||
...variableUnsignedInt(bytes.length),
|
||||
...bytes,
|
||||
...u8(0x06), // TAG(6)
|
||||
...u8(0x01), // length
|
||||
...u8(0x02) // data
|
||||
];
|
||||
bytes = [
|
||||
...u8(0x03), // TAG(3) = Object Descriptor ([2])
|
||||
...variableUnsignedInt(bytes.length),
|
||||
...bytes
|
||||
];
|
||||
|
||||
return fullBox('esds', 0, 0, bytes);
|
||||
};
|
||||
|
||||
/** Opus Specific Box. */
|
||||
@@ -665,9 +696,7 @@ export const dOps = (trackData: IsobmffAudioTrackData) => {
|
||||
// https://www.rfc-editor.org/rfc/rfc7845#section-5
|
||||
const description = trackData.info.decoderConfig?.description;
|
||||
if (description) {
|
||||
if (description.byteLength < 18) {
|
||||
throw new TypeError('Invalid decoder description provided for Opus; must be at least 18 bytes long.');
|
||||
}
|
||||
assert(description.byteLength < 18); // Is validated in an earlier step
|
||||
|
||||
const view = ArrayBuffer.isView(description)
|
||||
? new DataView(description.buffer, description.byteOffset, description.byteLength)
|
||||
|
||||
+47
-111
@@ -1,11 +1,12 @@
|
||||
import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff_boxes';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
import { Writer } from '../writer';
|
||||
import { ArrayBufferTargetWriter, Writer } from '../writer';
|
||||
import { assert, last, TransformationMatrix } from '../misc';
|
||||
import { Mp4OutputFormat } from '../output_format';
|
||||
import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles';
|
||||
import { ArrayBufferTarget } from '../target';
|
||||
import { validateAudioChunkMetadata, validateSubtitleMetadata, validateVideoChunkMetadata } from '../codec';
|
||||
|
||||
export const GLOBAL_TIMESCALE = 1000;
|
||||
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
|
||||
@@ -88,6 +89,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
#writer: Writer;
|
||||
#boxWriter: IsobmffBoxWriter;
|
||||
#format: Mp4OutputFormat;
|
||||
#fastStart: NonNullable<Mp4OutputFormat['options']['fastStart']>;
|
||||
|
||||
#auxTarget = new ArrayBufferTarget();
|
||||
#auxWriter = this.#auxTarget.createWriter();
|
||||
@@ -109,6 +111,14 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.#writer = output.writer;
|
||||
this.#boxWriter = new IsobmffBoxWriter(this.#writer);
|
||||
this.#format = format;
|
||||
|
||||
// If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as the
|
||||
// memory usage remains identical
|
||||
this.#fastStart = format.options.fastStart ?? (this.#writer instanceof ArrayBufferTargetWriter ? 'in-memory' : false);
|
||||
|
||||
if (this.#fastStart === 'in-memory' || this.#fastStart === 'fragmented') {
|
||||
this.#writer.ensureMonotonicity = true;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -117,21 +127,16 @@ export class IsobmffMuxer extends Muxer {
|
||||
// Write the header
|
||||
this.#boxWriter.writeBox(ftyp({
|
||||
holdsAvc: holdsAvc,
|
||||
fragmented: this.#format.options.fastStart === 'fragmented'
|
||||
fragmented: this.#fastStart === 'fragmented'
|
||||
}));
|
||||
|
||||
this.#ftypSize = this.#writer.getPos();
|
||||
|
||||
if (this.#format.options.fastStart === 'in-memory') {
|
||||
if (this.#fastStart === 'in-memory') {
|
||||
this.#mdat = mdat(false);
|
||||
} else if (this.#format.options.fastStart === 'fragmented') {
|
||||
} else if (this.#fastStart === 'fragmented') {
|
||||
// We write the moov box once we write out the first fragment to make sure we get the decoder configs
|
||||
} else {
|
||||
if (typeof this.#format.options.fastStart === 'object') {
|
||||
let moovSizeUpperBound = this.#computeMoovSizeUpperBound();
|
||||
this.#writer.seek(this.#writer.getPos() + moovSizeUpperBound);
|
||||
}
|
||||
|
||||
this.#mdat = mdat(true); // Reserve large size by default, can refine this when finalizing.
|
||||
this.#boxWriter.writeBox(this.#mdat);
|
||||
}
|
||||
@@ -139,45 +144,14 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.#writer.flush();
|
||||
}
|
||||
|
||||
#computeMoovSizeUpperBound() {
|
||||
assert(typeof this.#format.options.fastStart === 'object');
|
||||
|
||||
let upperBound = 0;
|
||||
let sampleCounts = [
|
||||
this.#format.options.fastStart.expectedVideoChunks,
|
||||
this.#format.options.fastStart.expectedAudioChunks
|
||||
];
|
||||
|
||||
for (let n of sampleCounts) {
|
||||
if (!n) continue;
|
||||
|
||||
// Given the max allowed sample count, compute the space they'll take up in the Sample Table Box, assuming
|
||||
// the worst case for each individual box:
|
||||
|
||||
// stts box - since it is compactly coded, the maximum length of this table will be 2/3n
|
||||
upperBound += (4 + 4) * Math.ceil(2/3 * n);
|
||||
// stss box - 1 entry per sample
|
||||
upperBound += 4 * n;
|
||||
// stsc box - since it is compactly coded, the maximum length of this table will be 2/3n
|
||||
upperBound += (4 + 4 + 4) * Math.ceil(2/3 * n);
|
||||
// stsz box - 1 entry per sample
|
||||
upperBound += 4 * n;
|
||||
// co64 box - we assume 1 sample per chunk and 64-bit chunk offsets
|
||||
upperBound += 8 * n;
|
||||
}
|
||||
|
||||
upperBound += 4096; // Assume a generous 4 kB for everything else: Track metadata, codec descriptors, etc.
|
||||
|
||||
return upperBound;
|
||||
}
|
||||
|
||||
#getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) {
|
||||
const existingTrackData = this.#trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as IsobmffVideoTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateVideoChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
assert(meta.decoderConfig.codedWidth !== undefined);
|
||||
@@ -216,7 +190,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
return existingTrackData as IsobmffAudioTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateAudioChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
|
||||
@@ -253,7 +228,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
return existingTrackData as IsobmffSubtitleTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateSubtitleMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.config);
|
||||
|
||||
@@ -293,62 +269,30 @@ export class IsobmffMuxer extends Muxer {
|
||||
addEncodedVideoChunk(track: OutputVideoTrack, chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {
|
||||
const trackData = this.#getVideoTrackData(track, meta);
|
||||
|
||||
if (
|
||||
typeof this.#format.options.fastStart === 'object' &&
|
||||
trackData.samples.length === this.#format.options.fastStart.expectedVideoChunks
|
||||
) {
|
||||
// TODO reference track id
|
||||
throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${
|
||||
this.#format.options.fastStart.expectedVideoChunks
|
||||
}).`);
|
||||
}
|
||||
|
||||
let data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
|
||||
let timestamp = this.validateAndNormalizeTimestamp(trackData.track, chunk.timestamp, chunk.type === 'key');
|
||||
let sample = this.#createSampleForTrack(trackData, data, timestamp, (chunk.duration ?? 0) / 1e6, chunk.type);
|
||||
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
trackData.sampleQueue.push(sample);
|
||||
this.#interleaveSamples();
|
||||
} else {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
}
|
||||
this.#registerSample(trackData, sample);
|
||||
}
|
||||
|
||||
addEncodedAudioChunk(track: OutputAudioTrack, chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
||||
const trackData = this.#getAudioTrackData(track, meta);
|
||||
|
||||
if (
|
||||
typeof this.#format.options.fastStart === 'object' &&
|
||||
trackData.samples.length === this.#format.options.fastStart.expectedAudioChunks
|
||||
) {
|
||||
// TODO reference track id
|
||||
throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${
|
||||
this.#format.options.fastStart.expectedAudioChunks
|
||||
}).`);
|
||||
}
|
||||
|
||||
let data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
|
||||
let timestamp = this.validateAndNormalizeTimestamp(trackData.track, chunk.timestamp, chunk.type === 'key');
|
||||
let sample = this.#createSampleForTrack(trackData, data, timestamp, (chunk.duration ?? 0) / 1e6, chunk.type);
|
||||
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
trackData.sampleQueue.push(sample);
|
||||
this.#interleaveSamples();
|
||||
} else {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
}
|
||||
this.#registerSample(trackData, sample);
|
||||
}
|
||||
|
||||
addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata) {
|
||||
const trackData = this.#getSubtitleTrackData(track, meta);
|
||||
|
||||
// TODO the expectedSubtitleChunks thing
|
||||
|
||||
this.validateAndNormalizeTimestamp(trackData.track, 1e6 * cue.timestamp, true);
|
||||
|
||||
if (track.source.codec === 'webvtt') {
|
||||
@@ -392,14 +336,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
let body = this.#auxWriter.getSlice(0, this.#auxWriter.getPos());
|
||||
let sample = this.#createSampleForTrack(trackData, body, trackData.lastCueEndTimestamp, sampleStart - trackData.lastCueEndTimestamp, 'key');
|
||||
|
||||
// todo extract this into reusable thing
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
trackData.sampleQueue.push(sample);
|
||||
this.#interleaveSamples();
|
||||
} else {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
}
|
||||
|
||||
this.#registerSample(trackData, sample);
|
||||
trackData.lastCueEndTimestamp = sampleStart;
|
||||
}
|
||||
|
||||
@@ -442,14 +379,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
let body = this.#auxWriter.getSlice(0, this.#auxWriter.getPos());
|
||||
let sample = this.#createSampleForTrack(trackData, body, sampleStart, sampleEnd - sampleStart, 'key');
|
||||
|
||||
// todo extract this into reusable thing
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
trackData.sampleQueue.push(sample);
|
||||
this.#interleaveSamples();
|
||||
} else {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
}
|
||||
|
||||
this.#registerSample(trackData, sample);
|
||||
trackData.lastCueEndTimestamp = sampleEnd;
|
||||
}
|
||||
}
|
||||
@@ -503,7 +433,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
trackData.lastTimescaleUnits += delta;
|
||||
trackData.lastSample.timescaleUnitsToNextSample = delta;
|
||||
|
||||
if (this.#format.options.fastStart !== 'fragmented') {
|
||||
if (this.#fastStart !== 'fragmented') {
|
||||
let lastTableEntry = last(trackData.timeToSampleTable);
|
||||
assert(lastTableEntry);
|
||||
|
||||
@@ -555,7 +485,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
} else {
|
||||
trackData.lastTimescaleUnits = 0;
|
||||
|
||||
if (this.#format.options.fastStart !== 'fragmented') {
|
||||
if (this.#fastStart !== 'fragmented') {
|
||||
trackData.timeToSampleTable.push({
|
||||
sampleCount: 1,
|
||||
sampleDelta: durationInTimescale
|
||||
@@ -573,15 +503,21 @@ export class IsobmffMuxer extends Muxer {
|
||||
trackData.timestampProcessingQueue.length = 0;
|
||||
}
|
||||
|
||||
#addSampleToTrack(
|
||||
trackData: IsobmffTrackData,
|
||||
sample: Sample
|
||||
) {
|
||||
#registerSample(trackData: IsobmffTrackData, sample: Sample) {
|
||||
if (this.#fastStart === 'fragmented') {
|
||||
trackData.sampleQueue.push(sample);
|
||||
this.#interleaveSamples();
|
||||
} else {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
}
|
||||
}
|
||||
|
||||
#addSampleToTrack(trackData: IsobmffTrackData, sample: Sample) {
|
||||
if (sample.type === 'key') {
|
||||
this.#processTimestamps(trackData);
|
||||
}
|
||||
|
||||
if (this.#format.options.fastStart !== 'fragmented') {
|
||||
if (this.#fastStart !== 'fragmented') {
|
||||
trackData.samples.push(sample);
|
||||
}
|
||||
|
||||
@@ -591,7 +527,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
} else {
|
||||
let currentChunkDuration = sample.timestamp - trackData.currentChunk.startTimestamp;
|
||||
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
if (this.#fastStart === 'fragmented') {
|
||||
// We can only finalize this fragment (and begin a new one) if we know that each track will be able to
|
||||
// start the new one with a key frame.
|
||||
const keyFrameQueuedEverywhere = this.#trackDatas.every(otherTrackData => {
|
||||
@@ -631,7 +567,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
#finalizeCurrentChunk(trackData: IsobmffTrackData) {
|
||||
assert(this.#format.options.fastStart !== 'fragmented');
|
||||
assert(this.#fastStart !== 'fragmented');
|
||||
|
||||
if (!trackData.currentChunk) return;
|
||||
|
||||
@@ -648,7 +584,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.#format.options.fastStart === 'in-memory') {
|
||||
if (this.#fastStart === 'in-memory') {
|
||||
trackData.currentChunk.offset = 0; // We'll compute the proper offset when finalizing
|
||||
return;
|
||||
}
|
||||
@@ -665,7 +601,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
#interleaveSamples() {
|
||||
assert(this.#format.options.fastStart === 'fragmented');
|
||||
assert(this.#fastStart === 'fragmented');
|
||||
|
||||
for (const track of this.output.tracks) {
|
||||
if (!track.source.closed && !this.#trackDatas.some(x => x.track === track)) {
|
||||
@@ -699,7 +635,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
#finalizeFragment(flushWriter = true) {
|
||||
assert(this.#format.options.fastStart === 'fragmented');
|
||||
assert(this.#fastStart === 'fragmented');
|
||||
|
||||
let fragmentNumber = this.#nextFragmentNumber++;
|
||||
|
||||
@@ -775,7 +711,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
if (this.#fastStart === 'fragmented') {
|
||||
// Since a track is now closed, we may be able to write out chunks that were previously waiting
|
||||
this.#interleaveSamples();
|
||||
}
|
||||
@@ -789,7 +725,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#format.options.fastStart === 'fragmented') {
|
||||
if (this.#fastStart === 'fragmented') {
|
||||
for (let trackData of this.#trackDatas) {
|
||||
for (let sample of trackData.sampleQueue) {
|
||||
this.#addSampleToTrack(trackData, sample);
|
||||
@@ -806,7 +742,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#format.options.fastStart === 'in-memory') {
|
||||
if (this.#fastStart === 'in-memory') {
|
||||
assert(this.#mdat);
|
||||
let mdatSize: number;
|
||||
|
||||
@@ -850,7 +786,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
sample.data = null;
|
||||
}
|
||||
}
|
||||
} else if (this.#format.options.fastStart === 'fragmented') {
|
||||
} else if (this.#fastStart === 'fragmented') {
|
||||
// Append the mfra box to the end of the file for better random access
|
||||
let startPos = this.#writer.getPos();
|
||||
let mfraBox = mfra(this.#trackDatas);
|
||||
@@ -873,7 +809,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
let movieBox = moov(this.#trackDatas, this.#creationTime);
|
||||
|
||||
if (typeof this.#format.options.fastStart === 'object') {
|
||||
if (typeof this.#fastStart === 'object') {
|
||||
this.#writer.seek(this.#ftypSize);
|
||||
this.#boxWriter.writeBox(movieBox);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateAudioChunkMetadata, validateSubtitleMetadata, validateVideoChunkMetadata } from '../codec';
|
||||
import { assert, COLOR_PRIMARIES_MAP, colorSpaceIsComplete, MATRIX_COEFFICIENTS_MAP, readBits, textEncoder, toUint8Array, TRANSFER_CHARACTERISTICS_MAP, writeBits } from '../misc';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
@@ -84,13 +85,8 @@ const TRACK_TYPE_MAP: Record<OutputTrack['type'], number> = {
|
||||
subtitle: 17
|
||||
};
|
||||
|
||||
// TODO: Perhaps we can make this muxer always be streamable. We can do it similar to the MP4 muxer, where for each
|
||||
// cluster, we hold onto all of the chunks (called sample there), until it's done, and then we write it out in one go.
|
||||
// This way, we can set proper headers. Will just mean a bit more memory usage.
|
||||
// Update: Not really. There are duration fields and seek fields that are just uneditable if streaming is required.
|
||||
|
||||
export class MatroskaMuxer extends Muxer {
|
||||
override timestampsMustStartAtZero = true;
|
||||
override timestampsMustStartAtZero = false;
|
||||
|
||||
#writer: Writer;
|
||||
#format: WebMOutputFormat | MkvOutputFormat;
|
||||
@@ -126,6 +122,10 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
this.#writer = output.writer;
|
||||
this.#format = format;
|
||||
|
||||
if (this.#format.options.streamable) {
|
||||
this.#writer.ensureMonotonicity = true;
|
||||
}
|
||||
}
|
||||
|
||||
#writeByte(value: number) {
|
||||
@@ -317,7 +317,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
start() {
|
||||
this.#writeEBMLHeader();
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
if (!this.#format.options.streamable) {
|
||||
this.#createSeekHead();
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
{ id: EBMLId.TimestampScale, data: 1e6 },
|
||||
{ id: EBMLId.MuxingApp, data: APP_NAME },
|
||||
{ id: EBMLId.WritingApp, data: APP_NAME },
|
||||
!this.#format.options.streaming ? segmentDuration : null
|
||||
!this.#format.options.streamable ? segmentDuration : null
|
||||
] };
|
||||
this.#segmentInfo = segmentInfo;
|
||||
}
|
||||
@@ -387,7 +387,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
tracksElement.data.push({ id: EBMLId.TrackEntry, data: [
|
||||
{ id: EBMLId.TrackNumber, data: trackData.track.id },
|
||||
{ id: EBMLId.TrackUID, data: trackData.track.id },
|
||||
{ id: EBMLId.TrackType, data: TRACK_TYPE_MAP[trackData.type] }, // TODO Subtitle case
|
||||
{ id: EBMLId.TrackType, data: TRACK_TYPE_MAP[trackData.type] },
|
||||
{ id: EBMLId.CodecID, data: CODEC_STRING_MAP[trackData.track.source.codec] },
|
||||
...(trackData.type === 'video' ? [
|
||||
(trackData.info.decoderConfig.description ? { id: EBMLId.CodecPrivate, data: toUint8Array(trackData.info.decoderConfig.description) } : null),
|
||||
@@ -432,9 +432,9 @@ export class MatroskaMuxer extends Muxer {
|
||||
#createSegment() {
|
||||
let segment: EBML = {
|
||||
id: EBMLId.Segment,
|
||||
size: this.#format.options.streaming ? -1 : SEGMENT_SIZE_BYTES,
|
||||
size: this.#format.options.streamable ? -1 : SEGMENT_SIZE_BYTES,
|
||||
data: [
|
||||
!this.#format.options.streaming ? this.#seekHead as EBML : null,
|
||||
!this.#format.options.streamable ? this.#seekHead as EBML : null,
|
||||
this.#segmentInfo,
|
||||
this.#tracksElement
|
||||
]
|
||||
@@ -466,7 +466,8 @@ export class MatroskaMuxer extends Muxer {
|
||||
return existingTrackData as MatroskaVideoTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateVideoChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
assert(meta.decoderConfig.codedWidth !== undefined);
|
||||
@@ -496,7 +497,8 @@ export class MatroskaMuxer extends Muxer {
|
||||
return existingTrackData as MatroskaAudioTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateAudioChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
|
||||
@@ -524,7 +526,8 @@ export class MatroskaMuxer extends Muxer {
|
||||
return existingTrackData as MatroskaAudioTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
validateSubtitleMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.config);
|
||||
|
||||
@@ -688,8 +691,6 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
/** Writes a block containing media data to the file. */
|
||||
#writeBlock(trackData: MatroskaTrackData, chunk: InternalMediaChunk) {
|
||||
// TODO Update this comment. This code always runs now
|
||||
// When streaming, we create the tracks and segment after we've received the first media chunks.
|
||||
// Due to the interlacing algorithm, this code will be run once we've seen one chunk from every media track.
|
||||
if (!this.#segment) {
|
||||
this.#createTracks();
|
||||
@@ -777,7 +778,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
/** Creates a new Cluster element to contain media chunks. */
|
||||
#createNewCluster(msTimestamp: number) {
|
||||
if (this.#currentCluster && !this.#format.options.streaming) {
|
||||
if (this.#currentCluster && !this.#format.options.streamable) {
|
||||
this.#finalizeCurrentCluster();
|
||||
}
|
||||
|
||||
@@ -789,7 +790,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
this.#currentCluster = {
|
||||
id: EBMLId.Cluster,
|
||||
size: this.#format.options.streaming ? -1 : CLUSTER_SIZE_BYTES,
|
||||
size: this.#format.options.streamable ? -1 : CLUSTER_SIZE_BYTES,
|
||||
data: [
|
||||
{ id: EBMLId.Timestamp, data: msTimestamp }
|
||||
]
|
||||
@@ -842,6 +843,11 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
/** Finalizes the file, making it ready for use. Must be called after all media chunks have been added. */
|
||||
finalize() {
|
||||
if (!this.#segment) {
|
||||
this.#createTracks();
|
||||
this.#createSegment();
|
||||
}
|
||||
|
||||
// Flush any remaining queued chunks to the file
|
||||
for (let trackData of this.#trackDatas) {
|
||||
while (trackData.chunkQueue.length > 0) {
|
||||
@@ -849,14 +855,14 @@ export class MatroskaMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
if (!this.#format.options.streamable && this.#currentCluster) {
|
||||
this.#finalizeCurrentCluster();
|
||||
}
|
||||
|
||||
assert(this.#cues);
|
||||
this.writeEBML(this.#cues);
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
if (!this.#format.options.streamable) {
|
||||
let endPos = this.#writer.getPos();
|
||||
|
||||
// Write the Segment size
|
||||
|
||||
@@ -74,3 +74,8 @@ export const MATRIX_COEFFICIENTS_MAP: Record<VideoMatrixCoefficients, number> =
|
||||
export const colorSpaceIsComplete = (colorSpace: VideoColorSpaceInit | undefined) => {
|
||||
return !!colorSpace && !!colorSpace.primaries && !!colorSpace.transfer && !!colorSpace.matrix && colorSpace.fullRange !== undefined;
|
||||
};
|
||||
|
||||
export const isAllowSharedBufferSource = (x: unknown) => {
|
||||
// Quite a mouthful:
|
||||
return x instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && x instanceof SharedArrayBuffer) || (ArrayBuffer.isView(x) && !(x instanceof DataView));
|
||||
};
|
||||
+48
-5
@@ -32,7 +32,7 @@ export type OutputAudioTrack = OutputTrack & { type: 'audio' };
|
||||
export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' };
|
||||
|
||||
type VideoTrackMetadata = {
|
||||
rotation?: 0 | 90 | 180 | 270 | TransformationMatrix,
|
||||
rotation?: 0 | 90 | 180 | 270 | TransformationMatrix, // TODO respect this field for Matroska
|
||||
frameRate?: number
|
||||
};
|
||||
type AudioTrackMetadata = {};
|
||||
@@ -46,6 +46,16 @@ export class Output {
|
||||
finalizing = false;
|
||||
|
||||
constructor(options: OutputOptions) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (!(options.format instanceof OutputFormat)) {
|
||||
throw new TypeError('options.format must be an OutputFormat.');
|
||||
}
|
||||
if (!(options.target instanceof Target)) {
|
||||
throw new TypeError('options.target must be a Target.');
|
||||
}
|
||||
|
||||
if (options.target.output) {
|
||||
throw new Error('Target is already used for another output.');
|
||||
}
|
||||
@@ -56,14 +66,51 @@ export class Output {
|
||||
}
|
||||
|
||||
addVideoTrack(source: VideoSource, metadata: VideoTrackMetadata = {}) {
|
||||
if (!(source instanceof VideoSource)) {
|
||||
throw new TypeError('source must be a VideoSource.');
|
||||
}
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
throw new TypeError('metadata must be an object.');
|
||||
}
|
||||
if (typeof metadata.rotation === 'number' && ![0, 90, 180, 270].includes(metadata.rotation)) {
|
||||
throw new TypeError(`Invalid video rotation: ${metadata.rotation}. Has to be 0, 90, 180 or 270.`);
|
||||
} else if (
|
||||
Array.isArray(metadata.rotation) &&
|
||||
(metadata.rotation.length !== 9 || metadata.rotation.some(value => !Number.isFinite(value)))
|
||||
) {
|
||||
throw new TypeError(`Invalid video transformation matrix: ${metadata.rotation.join()}`);
|
||||
}
|
||||
if (
|
||||
metadata.frameRate !== undefined &&
|
||||
(!Number.isInteger(metadata.frameRate) || metadata.frameRate <= 0)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid video frame rate: ${metadata.frameRate}. Must be a positive integer.`
|
||||
);
|
||||
}
|
||||
|
||||
this.addTrack('video', source, metadata);
|
||||
}
|
||||
|
||||
addAudioTrack(source: AudioSource, metadata: AudioTrackMetadata = {}) {
|
||||
if (!(source instanceof AudioSource)) {
|
||||
throw new TypeError('source must be an AudioSource.');
|
||||
}
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
throw new TypeError('metadata must be an object.');
|
||||
}
|
||||
|
||||
this.addTrack('audio', source, metadata);
|
||||
}
|
||||
|
||||
addSubtitleTrack(source: SubtitleSource, metadata: SubtitleTrackMetadata = {}) {
|
||||
if (!(source instanceof SubtitleSource)) {
|
||||
throw new TypeError('source must be a SubtitleSource.');
|
||||
}
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
throw new TypeError('metadata must be an object.');
|
||||
}
|
||||
|
||||
this.addTrack('subtitle', source, metadata);
|
||||
}
|
||||
|
||||
@@ -90,8 +137,6 @@ export class Output {
|
||||
}
|
||||
|
||||
start() {
|
||||
// TODO: Warn / throw if there are no sources
|
||||
|
||||
if (this.started) {
|
||||
throw new Error('Output already started.');
|
||||
}
|
||||
@@ -105,8 +150,6 @@ export class Output {
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
// TODO: Test what happens when finalizing without a single chunk of media
|
||||
|
||||
if (this.finalizing) {
|
||||
throw new Error('Cannot call finalize twice.');
|
||||
}
|
||||
|
||||
+17
-8
@@ -9,13 +9,15 @@ export abstract class OutputFormat {
|
||||
|
||||
export class Mp4OutputFormat extends OutputFormat {
|
||||
constructor(public options: {
|
||||
// TODO: Make this optional with a smart default
|
||||
fastStart: false | 'in-memory' | 'fragmented' | {
|
||||
// TODO: This is too simple now that multiple tracks can exist.
|
||||
expectedVideoChunks?: number,
|
||||
expectedAudioChunks?: number
|
||||
},
|
||||
}) {
|
||||
fastStart?: false | 'in-memory' | 'fragmented',
|
||||
} = {}) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.fastStart !== undefined && ![false, 'in-memory', 'fragmented'].includes(options.fastStart)) {
|
||||
throw new TypeError('options.fastStart, when provided, must be false, "in-memory", or "fragmented".');
|
||||
}
|
||||
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,8 +28,15 @@ export class Mp4OutputFormat extends OutputFormat {
|
||||
|
||||
export class MkvOutputFormat extends OutputFormat {
|
||||
constructor(public options: {
|
||||
streaming?: boolean // TODO: Is there a better name?
|
||||
streamable?: boolean
|
||||
} = {}) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.streamable !== undefined && typeof options.streamable !== 'boolean') {
|
||||
throw new TypeError('options.streamable, when provided, must be a boolean.');
|
||||
}
|
||||
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
+125
-12
@@ -3,15 +3,20 @@ import { assert } from "./misc";
|
||||
import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from "./output";
|
||||
import { SubtitleParser } from "./subtitles";
|
||||
|
||||
export type VideoCodec = 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1';
|
||||
export type AudioCodec = 'aac' | 'opus'; // TODO add the rest
|
||||
export type SubtitleCodec = 'webvtt';
|
||||
const VIDEO_CODECS = ['avc', 'hevc', 'vp8', 'vp9', 'av1'] as const;
|
||||
const AUDIO_CODECS = ['aac', 'opus'] as const; // TODO add the rest
|
||||
const SUBTITLE_CODECS = ['webvtt'] as const; // TODO add the rest
|
||||
|
||||
export type VideoCodec = typeof VIDEO_CODECS[number];
|
||||
export type AudioCodec = typeof AUDIO_CODECS[number];
|
||||
export type SubtitleCodec = typeof SUBTITLE_CODECS[number];
|
||||
|
||||
export abstract class MediaSource {
|
||||
connectedTrack: OutputTrack | null = null;
|
||||
closed = false;
|
||||
offsetTimestamps = false;
|
||||
|
||||
// TODO this is also just internal:
|
||||
ensureValidDigest() {
|
||||
if (!this.connectedTrack) {
|
||||
throw new Error('Cannot call digest without connecting the source to an output track.');
|
||||
@@ -64,6 +69,10 @@ export abstract class VideoSource extends MediaSource {
|
||||
constructor(codec: VideoCodec) {
|
||||
super();
|
||||
|
||||
if (!VIDEO_CODECS.includes(codec)) {
|
||||
throw new TypeError(`Invalid video codec '${codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`);
|
||||
}
|
||||
|
||||
this.codec = codec;
|
||||
}
|
||||
}
|
||||
@@ -73,9 +82,12 @@ export class EncodedVideoChunkSource extends VideoSource {
|
||||
super(codec);
|
||||
}
|
||||
|
||||
// TODO: Ensure that the first chunk is a key frame (same for the audio case)
|
||||
|
||||
digest(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {
|
||||
if (!(chunk instanceof EncodedVideoChunk)) {
|
||||
// TODO add polyfill for browsers that don't have this
|
||||
throw new TypeError('chunk must be an EncodedVideoChunk.');
|
||||
}
|
||||
|
||||
this.ensureValidDigest();
|
||||
this.connectedTrack?.output.muxer.addEncodedVideoChunk(this.connectedTrack, chunk, meta);
|
||||
}
|
||||
@@ -89,16 +101,44 @@ type VideoCodecConfig = {
|
||||
latencyMode?: VideoEncoderConfig['latencyMode']
|
||||
};
|
||||
|
||||
const validateVideoCodecConfig = (config: VideoCodecConfig) => {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new TypeError('Codec config must be an object.');
|
||||
}
|
||||
if (!VIDEO_CODECS.includes(config.codec)) {
|
||||
throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`);
|
||||
}
|
||||
if (!Number.isInteger(config.bitrate) || config.bitrate <= 0) {
|
||||
throw new TypeError('config.bitrate must be a positive integer.');
|
||||
}
|
||||
if (config.latencyMode !== undefined && ['quality', 'realtime'].includes(config.latencyMode)) {
|
||||
throw new TypeError("config.latencyMode, when provided, must be 'quality' or 'realtime'.");
|
||||
}
|
||||
};
|
||||
|
||||
class VideoEncoderWrapper {
|
||||
private encoder: VideoEncoder | null = null;
|
||||
private lastMultipleOfKeyFrameInterval = -1;
|
||||
private lastWidth: number | null = null;
|
||||
private lastHeight: number | null = null;
|
||||
|
||||
constructor(private source: VideoSource, private codecConfig: VideoCodecConfig) {}
|
||||
constructor(private source: VideoSource, private codecConfig: VideoCodecConfig) {
|
||||
validateVideoCodecConfig(codecConfig);
|
||||
}
|
||||
|
||||
// TODO: Ensure video frame size remains constant
|
||||
digest(videoFrame: VideoFrame) {
|
||||
this.source.ensureValidDigest();
|
||||
|
||||
// Ensure video frame size remains constant
|
||||
if (this.lastWidth !== null && this.lastHeight !== null) {
|
||||
if (videoFrame.codedWidth !== this.lastWidth || videoFrame.codedHeight !== this.lastHeight) {
|
||||
throw new Error(`Video frame size must remain constant. Expected ${this.lastWidth}x${this.lastHeight}, got ${videoFrame.codedWidth}x${videoFrame.codedHeight}.`);
|
||||
}
|
||||
} else {
|
||||
this.lastWidth = videoFrame.codedWidth;
|
||||
this.lastHeight = videoFrame.codedHeight;
|
||||
}
|
||||
|
||||
this.ensureEncoder(videoFrame);
|
||||
assert(this.encoder);
|
||||
|
||||
@@ -119,7 +159,7 @@ class VideoEncoderWrapper {
|
||||
|
||||
this.encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack, chunk, meta),
|
||||
error: (error) => console.error(error), // TODO
|
||||
error: (error) => console.error('Video encode error:', error),
|
||||
});
|
||||
|
||||
this.encoder.configure({
|
||||
@@ -146,6 +186,10 @@ export class VideoFrameSource extends VideoSource {
|
||||
}
|
||||
|
||||
digest(videoFrame: VideoFrame) {
|
||||
if (!(videoFrame instanceof VideoFrame)) {
|
||||
throw new TypeError('videoFrame must be a VideoFrame.');
|
||||
}
|
||||
|
||||
this.encoder.digest(videoFrame);
|
||||
}
|
||||
|
||||
@@ -158,11 +202,22 @@ export class CanvasSource extends VideoSource {
|
||||
private encoder: VideoEncoderWrapper;
|
||||
|
||||
constructor(private canvas: HTMLCanvasElement, codecConfig: VideoCodecConfig) {
|
||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||
throw new TypeError('canvas must be an HTMLCanvasElement.');
|
||||
}
|
||||
|
||||
super(codecConfig.codec);
|
||||
this.encoder = new VideoEncoderWrapper(this, codecConfig);
|
||||
}
|
||||
|
||||
digest(timestamp: number, duration = 0) {
|
||||
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
||||
throw new TypeError('timestamp must be a non-negative number.');
|
||||
}
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
throw new TypeError('duration must be a non-negative number.');
|
||||
}
|
||||
|
||||
const frame = new VideoFrame(this.canvas, {
|
||||
timestamp: Math.round(1e6 * timestamp),
|
||||
duration: Math.round(1e6 * duration),
|
||||
@@ -185,6 +240,10 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
override offsetTimestamps = true;
|
||||
|
||||
constructor(private track: MediaStreamVideoTrack, codecConfig: VideoCodecConfig) {
|
||||
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
|
||||
throw new TypeError('track must be a video MediaStreamTrack.');
|
||||
}
|
||||
|
||||
super(codecConfig.codec);
|
||||
this.encoder = new VideoEncoderWrapper(this, codecConfig);
|
||||
}
|
||||
@@ -227,6 +286,10 @@ export abstract class AudioSource extends MediaSource {
|
||||
constructor(codec: AudioCodec) {
|
||||
super();
|
||||
|
||||
if (!AUDIO_CODECS.includes(codec)) {
|
||||
throw new TypeError(`Invalid audio codec '${codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
|
||||
}
|
||||
|
||||
this.codec = codec;
|
||||
}
|
||||
}
|
||||
@@ -237,6 +300,11 @@ export class EncodedAudioChunkSource extends AudioSource {
|
||||
}
|
||||
|
||||
digest(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
||||
if (!(chunk instanceof EncodedAudioChunk)) {
|
||||
// TODO add polyfill for browsers that don't have this
|
||||
throw new TypeError('chunk must be an EncodedAudioChunk.');
|
||||
}
|
||||
|
||||
this.ensureValidDigest();
|
||||
this.connectedTrack?.output.muxer.addEncodedAudioChunk(this.connectedTrack, chunk, meta);
|
||||
}
|
||||
@@ -247,15 +315,40 @@ type AudioCodecConfig = {
|
||||
bitrate: number
|
||||
};
|
||||
|
||||
const validateAudioCodecConfig = (config: AudioCodecConfig) => {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new TypeError('Codec config must be an object.');
|
||||
}
|
||||
if (!AUDIO_CODECS.includes(config.codec)) {
|
||||
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
|
||||
}
|
||||
if (!Number.isInteger(config.bitrate) || config.bitrate <= 0) {
|
||||
throw new TypeError('config.bitrate must be a positive integer.');
|
||||
}
|
||||
};
|
||||
|
||||
class AudioEncoderWrapper {
|
||||
private encoder: AudioEncoder | null = null;
|
||||
private lastNumberOfChannels: number | null = null;
|
||||
private lastSampleRate: number | null = null;
|
||||
|
||||
constructor(private source: AudioSource, private codecConfig: AudioCodecConfig) {}
|
||||
constructor(private source: AudioSource, private codecConfig: AudioCodecConfig) {
|
||||
validateAudioCodecConfig(codecConfig);
|
||||
}
|
||||
|
||||
// TODO: Ensure audio parameters remain constant
|
||||
digest(audioData: AudioData) {
|
||||
this.source.ensureValidDigest();
|
||||
|
||||
// Ensure audio parameters remain constant
|
||||
if (this.lastNumberOfChannels !== null && this.lastSampleRate !== null) {
|
||||
if (audioData.numberOfChannels !== this.lastNumberOfChannels || audioData.sampleRate !== this.lastSampleRate) {
|
||||
throw new Error(`Audio parameters must remain constant. Expected ${this.lastNumberOfChannels} channels at ${this.lastSampleRate} Hz, got ${audioData.numberOfChannels} channels at ${audioData.sampleRate} Hz.`);
|
||||
}
|
||||
} else {
|
||||
this.lastNumberOfChannels = audioData.numberOfChannels;
|
||||
this.lastSampleRate = audioData.sampleRate;
|
||||
}
|
||||
|
||||
this.ensureEncoder(audioData);
|
||||
assert(this.encoder);
|
||||
|
||||
@@ -269,7 +362,7 @@ class AudioEncoderWrapper {
|
||||
|
||||
this.encoder = new AudioEncoder({
|
||||
output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack, chunk, meta),
|
||||
error: (error) => console.error(error), // TODO
|
||||
error: (error) => console.error('Audio encode error:', error),
|
||||
});
|
||||
|
||||
this.encoder.configure({
|
||||
@@ -294,6 +387,10 @@ export class AudioDataSource extends AudioSource {
|
||||
}
|
||||
|
||||
digest(audioData: AudioData) {
|
||||
if (!(audioData instanceof AudioData)) {
|
||||
throw new TypeError('audioData must be an AudioData.');
|
||||
}
|
||||
|
||||
this.encoder.digest(audioData);
|
||||
}
|
||||
|
||||
@@ -312,6 +409,10 @@ export class AudioBufferSource extends AudioSource {
|
||||
}
|
||||
|
||||
digest(audioBuffer: AudioBuffer) {
|
||||
if (!(audioBuffer instanceof AudioBuffer)) {
|
||||
throw new TypeError('audioBuffer must be an AudioBuffer.');
|
||||
}
|
||||
|
||||
const numberOfChannels = audioBuffer.numberOfChannels;
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
const numberOfFrames = audioBuffer.length;
|
||||
@@ -350,6 +451,10 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
override offsetTimestamps = true;
|
||||
|
||||
constructor(private track: MediaStreamAudioTrack, codecConfig: AudioCodecConfig) {
|
||||
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
|
||||
throw new TypeError('track must be an audio MediaStreamTrack.');
|
||||
}
|
||||
|
||||
super(codecConfig.codec);
|
||||
this.encoder = new AudioEncoderWrapper(this, codecConfig);
|
||||
}
|
||||
@@ -392,6 +497,10 @@ export abstract class SubtitleSource extends MediaSource {
|
||||
constructor(codec: SubtitleCodec) {
|
||||
super();
|
||||
|
||||
if (!SUBTITLE_CODECS.includes(codec)) {
|
||||
throw new TypeError(`Invalid subtitle codec '${codec}'. Must be one of: ${SUBTITLE_CODECS.join(', ')}.`);
|
||||
}
|
||||
|
||||
this.codec = codec;
|
||||
}
|
||||
}
|
||||
@@ -405,11 +514,15 @@ export class TextSubtitleSource extends SubtitleSource {
|
||||
this.parser = new SubtitleParser({
|
||||
codec,
|
||||
output: (cue, metadata) => this.connectedTrack?.output.muxer.addSubtitleCue(this.connectedTrack, cue, metadata),
|
||||
error: (error) => console.error(error) // TODO
|
||||
error: (error) => console.error('Subtitle parse error:', error)
|
||||
});
|
||||
}
|
||||
|
||||
digest(text: string) {
|
||||
if (typeof text !== 'string') {
|
||||
throw new TypeError('text must be a string.');
|
||||
}
|
||||
|
||||
this.ensureValidDigest();
|
||||
this.parser.parse(text);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget } from './target';
|
||||
|
||||
export abstract class Writer {
|
||||
/** Setting this to true will cause the writer to ensure data is written in a strictly monotonic, streamable way. */
|
||||
ensureMonotonicity = false;
|
||||
|
||||
/** Writes the given data to the target, at the current position. */
|
||||
abstract write(data: Uint8Array): void;
|
||||
/** Sets the current position for future writes to a new one. */
|
||||
@@ -84,6 +87,7 @@ export class StreamTargetWriter extends Writer {
|
||||
data: Uint8Array,
|
||||
start: number
|
||||
}[] = [];
|
||||
#lastFlushEnd = 0;
|
||||
|
||||
constructor(target: StreamTarget) {
|
||||
super();
|
||||
@@ -148,7 +152,12 @@ export class StreamTargetWriter extends Writer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.ensureMonotonicity && chunk.start !== this.#lastFlushEnd) {
|
||||
throw new Error('Internal error: Monotonicity violation.');
|
||||
}
|
||||
|
||||
this.#target.options.onData?.(chunk.data, chunk.start);
|
||||
this.#lastFlushEnd = chunk.start + chunk.data.byteLength;
|
||||
}
|
||||
|
||||
this.#sections.length = 0;
|
||||
@@ -185,6 +194,7 @@ export class ChunkedStreamTargetWriter extends Writer {
|
||||
* A chunk is flushed if all of its contents have been written.
|
||||
*/
|
||||
#chunks: Chunk[] = [];
|
||||
#lastFlushEnd = 0;
|
||||
|
||||
constructor(target: StreamTarget) {
|
||||
super();
|
||||
@@ -298,10 +308,15 @@ export class ChunkedStreamTargetWriter extends Writer {
|
||||
if (!chunk.shouldFlush && !force) continue;
|
||||
|
||||
for (let section of chunk.written) {
|
||||
if (this.ensureMonotonicity && chunk.start + section.start !== this.#lastFlushEnd) {
|
||||
throw new Error('Internal error: Monotonicity violation.');
|
||||
}
|
||||
|
||||
this.#target.options.onData?.(
|
||||
chunk.data.subarray(section.start, section.end),
|
||||
chunk.start + section.start
|
||||
);
|
||||
this.#lastFlushEnd = chunk.start + section.end;
|
||||
}
|
||||
this.#chunks.splice(i--, 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user