Address a bunch of TODOs and add parameter validation everywhere

This commit is contained in:
Vanilagy
2024-11-24 21:17:00 +01:00
parent 29749451bb
commit b51b2b1b45
14 changed files with 1846 additions and 1059 deletions
+4 -3
View File
@@ -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];
+592 -338
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
File diff suppressed because one or more lines are too long
+592 -338
View File
File diff suppressed because it is too large Load Diff
+124 -2
View File
@@ -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.");
}
}
+61 -32
View File
@@ -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
View File
@@ -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);
+26 -20
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+15
View File
@@ -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);
}