mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-29 11:53:52 +02:00
Add Matroska muxer
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
export interface EBMLElement {
|
||||
id: number,
|
||||
size?: number,
|
||||
data: number | string | Uint8Array | EBMLFloat32 | EBMLFloat64 | (EBML | null)[]
|
||||
}
|
||||
|
||||
export type EBML = EBMLElement | Uint8Array | (EBML | null)[];
|
||||
|
||||
/** Wrapper around a number to be able to differentiate it in the writer. */
|
||||
export class EBMLFloat32 {
|
||||
value: number;
|
||||
|
||||
constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrapper around a number to be able to differentiate it in the writer. */
|
||||
export class EBMLFloat64 {
|
||||
value: number;
|
||||
|
||||
constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Defines some of the EBML IDs used by Matroska files. */
|
||||
export enum EBMLId {
|
||||
EBML = 0x1a45dfa3,
|
||||
EBMLVersion = 0x4286,
|
||||
EBMLReadVersion = 0x42f7,
|
||||
EBMLMaxIDLength = 0x42f2,
|
||||
EBMLMaxSizeLength = 0x42f3,
|
||||
DocType = 0x4282,
|
||||
DocTypeVersion = 0x4287,
|
||||
DocTypeReadVersion = 0x4285,
|
||||
SeekHead = 0x114d9b74,
|
||||
Seek = 0x4dbb,
|
||||
SeekID = 0x53ab,
|
||||
SeekPosition = 0x53ac,
|
||||
Duration = 0x4489,
|
||||
Info = 0x1549a966,
|
||||
TimestampScale = 0x2ad7b1,
|
||||
MuxingApp = 0x4d80,
|
||||
WritingApp = 0x5741,
|
||||
Tracks = 0x1654ae6b,
|
||||
TrackEntry = 0xae,
|
||||
TrackNumber = 0xd7,
|
||||
TrackUID = 0x73c5,
|
||||
TrackType = 0x83,
|
||||
CodecID = 0x86,
|
||||
CodecPrivate = 0x63a2,
|
||||
DefaultDuration = 0x23e383,
|
||||
Video = 0xe0,
|
||||
PixelWidth = 0xb0,
|
||||
PixelHeight = 0xba,
|
||||
Void = 0xec,
|
||||
Audio = 0xe1,
|
||||
SamplingFrequency = 0xb5,
|
||||
Channels = 0x9f,
|
||||
BitDepth = 0x6264,
|
||||
Segment = 0x18538067,
|
||||
SimpleBlock = 0xa3,
|
||||
BlockGroup = 0xa0,
|
||||
Block = 0xa1,
|
||||
BlockAdditions = 0x75a1,
|
||||
BlockDuration = 0x9b,
|
||||
ReferenceBlock = 0xfb,
|
||||
Cluster = 0x1f43b675,
|
||||
Timestamp = 0xe7,
|
||||
Cues = 0x1c53bb6b,
|
||||
CuePoint = 0xbb,
|
||||
CueTime = 0xb3,
|
||||
CueTrackPositions = 0xb7,
|
||||
CueTrack = 0xf7,
|
||||
CueClusterPosition = 0xf1,
|
||||
Colour = 0x55b0,
|
||||
MatrixCoefficients = 0x55b1,
|
||||
TransferCharacteristics = 0x55ba,
|
||||
Primaries = 0x55bb,
|
||||
Range = 0x55b9,
|
||||
AlphaMode = 0x53c0
|
||||
}
|
||||
|
||||
export const measureUnsignedInt = (value: number) => {
|
||||
// Force to 32-bit unsigned integer
|
||||
if (value < (1 << 8)) {
|
||||
return 1;
|
||||
} else if (value < (1 << 16)) {
|
||||
return 2;
|
||||
} else if (value < (1 << 24)) {
|
||||
return 3;
|
||||
} else if (value < 2**32) {
|
||||
return 4;
|
||||
} else if (value < 2**40) {
|
||||
return 5;
|
||||
} else {
|
||||
return 6;
|
||||
}
|
||||
};
|
||||
|
||||
export const measureEBMLVarInt = (value: number) => {
|
||||
if (value < (1 << 7) - 1) {
|
||||
/** Top bit is set, leaving 7 bits to hold the integer, but we can't store
|
||||
* 127 because "all bits set to one" is a reserved value. Same thing for the
|
||||
* other cases below:
|
||||
*/
|
||||
return 1;
|
||||
} else if (value < (1 << 14) - 1) {
|
||||
return 2;
|
||||
} else if (value < (1 << 21) - 1) {
|
||||
return 3;
|
||||
} else if (value < (1 << 28) - 1) {
|
||||
return 4;
|
||||
} else if (value < 2**35-1) {
|
||||
return 5;
|
||||
} else if (value < 2**42-1) {
|
||||
return 6;
|
||||
} else {
|
||||
throw new Error('EBML VINT size not supported ' + value);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,915 @@
|
||||
import { assert, readBits, toUint8Array, writeBits } from '../misc';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
import { MkvOutputFormat, WebMOutputFormat } from '../output_format';
|
||||
import { AudioCodec, VideoCodec } from '../source';
|
||||
import { Writer } from '../writer';
|
||||
import { EBML, EBMLElement, EBMLFloat32, EBMLFloat64, EBMLId, measureEBMLVarInt, measureUnsignedInt } from './ebml';
|
||||
|
||||
const VIDEO_TRACK_TYPE = 1;
|
||||
const AUDIO_TRACK_TYPE = 2;
|
||||
const MAX_CHUNK_LENGTH_MS = 2**15;
|
||||
const APP_NAME = 'https://github.com/Vanilagy/webm-muxer'; // TODO
|
||||
const SEGMENT_SIZE_BYTES = 6;
|
||||
const CLUSTER_SIZE_BYTES = 5;
|
||||
|
||||
type InternalMediaChunk = {
|
||||
data: Uint8Array,
|
||||
type: 'key' | 'delta',
|
||||
timestamp: number,
|
||||
duration: number | null,
|
||||
additions: Uint8Array | null,
|
||||
};
|
||||
|
||||
type SeekHead = {
|
||||
id: number,
|
||||
data: {
|
||||
id: number,
|
||||
data: ({
|
||||
id: number,
|
||||
data: Uint8Array,
|
||||
size?: undefined
|
||||
} | {
|
||||
id: number,
|
||||
size: number,
|
||||
data: number
|
||||
})[]
|
||||
}[]
|
||||
};
|
||||
|
||||
type MatroskaTrackData = {
|
||||
chunkQueue: InternalMediaChunk[],
|
||||
|
||||
firstTimestamp: number | null,
|
||||
lastTimestamp: number | null,
|
||||
lastWrittenTimestamp: number | null
|
||||
} & ({
|
||||
track: OutputVideoTrack,
|
||||
type: 'video',
|
||||
info: {
|
||||
width: number,
|
||||
height: number,
|
||||
decoderConfig: VideoDecoderConfig
|
||||
}
|
||||
} | {
|
||||
track: OutputAudioTrack,
|
||||
type: 'audio',
|
||||
info: {
|
||||
numberOfChannels: number,
|
||||
sampleRate: number,
|
||||
decoderConfig: AudioDecoderConfig
|
||||
}
|
||||
});
|
||||
|
||||
type MatroskaVideoTrackData = MatroskaTrackData & { type: 'video' };
|
||||
type MatroskaAudioTrackData = MatroskaTrackData & { type: 'audio' };
|
||||
|
||||
const CODEC_STRING_MAP: Record<VideoCodec | AudioCodec, string> = {
|
||||
avc: 'V_MPEG4/ISO/AVC',
|
||||
hevc: 'V_MPEGH/ISO/HEVC',
|
||||
vp8: 'V_VP8',
|
||||
vp9: 'V_VP9',
|
||||
av1: 'V_AV1',
|
||||
aac: 'A_AAC',
|
||||
opus: 'A_OPUS',
|
||||
vorbis: 'A_VORBIS',
|
||||
};
|
||||
|
||||
// TODO: Unify the timestamps in this. Some timestamps are in us, some are in ms, yuck.
|
||||
// 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 {
|
||||
#writer: Writer;
|
||||
#format: WebMOutputFormat | MkvOutputFormat;
|
||||
|
||||
#helper = new Uint8Array(8);
|
||||
#helperView = new DataView(this.#helper.buffer);
|
||||
|
||||
/**
|
||||
* Stores the position from the start of the file to where EBML elements have been written. This is used to
|
||||
* rewrite/edit elements that were already added before, and to measure sizes of things.
|
||||
*/
|
||||
offsets = new WeakMap<EBML, number>();
|
||||
/** Same as offsets, but stores position where the element's data starts (after ID and size fields). */
|
||||
dataOffsets = new WeakMap<EBML, number>();
|
||||
|
||||
#trackDatas: MatroskaTrackData[] = [];
|
||||
|
||||
#segment: EBMLElement | null = null;
|
||||
#segmentInfo: EBMLElement | null = null;
|
||||
#seekHead: SeekHead | null = null;
|
||||
#tracksElement: EBMLElement | null = null;
|
||||
#segmentDuration: EBMLElement | null = null;
|
||||
#cues: EBMLElement | null = null;
|
||||
|
||||
#currentCluster: EBMLElement | null = null;
|
||||
#currentClusterTimestamp: number | null = null;
|
||||
#trackDatasInCurrentCluster = new Set<MatroskaTrackData>();
|
||||
|
||||
#duration = 0;
|
||||
|
||||
constructor(output: Output, format: MkvOutputFormat) {
|
||||
super(output);
|
||||
|
||||
this.#writer = output.writer;
|
||||
this.#format = format;
|
||||
}
|
||||
|
||||
#writeByte(value: number) {
|
||||
this.#helperView.setUint8(0, value);
|
||||
this.#writer.write(this.#helper.subarray(0, 1));
|
||||
}
|
||||
|
||||
#writeFloat32(value: number) {
|
||||
this.#helperView.setFloat32(0, value, false);
|
||||
this.#writer.write(this.#helper.subarray(0, 4));
|
||||
}
|
||||
|
||||
#writeFloat64(value: number) {
|
||||
this.#helperView.setFloat64(0, value, false);
|
||||
this.#writer.write(this.#helper);
|
||||
}
|
||||
|
||||
#writeUnsignedInt(value: number, width: number = measureUnsignedInt(value)) {
|
||||
let pos = 0;
|
||||
|
||||
// Each case falls through:
|
||||
switch (width) {
|
||||
case 6:
|
||||
// Need to use division to access >32 bits of floating point var
|
||||
this.#helperView.setUint8(pos++, (value / 2**40) | 0);
|
||||
case 5:
|
||||
this.#helperView.setUint8(pos++, (value / 2**32) | 0);
|
||||
case 4:
|
||||
this.#helperView.setUint8(pos++, value >> 24);
|
||||
case 3:
|
||||
this.#helperView.setUint8(pos++, value >> 16);
|
||||
case 2:
|
||||
this.#helperView.setUint8(pos++, value >> 8);
|
||||
case 1:
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Bad UINT size ' + width);
|
||||
}
|
||||
|
||||
this.#writer.write(this.#helper.subarray(0, pos));
|
||||
}
|
||||
|
||||
writeEBMLVarInt(value: number, width: number = measureEBMLVarInt(value)) {
|
||||
let pos = 0;
|
||||
|
||||
switch (width) {
|
||||
case 1:
|
||||
this.#helperView.setUint8(pos++, (1 << 7) | value);
|
||||
break;
|
||||
case 2:
|
||||
this.#helperView.setUint8(pos++, (1 << 6) | (value >> 8));
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
case 3:
|
||||
this.#helperView.setUint8(pos++, (1 << 5) | (value >> 16));
|
||||
this.#helperView.setUint8(pos++, value >> 8);
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
case 4:
|
||||
this.#helperView.setUint8(pos++, (1 << 4) | (value >> 24));
|
||||
this.#helperView.setUint8(pos++, value >> 16);
|
||||
this.#helperView.setUint8(pos++, value >> 8);
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
case 5:
|
||||
/**
|
||||
* JavaScript converts its doubles to 32-bit integers for bitwise
|
||||
* operations, so we need to do a division by 2^32 instead of a
|
||||
* right-shift of 32 to retain those top 3 bits
|
||||
*/
|
||||
this.#helperView.setUint8(pos++, (1 << 3) | ((value / 2**32) & 0x7));
|
||||
this.#helperView.setUint8(pos++, value >> 24);
|
||||
this.#helperView.setUint8(pos++, value >> 16);
|
||||
this.#helperView.setUint8(pos++, value >> 8);
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
case 6:
|
||||
this.#helperView.setUint8(pos++, (1 << 2) | ((value / 2**40) & 0x3));
|
||||
this.#helperView.setUint8(pos++, (value / 2**32) | 0);
|
||||
this.#helperView.setUint8(pos++, value >> 24);
|
||||
this.#helperView.setUint8(pos++, value >> 16);
|
||||
this.#helperView.setUint8(pos++, value >> 8);
|
||||
this.#helperView.setUint8(pos++, value);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Bad EBML VINT size ' + width);
|
||||
}
|
||||
|
||||
this.#writer.write(this.#helper.subarray(0, pos));
|
||||
}
|
||||
|
||||
// Assumes the string is ASCII
|
||||
#writeString(str: string) {
|
||||
this.#writer.write(new Uint8Array(str.split('').map(x => x.charCodeAt(0))));
|
||||
}
|
||||
|
||||
writeEBML(data: EBML | null) {
|
||||
if (data === null) return;
|
||||
|
||||
if (data instanceof Uint8Array) {
|
||||
this.#writer.write(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
for (let elem of data) {
|
||||
this.writeEBML(elem);
|
||||
}
|
||||
} else {
|
||||
this.offsets.set(data, this.#writer.getPos());
|
||||
|
||||
this.#writeUnsignedInt(data.id); // ID field
|
||||
|
||||
if (Array.isArray(data.data)) {
|
||||
let sizePos = this.#writer.getPos();
|
||||
let sizeSize = data.size === -1 ? 1 : (data.size ?? 4);
|
||||
|
||||
if (data.size === -1) {
|
||||
// Write the reserved all-one-bits marker for unknown/unbounded size.
|
||||
this.#writeByte(0xff);
|
||||
} else {
|
||||
this.#writer.seek(this.#writer.getPos() + sizeSize);
|
||||
}
|
||||
|
||||
let startPos = this.#writer.getPos();
|
||||
this.dataOffsets.set(data, startPos);
|
||||
this.writeEBML(data.data);
|
||||
|
||||
if (data.size !== -1) {
|
||||
let size = this.#writer.getPos() - startPos;
|
||||
let endPos = this.#writer.getPos();
|
||||
this.#writer.seek(sizePos);
|
||||
this.writeEBMLVarInt(size, sizeSize);
|
||||
this.#writer.seek(endPos);
|
||||
}
|
||||
} else if (typeof data.data === 'number') {
|
||||
let size = data.size ?? measureUnsignedInt(data.data);
|
||||
this.writeEBMLVarInt(size);
|
||||
this.#writeUnsignedInt(data.data, size);
|
||||
} else if (typeof data.data === 'string') {
|
||||
this.writeEBMLVarInt(data.data.length);
|
||||
this.#writeString(data.data);
|
||||
} else if (data.data instanceof Uint8Array) {
|
||||
this.writeEBMLVarInt(data.data.byteLength, data.size);
|
||||
this.#writer.write(data.data);
|
||||
} else if (data.data instanceof EBMLFloat32) {
|
||||
this.writeEBMLVarInt(4);
|
||||
this.#writeFloat32(data.data.value);
|
||||
} else if (data.data instanceof EBMLFloat64) {
|
||||
this.writeEBMLVarInt(8);
|
||||
this.#writeFloat64(data.data.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override beforeTrackAdd(track: OutputTrack) {
|
||||
if (!(this.#format instanceof WebMOutputFormat)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.type === 'video') {
|
||||
if (!['vp8', 'vp9', 'av1'].includes(track.source.codec)) {
|
||||
throw new Error(`WebM only supports VP8, VP9 and AV1 as video codecs. Switching to MKV removes this restriction.`);
|
||||
}
|
||||
} else {
|
||||
if (!['opus', 'vorbis'].includes(track.source.codec)) {
|
||||
throw new Error(`WebM only supports Opus and Vorbis as audio codecs. Switching to MKV removes this restriction.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.#writeEBMLHeader();
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
this.#createSeekHead();
|
||||
}
|
||||
|
||||
this.#createSegmentInfo();
|
||||
this.#createCues();
|
||||
|
||||
this.#writer.flush();
|
||||
}
|
||||
|
||||
#writeEBMLHeader() {
|
||||
let ebmlHeader: EBML = { id: EBMLId.EBML, data: [
|
||||
{ id: EBMLId.EBMLVersion, data: 1 },
|
||||
{ id: EBMLId.EBMLReadVersion, data: 1 },
|
||||
{ id: EBMLId.EBMLMaxIDLength, data: 4 },
|
||||
{ id: EBMLId.EBMLMaxSizeLength, data: 8 },
|
||||
{ id: EBMLId.DocType, data: this.#format instanceof WebMOutputFormat ? 'webm' : 'matroska' },
|
||||
{ id: EBMLId.DocTypeVersion, data: 2 },
|
||||
{ id: EBMLId.DocTypeReadVersion, data: 2 }
|
||||
] };
|
||||
this.writeEBML(ebmlHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SeekHead element which is positioned near the start of the file and allows the media player to seek to
|
||||
* relevant sections more easily. Since we don't know the positions of those sections yet, we'll set them later.
|
||||
*/
|
||||
#createSeekHead() {
|
||||
const kaxCues = new Uint8Array([ 0x1c, 0x53, 0xbb, 0x6b ]);
|
||||
const kaxInfo = new Uint8Array([ 0x15, 0x49, 0xa9, 0x66 ]);
|
||||
const kaxTracks = new Uint8Array([ 0x16, 0x54, 0xae, 0x6b ]);
|
||||
|
||||
let seekHead = { id: EBMLId.SeekHead, data: [
|
||||
{ id: EBMLId.Seek, data: [
|
||||
{ id: EBMLId.SeekID, data: kaxCues },
|
||||
{ id: EBMLId.SeekPosition, size: 5, data: 0 }
|
||||
] },
|
||||
{ id: EBMLId.Seek, data: [
|
||||
{ id: EBMLId.SeekID, data: kaxInfo },
|
||||
{ id: EBMLId.SeekPosition, size: 5, data: 0 }
|
||||
] },
|
||||
{ id: EBMLId.Seek, data: [
|
||||
{ id: EBMLId.SeekID, data: kaxTracks },
|
||||
{ id: EBMLId.SeekPosition, size: 5, data: 0 }
|
||||
] }
|
||||
] };
|
||||
this.#seekHead = seekHead;
|
||||
}
|
||||
|
||||
#createSegmentInfo() {
|
||||
let segmentDuration: EBML = { id: EBMLId.Duration, data: new EBMLFloat64(0) };
|
||||
this.#segmentDuration = segmentDuration;
|
||||
|
||||
let segmentInfo: EBML = { id: EBMLId.Info, data: [
|
||||
{ id: EBMLId.TimestampScale, data: 1e6 },
|
||||
{ id: EBMLId.MuxingApp, data: APP_NAME },
|
||||
{ id: EBMLId.WritingApp, data: APP_NAME },
|
||||
!this.#format.options.streaming ? segmentDuration : null
|
||||
] };
|
||||
this.#segmentInfo = segmentInfo;
|
||||
}
|
||||
|
||||
#createTracks() {
|
||||
let tracksElement = { id: EBMLId.Tracks, data: [] as EBML[] };
|
||||
this.#tracksElement = tracksElement;
|
||||
|
||||
for (let trackData of this.#trackDatas) {
|
||||
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: trackData.type === 'video' ? VIDEO_TRACK_TYPE : AUDIO_TRACK_TYPE }, // TODO Subtitle case
|
||||
{ id: EBMLId.CodecID, data: CODEC_STRING_MAP[trackData.track.source.codec] },
|
||||
(trackData.info.decoderConfig.description ? { id: EBMLId.CodecPrivate, data: toUint8Array(trackData.info.decoderConfig.description) } : null),
|
||||
...(trackData.type === 'video' ? [
|
||||
(trackData.track.source.metadata.frameRate ? { id: EBMLId.DefaultDuration, data: 1e9 / trackData.track.source.metadata.frameRate } : null),
|
||||
{ id: EBMLId.Video, data: [
|
||||
{ id: EBMLId.PixelWidth, data: trackData.info.width },
|
||||
{ id: EBMLId.PixelHeight, data: trackData.info.height },
|
||||
(() => {
|
||||
if (trackData.info.decoderConfig.colorSpace) {
|
||||
let colorSpace = trackData.info.decoderConfig.colorSpace;
|
||||
if (!colorSpace.matrix || !colorSpace.transfer || !colorSpace.primaries || colorSpace.fullRange == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {id: EBMLId.Colour, data: [
|
||||
{ id: EBMLId.MatrixCoefficients, data: {
|
||||
'rgb': 1,
|
||||
'bt709': 1,
|
||||
'bt470bg': 5,
|
||||
'smpte170m': 6
|
||||
}[colorSpace.matrix] },
|
||||
{ id: EBMLId.TransferCharacteristics, data: {
|
||||
'bt709': 1,
|
||||
'smpte170m': 6,
|
||||
'iec61966-2-1': 13
|
||||
}[colorSpace.transfer] },
|
||||
{ id: EBMLId.Primaries, data: {
|
||||
'bt709': 1,
|
||||
'bt470bg': 5,
|
||||
'smpte170m': 6
|
||||
}[colorSpace.primaries] },
|
||||
{ id: EBMLId.Range, data: [1, 2][Number(colorSpace.fullRange)]! }
|
||||
] };
|
||||
}
|
||||
|
||||
return null;
|
||||
})()
|
||||
] }
|
||||
] : []),
|
||||
...(trackData.type === 'audio' ? [
|
||||
{ id: EBMLId.Audio, data: [
|
||||
{ id: EBMLId.SamplingFrequency, data: new EBMLFloat32(trackData.info.sampleRate) },
|
||||
{ id: EBMLId.Channels, data: trackData.info.numberOfChannels },
|
||||
// Bit depth for when PCM is a thing
|
||||
] }
|
||||
] : [])
|
||||
] })
|
||||
}
|
||||
|
||||
/*
|
||||
if (this.#options.subtitles) {
|
||||
tracksElement.data.push({ id: EBMLId.TrackEntry, data: [
|
||||
{ id: EBMLId.TrackNumber, data: SUBTITLE_TRACK_NUMBER },
|
||||
{ id: EBMLId.TrackUID, data: SUBTITLE_TRACK_NUMBER },
|
||||
{ id: EBMLId.TrackType, data: SUBTITLE_TRACK_TYPE },
|
||||
{ id: EBMLId.CodecID, data: this.#options.subtitles.codec },
|
||||
this.#subtitleCodecPrivate
|
||||
] });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
#createSegment() {
|
||||
let segment: EBML = {
|
||||
id: EBMLId.Segment,
|
||||
size: this.#format.options.streaming ? -1 : SEGMENT_SIZE_BYTES,
|
||||
data: [
|
||||
!this.#format.options.streaming ? this.#seekHead as EBML : null,
|
||||
this.#segmentInfo,
|
||||
this.#tracksElement
|
||||
]
|
||||
};
|
||||
this.#segment = segment;
|
||||
|
||||
this.writeEBML(segment);
|
||||
|
||||
/*
|
||||
if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onHeader) {
|
||||
let { data, start } = this.#writer.getTrackedWrites(); // start should be 0
|
||||
this.#writer.target.options.onHeader(data, start);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
#createCues() {
|
||||
this.#cues = { id: EBMLId.Cues, data: [] };
|
||||
}
|
||||
|
||||
get #segmentDataOffset() {
|
||||
assert(this.#segment);
|
||||
return this.dataOffsets.get(this.#segment)!;
|
||||
}
|
||||
|
||||
#getVideoTrackData(track: OutputVideoTrack, chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {
|
||||
const existingTrackData = this.#trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as MatroskaVideoTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
assert(meta.decoderConfig.codedWidth !== undefined);
|
||||
assert(meta.decoderConfig.codedHeight !== undefined);
|
||||
|
||||
const newTrackData: MatroskaVideoTrackData = {
|
||||
track,
|
||||
type: 'video',
|
||||
info: {
|
||||
width: meta.decoderConfig.codedWidth,
|
||||
height: meta.decoderConfig.codedHeight,
|
||||
decoderConfig: meta.decoderConfig
|
||||
},
|
||||
chunkQueue: [],
|
||||
firstTimestamp: null,
|
||||
lastTimestamp: null,
|
||||
lastWrittenTimestamp: null
|
||||
};
|
||||
|
||||
this.#trackDatas.push(newTrackData);
|
||||
this.#trackDatas.sort((a, b) => a.track.id - b.track.id);
|
||||
|
||||
return newTrackData;
|
||||
}
|
||||
|
||||
#getAudioTrackData(track: OutputAudioTrack, chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
||||
const existingTrackData = this.#trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as MatroskaAudioTrackData;
|
||||
}
|
||||
|
||||
// TODO Make proper errors for these
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
|
||||
const newTrackData: MatroskaAudioTrackData = {
|
||||
track,
|
||||
type: 'audio',
|
||||
info: {
|
||||
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
||||
sampleRate: meta.decoderConfig.sampleRate,
|
||||
decoderConfig: meta.decoderConfig
|
||||
},
|
||||
chunkQueue: [],
|
||||
firstTimestamp: null,
|
||||
lastTimestamp: null,
|
||||
lastWrittenTimestamp: null
|
||||
};
|
||||
|
||||
this.#trackDatas.push(newTrackData);
|
||||
this.#trackDatas.sort((a, b) => a.track.id - b.track.id);
|
||||
|
||||
return newTrackData;
|
||||
}
|
||||
|
||||
addEncodedVideoChunk(track: OutputVideoTrack, chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata, compositionTimeOffset?: number) {
|
||||
const trackData = this.#getVideoTrackData(track, chunk, meta);
|
||||
|
||||
let videoChunk = this.#createInternalChunk(trackData, chunk);
|
||||
if (track.source.codec === 'vp9') this.#fixVP9ColorSpace(trackData, videoChunk);
|
||||
|
||||
trackData.lastTimestamp = videoChunk.timestamp;
|
||||
|
||||
trackData.chunkQueue.push(videoChunk);
|
||||
this.#interleaveChunks();
|
||||
|
||||
//this.#writeSubtitleChunks();
|
||||
this.#writer.flush();
|
||||
}
|
||||
|
||||
addEncodedAudioChunk(track: OutputAudioTrack, chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
||||
const trackData = this.#getAudioTrackData(track, chunk, meta);
|
||||
|
||||
let audioChunk = this.#createInternalChunk(trackData, chunk);
|
||||
trackData.lastTimestamp = audioChunk.timestamp;
|
||||
|
||||
trackData.chunkQueue.push(audioChunk);
|
||||
this.#interleaveChunks();
|
||||
|
||||
//this.#writeSubtitleChunks();
|
||||
this.#writer.flush();
|
||||
}
|
||||
|
||||
#interleaveChunks() {
|
||||
if (this.#trackDatas.length < this.output.tracks.length) {
|
||||
return; // We haven't seen a sample from each track yet
|
||||
}
|
||||
|
||||
outer:
|
||||
while (true) {
|
||||
let trackWithMinTimestamp: MatroskaTrackData | null = null;
|
||||
let minTimestamp = Infinity;
|
||||
|
||||
for (let trackData of this.#trackDatas) {
|
||||
if (trackData.chunkQueue.length === 0) {
|
||||
break outer;
|
||||
}
|
||||
|
||||
if (trackData.chunkQueue[0]!.timestamp < minTimestamp) {
|
||||
trackWithMinTimestamp = trackData;
|
||||
minTimestamp = trackData.chunkQueue[0]!.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
if (!trackWithMinTimestamp) {
|
||||
break;
|
||||
}
|
||||
|
||||
let chunk = trackWithMinTimestamp.chunkQueue.shift()!;
|
||||
this.#writeBlock(trackWithMinTimestamp, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/** Due to [a bug in Chromium](https://bugs.chromium.org/p/chromium/issues/detail?id=1377842), VP9 streams often
|
||||
* lack color space information. This method patches in that information. */
|
||||
// http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
|
||||
#fixVP9ColorSpace(trackData: MatroskaVideoTrackData, chunk: InternalMediaChunk) {
|
||||
if (chunk.type !== 'key') return;
|
||||
if (!trackData.info.decoderConfig.colorSpace || !trackData.info.decoderConfig.colorSpace.matrix) return;
|
||||
|
||||
let i = 0;
|
||||
// Check if it's a "superframe"
|
||||
if (readBits(chunk.data, 0, 2) !== 0b10) return; i += 2;
|
||||
|
||||
let profile = (readBits(chunk.data, i+1, i+2) << 1) + readBits(chunk.data, i+0, i+1); i += 2;
|
||||
if (profile === 3) i++;
|
||||
|
||||
let showExistingFrame = readBits(chunk.data, i+0, i+1); i++;
|
||||
if (showExistingFrame) return;
|
||||
|
||||
let frameType = readBits(chunk.data, i+0, i+1); i++;
|
||||
if (frameType !== 0) return; // Just to be sure
|
||||
|
||||
i += 2;
|
||||
|
||||
let syncCode = readBits(chunk.data, i+0, i+24); i += 24;
|
||||
if (syncCode !== 0x498342) return;
|
||||
|
||||
if (profile >= 2) i++;
|
||||
|
||||
let colorSpaceID = {
|
||||
'rgb': 7,
|
||||
'bt709': 2,
|
||||
'bt470bg': 1,
|
||||
'smpte170m': 3
|
||||
}[trackData.info.decoderConfig.colorSpace.matrix];
|
||||
writeBits(chunk.data, i+0, i+3, colorSpaceID);
|
||||
}
|
||||
|
||||
/*
|
||||
addSubtitleChunk(chunk: EncodedSubtitleChunk, meta: EncodedSubtitleChunkMetadata, timestamp?: number) {
|
||||
if (typeof chunk !== 'object' || !chunk) {
|
||||
throw new TypeError("addSubtitleChunk's first argument (chunk) must be an object.");
|
||||
} else {
|
||||
// We can't simply do an instanceof check, so let's check the structure itself:
|
||||
if (!(chunk.body instanceof Uint8Array)) {
|
||||
throw new TypeError('body must be an instance of Uint8Array.');
|
||||
}
|
||||
if (!Number.isFinite(chunk.timestamp) || chunk.timestamp < 0) {
|
||||
throw new TypeError('timestamp must be a non-negative real number.');
|
||||
}
|
||||
if (!Number.isFinite(chunk.duration) || chunk.duration < 0) {
|
||||
throw new TypeError('duration must be a non-negative real number.');
|
||||
}
|
||||
if (chunk.additions && !(chunk.additions instanceof Uint8Array)) {
|
||||
throw new TypeError('additions, when present, must be an instance of Uint8Array.');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof meta !== 'object') {
|
||||
throw new TypeError("addSubtitleChunk's second argument (meta) must be an object.");
|
||||
}
|
||||
|
||||
this.#ensureNotFinalized();
|
||||
if (!this.#options.subtitles) throw new Error('No subtitle track declared.');
|
||||
|
||||
// Write possible subtitle decoder metadata to the file
|
||||
if (meta?.decoderConfig) {
|
||||
if (this.#options.streaming) {
|
||||
this.#subtitleCodecPrivate = this.#createCodecPrivateElement(meta.decoderConfig.description);
|
||||
} else {
|
||||
this.#writeCodecPrivate(this.#subtitleCodecPrivate, meta.decoderConfig.description);
|
||||
}
|
||||
}
|
||||
|
||||
let subtitleChunk = this.#createInternalChunk(
|
||||
chunk.body,
|
||||
'key',
|
||||
timestamp ?? chunk.timestamp,
|
||||
SUBTITLE_TRACK_NUMBER,
|
||||
chunk.duration,
|
||||
chunk.additions
|
||||
);
|
||||
|
||||
this.#lastSubtitleTimestamp = subtitleChunk.timestamp;
|
||||
this.#subtitleChunkQueue.push(subtitleChunk);
|
||||
|
||||
this.#writeSubtitleChunks();
|
||||
this.#maybeFlushStreamingTargetWriter();
|
||||
}
|
||||
|
||||
#writeSubtitleChunks() {
|
||||
// Writing subtitle chunks is different from video and audio: A subtitle chunk will be written if it's
|
||||
// guaranteed that no more media chunks will be written before it, to ensure monotonicity. However, media chunks
|
||||
// will NOT wait for subtitle chunks to arrive, as they may never arrive, so that's how non-monotonicity can
|
||||
// arrive. But it should be fine, since it's all still in one cluster.
|
||||
|
||||
let lastWrittenMediaTimestamp = Math.min(
|
||||
this.#options.video ? this.#lastVideoTimestamp : Infinity,
|
||||
this.#options.audio ? this.#lastAudioTimestamp : Infinity
|
||||
);
|
||||
|
||||
let queue = this.#subtitleChunkQueue;
|
||||
while (queue.length > 0 && queue[0].timestamp <= lastWrittenMediaTimestamp) {
|
||||
this.#writeBlock(queue.shift(), !this.#options.video && !this.#options.audio);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/** Converts a read-only external chunk into an internal one for easier use. */
|
||||
#createInternalChunk(
|
||||
trackData: MatroskaTrackData,
|
||||
chunk: EncodedVideoChunk | EncodedAudioChunk
|
||||
) {
|
||||
let adjustedTimestamp = this.#validateTimestamp(trackData, chunk.timestamp);
|
||||
|
||||
let data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
|
||||
let internalChunk: InternalMediaChunk = {
|
||||
data,
|
||||
type: chunk.type,
|
||||
timestamp: adjustedTimestamp,
|
||||
duration: chunk.duration,
|
||||
additions: null
|
||||
};
|
||||
|
||||
return internalChunk;
|
||||
}
|
||||
|
||||
#validateTimestamp(trackData: MatroskaTrackData, timestamp: number) {
|
||||
if (timestamp < 0) {
|
||||
throw new Error(`Timestamps must be non-negative (got ${timestamp}s).`);
|
||||
}
|
||||
|
||||
if (trackData.firstTimestamp === null) {
|
||||
trackData.firstTimestamp = timestamp;
|
||||
}
|
||||
|
||||
timestamp -= trackData.firstTimestamp;
|
||||
|
||||
if (trackData.lastTimestamp !== null && timestamp < trackData.lastTimestamp) {
|
||||
throw new Error(
|
||||
`Timestamps must be monotonically increasing ` +
|
||||
`(timestamp went from ${trackData.lastTimestamp}s to ${timestamp}s).`
|
||||
);
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
this.#createSegment();
|
||||
}
|
||||
|
||||
let msTimestamp = Math.floor(chunk.timestamp / 1000);
|
||||
// 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 => {
|
||||
if (trackData === otherTrackData) {
|
||||
return chunk.type === 'key';
|
||||
}
|
||||
|
||||
const firstQueuedSample = otherTrackData.chunkQueue[0];
|
||||
return firstQueuedSample && firstQueuedSample.type === 'key';
|
||||
});
|
||||
|
||||
if (
|
||||
!this.#currentCluster ||
|
||||
(keyFrameQueuedEverywhere && msTimestamp - this.#currentClusterTimestamp! >= 1000)
|
||||
) {
|
||||
this.#createNewCluster(msTimestamp);
|
||||
}
|
||||
|
||||
let relativeTimestamp = msTimestamp - this.#currentClusterTimestamp!;
|
||||
if (relativeTimestamp < 0) {
|
||||
// The chunk lies outside of the current cluster
|
||||
return;
|
||||
}
|
||||
|
||||
let clusterIsTooLong = relativeTimestamp >= MAX_CHUNK_LENGTH_MS;
|
||||
if (clusterIsTooLong) {
|
||||
throw new Error(
|
||||
`Current Matroska cluster exceeded its maximum allowed length of ${MAX_CHUNK_LENGTH_MS} ` +
|
||||
`milliseconds. In order to produce a correct WebM file, you must pass in a key frame at least every ` +
|
||||
`${MAX_CHUNK_LENGTH_MS} milliseconds.`
|
||||
);
|
||||
}
|
||||
|
||||
let prelude = new Uint8Array(4);
|
||||
let view = new DataView(prelude.buffer);
|
||||
// 0x80 to indicate it's the last byte of a multi-byte number
|
||||
view.setUint8(0, 0x80 | trackData.track.id);
|
||||
view.setInt16(1, relativeTimestamp, false);
|
||||
|
||||
let msDuration = Math.floor((chunk.duration ?? 0) / 1000);
|
||||
|
||||
if (msDuration === 0 && !chunk.additions) {
|
||||
// No duration or additions, we can write out a SimpleBlock
|
||||
view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock)
|
||||
|
||||
let simpleBlock = { id: EBMLId.SimpleBlock, data: [
|
||||
prelude,
|
||||
chunk.data
|
||||
] };
|
||||
this.writeEBML(simpleBlock);
|
||||
} else {
|
||||
let blockGroup = { id: EBMLId.BlockGroup, data: [
|
||||
{ id: EBMLId.Block, data: [
|
||||
prelude,
|
||||
chunk.data
|
||||
] },
|
||||
chunk.type === 'delta' ? { id: EBMLId.ReferenceBlock, data: trackData.lastWrittenTimestamp! - msTimestamp } : null,
|
||||
chunk.duration !== null ? { id: EBMLId.BlockDuration, data: msDuration } : null,
|
||||
chunk.additions ? { id: EBMLId.BlockAdditions, data: chunk.additions } : null
|
||||
] };
|
||||
this.writeEBML(blockGroup);
|
||||
}
|
||||
|
||||
this.#duration = Math.max(this.#duration, msTimestamp + msDuration);
|
||||
trackData.lastWrittenTimestamp = msTimestamp;
|
||||
|
||||
this.#trackDatasInCurrentCluster.add(trackData);
|
||||
}
|
||||
|
||||
/** Creates a new Cluster element to contain media chunks. */
|
||||
#createNewCluster(timestamp: number) {
|
||||
if (this.#currentCluster && !this.#format.options.streaming) {
|
||||
this.#finalizeCurrentCluster();
|
||||
}
|
||||
|
||||
/*
|
||||
if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
|
||||
this.#writer.startTrackingWrites();
|
||||
}
|
||||
*/
|
||||
|
||||
this.#currentCluster = {
|
||||
id: EBMLId.Cluster,
|
||||
size: this.#format.options.streaming ? -1 : CLUSTER_SIZE_BYTES,
|
||||
data: [
|
||||
{ id: EBMLId.Timestamp, data: timestamp }
|
||||
]
|
||||
};
|
||||
this.writeEBML(this.#currentCluster);
|
||||
|
||||
this.#currentClusterTimestamp = timestamp;
|
||||
this.#trackDatasInCurrentCluster.clear();
|
||||
}
|
||||
|
||||
#finalizeCurrentCluster() {
|
||||
assert(this.#currentCluster);
|
||||
let clusterSize = this.#writer.getPos() - this.dataOffsets.get(this.#currentCluster)!;
|
||||
let endPos = this.#writer.getPos();
|
||||
|
||||
// Write the size now that we know it
|
||||
this.#writer.seek(this.offsets.get(this.#currentCluster)! + 4);
|
||||
this.writeEBMLVarInt(clusterSize, CLUSTER_SIZE_BYTES);
|
||||
this.#writer.seek(endPos);
|
||||
|
||||
/*
|
||||
if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
|
||||
let { data, start } = this.#writer.getTrackedWrites();
|
||||
this.#writer.target.options.onCluster(data, start, this.#currentClusterTimestamp);
|
||||
}
|
||||
*/
|
||||
|
||||
let clusterOffsetFromSegment =
|
||||
this.offsets.get(this.#currentCluster)! - this.#segmentDataOffset;
|
||||
|
||||
assert(this.#cues);
|
||||
|
||||
// Add a CuePoint to the Cues element for better seeking
|
||||
// TODO: Should this include subtitle tracks?
|
||||
(this.#cues.data as EBML[]).push({ id: EBMLId.CuePoint, data: [
|
||||
{ id: EBMLId.CueTime, data: this.#currentClusterTimestamp! },
|
||||
// We only write out cues for tracks that have at least one chunk in this cluster
|
||||
...[...this.#trackDatasInCurrentCluster].map(trackData => {
|
||||
return { id: EBMLId.CueTrackPositions, data: [
|
||||
{ id: EBMLId.CueTrack, data: trackData.track.id },
|
||||
{ id: EBMLId.CueClusterPosition, data: clusterOffsetFromSegment }
|
||||
] };
|
||||
})
|
||||
] });
|
||||
}
|
||||
|
||||
/** Finalizes the file, making it ready for use. Must be called after all media chunks have been added. */
|
||||
finalize() {
|
||||
// Flush any remaining queued chunks to the file
|
||||
for (let trackData of this.#trackDatas) {
|
||||
while (trackData.chunkQueue.length > 0) {
|
||||
this.#writeBlock(trackData, trackData.chunkQueue.shift()!);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
this.#finalizeCurrentCluster();
|
||||
}
|
||||
|
||||
/*
|
||||
while (this.#videoChunkQueue.length > 0) this.#writeBlock(this.#videoChunkQueue.shift(), true);
|
||||
while (this.#audioChunkQueue.length > 0) this.#writeBlock(this.#audioChunkQueue.shift(), true);
|
||||
while (this.#subtitleChunkQueue.length > 0 && this.#subtitleChunkQueue[0].timestamp <= this.#duration) {
|
||||
this.#writeBlock(this.#subtitleChunkQueue.shift(), false);
|
||||
}
|
||||
*/
|
||||
|
||||
assert(this.#cues);
|
||||
this.writeEBML(this.#cues);
|
||||
|
||||
if (!this.#format.options.streaming) {
|
||||
let endPos = this.#writer.getPos();
|
||||
|
||||
// Write the Segment size
|
||||
let segmentSize = this.#writer.getPos() - this.#segmentDataOffset;
|
||||
this.#writer.seek(this.offsets.get(this.#segment!)! + 4);
|
||||
this.writeEBMLVarInt(segmentSize, SEGMENT_SIZE_BYTES);
|
||||
|
||||
// Write the duration of the media to the Segment
|
||||
this.#segmentDuration!.data = new EBMLFloat64(this.#duration);
|
||||
this.#writer.seek(this.offsets.get(this.#segmentDuration!)!);
|
||||
this.writeEBML(this.#segmentDuration!);
|
||||
|
||||
// Fill in SeekHead position data and write it again
|
||||
this.#seekHead!.data[0]!.data[1]!.data =
|
||||
this.offsets.get(this.#cues)! - this.#segmentDataOffset;
|
||||
this.#seekHead!.data[1]!.data[1]!.data =
|
||||
this.offsets.get(this.#segmentInfo!)! - this.#segmentDataOffset;
|
||||
this.#seekHead!.data[2]!.data[1]!.data =
|
||||
this.offsets.get(this.#tracksElement!)! - this.#segmentDataOffset;
|
||||
|
||||
this.#writer.seek(this.offsets.get(this.#seekHead!)!);
|
||||
this.writeEBML(this.#seekHead!);
|
||||
|
||||
this.#writer.seek(endPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user