diff --git a/api_sketch.ts b/api_sketch.ts index 4f8f252..1186ecb 100644 --- a/api_sketch.ts +++ b/api_sketch.ts @@ -11,18 +11,18 @@ class StreamTarget extends Target { } abstract class OutputFormat { - + } class Mp4OutputFormat extends OutputFormat { - constructor(options: { - fastStart: false | 'in-memory' | 'fragmented' | { - expectedVideoChunks?: number, - expectedAudioChunks?: number - }, - }) { - super(); - } + constructor(options: { + fastStart: false | 'in-memory' | 'fragmented' | { + expectedVideoChunks?: number, + expectedAudioChunks?: number + }, + }) { + super(); + } } class WebMOutputFormat extends OutputFormat { @@ -34,30 +34,30 @@ class MkvOutputFormat extends OutputFormat { } type OutputOptions = { - format: OutputFormat, - target: Target + format: OutputFormat, + target: Target }; class Output { - constructor(options: OutputOptions) { + constructor(options: OutputOptions) { - } + } - addVideoTrack(source: VideoSource) { + addVideoTrack(source: VideoSource) { - } + } - addAudioTrack(source: AudioSource) { + addAudioTrack(source: AudioSource) { - } + } - start() { - - } + start() { + + } - async finalize() { + async finalize() { - } + } } abstract class InputSource { @@ -65,7 +65,7 @@ abstract class InputSource { } class InputFormat { - constructor(format: string) {} + constructor(format: string) {} } const MP4_INPUT_FORMAT = new InputFormat('mp4'); @@ -73,14 +73,14 @@ const WEB_M_INPUT_FORMAT = new InputFormat('webm'); const MKV_INPUT_FORMAT = new InputFormat('mkv'); type InputOptions = { - formats: InputFormat[], - source: InputSource + formats: InputFormat[], + source: InputSource }; class Input { - constructor(options: InputOptions) { + constructor(options: InputOptions) { - } + } } abstract class VideoSource { @@ -88,73 +88,73 @@ abstract class VideoSource { } abstract class AudioSource { - + } type VideoCodecConfig = { - codec: 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1', - bitrate: number + codec: 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1', + bitrate: number }; type AudioCodecConfig = { - codec: 'aac' | 'opus' | 'vorbis', - bitrate: number + codec: 'aac' | 'opus' | 'vorbis', + bitrate: number }; class VideoFrameSource extends VideoSource { - constructor(codecConfig: VideoCodecConfig) { - super(); - } + constructor(codecConfig: VideoCodecConfig) { + super(); + } - digest(videoFrame: VideoFrame) { - - } + digest(videoFrame: VideoFrame) { + + } } class CanvasSource extends VideoSource { - constructor(canvas: HTMLCanvasElement, codecConfig: VideoCodecConfig) { - super(); - } + constructor(canvas: HTMLCanvasElement, codecConfig: VideoCodecConfig) { + super(); + } - digest(timestamp: number) { + digest(timestamp: number) { - } + } } class MediaStreamTrackVideoSource extends VideoSource { - constructor(track: MediaStreamTrack, codecConfig: VideoCodecConfig) { - super(); - } + constructor(track: MediaStreamTrack, codecConfig: VideoCodecConfig) { + super(); + } } class AudioDataSource extends AudioSource { - constructor(audioData: AudioData, codecConfig: AudioCodecConfig) { - super(); - } + constructor(audioData: AudioData, codecConfig: AudioCodecConfig) { + super(); + } } class AudioBufferSource extends AudioSource { - constructor(audioBuffer: AudioBuffer, codecConfig: AudioCodecConfig) { - super(); - } + constructor(audioBuffer: AudioBuffer, codecConfig: AudioCodecConfig) { + super(); + } } class MediaStreamTrackAudioSource extends AudioSource { - constructor(track: MediaStreamTrack, codecConfig: AudioCodecConfig) { - super(); - } + constructor(track: MediaStreamTrack, codecConfig: AudioCodecConfig) { + super(); + } } let output = new Output({ - format: new Mp4OutputFormat({ - fastStart: false - }), - target: new ArrayBufferTarget() + format: new Mp4OutputFormat({ + fastStart: false + }), + target: new ArrayBufferTarget() }); let source = new CanvasSource(document.createElement('canvas'), { - codec: 'avc', - bitrate: 1e6 + codec: 'avc', + bitrate: 1e6 }); output.addVideoTrack(source); diff --git a/dev/CantinaBand60.wav b/dev/CantinaBand60.wav new file mode 100644 index 0000000..47842ed Binary files /dev/null and b/dev/CantinaBand60.wav differ diff --git a/dev/index.html b/dev/index.html new file mode 100644 index 0000000..0f22b80 --- /dev/null +++ b/dev/index.html @@ -0,0 +1,76 @@ + + + \ No newline at end of file diff --git a/dist/metamuxer.js b/dist/metamuxer.js index 3098679..062bd9b 100644 --- a/dist/metamuxer.js +++ b/dist/metamuxer.js @@ -1,6 +1,1991 @@ "use strict"; var Metamuxer = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + // src/index.ts - console.log("hi"); + var src_exports = {}; + __export(src_exports, { + ArrayBufferTarget: () => ArrayBufferTarget2, + AudioBufferSource: () => AudioBufferSource, + AudioDataSource: () => AudioDataSource, + CanvasSource: () => CanvasSource, + FileSystemWritableFileStreamTarget: () => FileSystemWritableFileStreamTarget2, + MediaStreamAudioTrackSource: () => MediaStreamAudioTrackSource, + MediaStreamVideoTrackSource: () => MediaStreamVideoTrackSource, + Mp4OutputFormat: () => Mp4OutputFormat, + Output: () => Output, + StreamTarget: () => StreamTarget, + Target: () => Target, + VideoFrameSource: () => VideoFrameSource + }); + + // src/codec.ts + var buildVideoCodecString = (codec, width, height) => { + if (codec === "avc") { + let profileIndication = 100; + if (width <= 768 && height <= 432) { + profileIndication = 66; + } else if (width <= 1920 && height <= 1080) { + profileIndication = 77; + } + const profileCompatibility = 0; + const levelIndication = width > 1920 || height > 1080 ? 50 : 41; + const hexProfileIndication = profileIndication.toString(16).padStart(2, "0"); + const hexProfileCompatibility = profileCompatibility.toString(16).padStart(2, "0"); + const hexLevelIndication = levelIndication.toString(16).padStart(2, "0"); + return `avc1.${hexProfileIndication}${hexProfileCompatibility}${hexLevelIndication}`; + } else if (codec === "hevc") { + let profileSpace = 0; + let profileIdc = 1; + const compatibilityFlags = Array(32).fill(0); + compatibilityFlags[profileIdc] = 1; + const compatibilityHex = parseInt(compatibilityFlags.reverse().join(""), 2).toString(16).replace(/^0+/, ""); + let tier = "L"; + let level = 120; + if (width <= 1280 && height <= 720) { + level = 93; + } else if (width <= 1920 && height <= 1080) { + level = 120; + } else if (width <= 3840 && height <= 2160) { + level = 150; + } else { + tier = "H"; + level = 180; + } + const constraintFlags = "B0"; + const profilePrefix = profileSpace === 0 ? "" : String.fromCharCode(65 + profileSpace - 1); + return `hev1.${profilePrefix}${profileIdc}.${compatibilityHex}.${tier}${level}.${constraintFlags}`; + } else if (codec === "vp8") { + return "vp8"; + } else if (codec === "vp9") { + const profile = "00"; + let level; + if (width <= 854 && height <= 480) { + level = "21"; + } else if (width <= 1280 && height <= 720) { + level = "31"; + } else if (width <= 1920 && height <= 1080) { + level = "41"; + } else if (width <= 3840 && height <= 2160) { + level = "51"; + } else { + level = "61"; + } + const bitDepth = "08"; + return `vp09.${profile}.${level}.${bitDepth}`; + } else if (codec === "av1") { + const profile = 0; + let level; + if (width <= 854 && height <= 480) { + level = "01"; + } else if (width <= 1280 && height <= 720) { + level = "03"; + } else if (width <= 1920 && height <= 1080) { + level = "04"; + } else if (width <= 3840 && height <= 2160) { + level = "07"; + } else { + level = "09"; + } + const tier = "M"; + const bitDepth = "08"; + return `av01.${profile}.${level}${tier}.${bitDepth}`; + } + throw new Error(`Unhandled codec '${codec}'.`); + }; + var buildAudioCodecString = (codec, numberOfChannels, sampleRate) => { + if (codec === "aac") { + if (numberOfChannels >= 2 && sampleRate <= 24e3) { + return "mp4a.40.29"; + } + if (sampleRate <= 24e3) { + return "mp4a.40.5"; + } + return "mp4a.40.2"; + } else if (codec === "opus") { + return "opus"; + } else if (codec === "vorbis") { + return "vorbis"; + } + throw new Error(`Unhandled codec '${codec}'.`); + }; + + // src/misc.ts + function assert(x) { + if (!x) { + throw new Error("Assertion failed."); + } + } + var last = (arr) => { + return arr && arr[arr.length - 1]; + }; + var isU32 = (value) => { + return value >= 0 && value < 2 ** 32; + }; + + // src/source.ts + var VideoSource = class { + constructor(codec, metadata) { + this.connectedTrack = null; + this.codec = codec; + this.metadata = metadata; + } + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error("Cannot call digest after output has started finalizing."); + } + } + start() { + } + async flush() { + } + }; + var AudioSource = class { + constructor(codec, metadata) { + this.connectedTrack = null; + this.codec = codec; + this.metadata = metadata; + } + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error("Cannot call digest after output has started finalizing."); + } + } + start() { + } + async flush() { + } + }; + var KEY_FRAME_INTERVAL = 5; + var VideoEncoderWrapper = class { + constructor(source, codecConfig) { + this.source = source; + this.codecConfig = codecConfig; + this.encoder = null; + this.lastMultipleOfKeyFrameInterval = -1; + } + digest(videoFrame) { + this.source.ensureNotFinalizing(); + this.ensureEncoder(videoFrame); + assert(this.encoder); + const multipleOfKeyFrameInterval = Math.floor(videoFrame.timestamp / 1e6 / KEY_FRAME_INTERVAL); + this.encoder.encode(videoFrame, { keyFrame: multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval }); + this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; + } + ensureEncoder(videoFrame) { + if (this.encoder) { + return; + } + this.encoder = new VideoEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error) + // TODO + }); + this.encoder.configure({ + codec: buildVideoCodecString(this.codecConfig.codec, videoFrame.codedWidth, videoFrame.codedHeight), + width: videoFrame.codedWidth, + height: videoFrame.codedHeight, + bitrate: this.codecConfig.bitrate + }); + } + async flush() { + return this.encoder?.flush(); + } + }; + var VideoFrameSource = class extends VideoSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + digest(videoFrame) { + this.encoder.digest(videoFrame); + } + flush() { + return this.encoder.flush(); + } + }; + var CanvasSource = class extends VideoSource { + constructor(canvas, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.canvas = canvas; + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + digest(timestamp, duration = 0) { + const frame = new VideoFrame(this.canvas, { + timestamp: Math.round(1e6 * timestamp), + duration: Math.round(1e6 * duration) + }); + this.encoder.digest(frame); + frame.close(); + } + flush() { + return this.encoder.flush(); + } + }; + var MediaStreamVideoTrackSource = class extends VideoSource { + constructor(track, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.track = track; + this.abortController = null; + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + start() { + this.abortController = new AbortController(); + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (videoFrame) => { + this.encoder.digest(videoFrame); + videoFrame.close(); + } + }); + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return; + console.error("Pipe error:", err); + }); + } + async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + await this.encoder.flush(); + } + }; + var AudioEncoderWrapper = class { + constructor(source, codecConfig) { + this.source = source; + this.codecConfig = codecConfig; + this.encoder = null; + } + digest(audioData) { + this.source.ensureNotFinalizing(); + this.ensureEncoder(audioData); + assert(this.encoder); + this.encoder.encode(audioData); + } + ensureEncoder(audioData) { + if (this.encoder) { + return; + } + this.encoder = new AudioEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error) + // TODO + }); + this.encoder.configure({ + codec: buildAudioCodecString(this.codecConfig.codec, audioData.numberOfChannels, audioData.sampleRate), + numberOfChannels: audioData.numberOfChannels, + sampleRate: audioData.sampleRate, + bitrate: this.codecConfig.bitrate + }); + } + async flush() { + return this.encoder?.flush(); + } + }; + var AudioDataSource = class extends AudioSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + digest(audioData) { + this.encoder.digest(audioData); + } + flush() { + return this.encoder.flush(); + } + }; + var AudioBufferSource = class extends AudioSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.accumulatedFrameCount = 0; + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + digest(audioBuffer) { + const numberOfChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numberOfFrames = audioBuffer.length; + const data = new Float32Array(numberOfChannels * numberOfFrames); + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = audioBuffer.getChannelData(channel); + data.set(channelData, channel * numberOfFrames); + } + const audioData = new AudioData({ + format: "f32-planar", + sampleRate, + numberOfFrames, + numberOfChannels, + timestamp: Math.round(1e6 * this.accumulatedFrameCount / sampleRate), + data + }); + this.encoder.digest(audioData); + audioData.close(); + this.accumulatedFrameCount += numberOfFrames; + } + flush() { + return this.encoder.flush(); + } + }; + var MediaStreamAudioTrackSource = class extends AudioSource { + constructor(track, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.track = track; + this.abortController = null; + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + start() { + this.abortController = new AbortController(); + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (audioData) => { + this.encoder.digest(audioData); + audioData.close(); + } + }); + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return; + console.error("Pipe error:", err); + }); + } + async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + await this.encoder.flush(); + } + }; + + // src/output.ts + var Output = class { + constructor(options) { + this.tracks = []; + this.started = false; + this.finalizing = false; + this.writer = options.target.createWriter(); + this.muxer = options.format.createMuxer(this); + } + addTrack(source) { + if (this.started) { + throw new Error("Cannot add track after output has started."); + } + if (source.connectedTrack) { + throw new Error("Source is already used for a track."); + } + const track = { + id: this.tracks.length + 1, + output: this, + type: source instanceof VideoSource ? "video" : "audio", + source + }; + this.tracks.push(track); + source.connectedTrack = track; + } + start() { + if (this.started) { + throw new Error("Output already started."); + } + this.started = true; + this.muxer.start(); + for (const track of this.tracks) { + track.source.start(); + } + } + async finalize() { + if (this.finalizing) { + throw new Error("Cannot call finalize twice."); + } + this.finalizing = true; + const promises = this.tracks.map((x) => x.source.flush()); + await Promise.all(promises); + this.muxer.finalize(); + this.writer.flush(); + this.writer.finalize(); + } + }; + + // src/isobmff/isobmff_boxes.ts + var bytes = new Uint8Array(8); + var view = new DataView(bytes.buffer); + var u8 = (value) => { + return [(value % 256 + 256) % 256]; + }; + var u16 = (value) => { + view.setUint16(0, value, false); + return [bytes[0], bytes[1]]; + }; + var i16 = (value) => { + view.setInt16(0, value, false); + return [bytes[0], bytes[1]]; + }; + var u24 = (value) => { + view.setUint32(0, value, false); + return [bytes[1], bytes[2], bytes[3]]; + }; + var u32 = (value) => { + view.setUint32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; + }; + var i32 = (value) => { + view.setInt32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; + }; + var u64 = (value) => { + view.setUint32(0, Math.floor(value / 2 ** 32), false); + view.setUint32(4, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]]; + }; + var fixed_8_8 = (value) => { + view.setInt16(0, 2 ** 8 * value, false); + return [bytes[0], bytes[1]]; + }; + var fixed_16_16 = (value) => { + view.setInt32(0, 2 ** 16 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; + }; + var fixed_2_30 = (value) => { + view.setInt32(0, 2 ** 30 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; + }; + var ascii = (text, nullTerminated = false) => { + let bytes2 = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i)); + if (nullTerminated) bytes2.push(0); + return bytes2; + }; + var lastPresentedSample = (samples) => { + let result = null; + for (let sample of samples) { + if (!result || sample.presentationTimestamp > result.presentationTimestamp) { + result = sample; + } + } + return result; + }; + var rotationMatrix = (rotationInDegrees) => { + let theta = rotationInDegrees * (Math.PI / 180); + let cosTheta = Math.cos(theta); + let sinTheta = Math.sin(theta); + return [ + cosTheta, + sinTheta, + 0, + -sinTheta, + cosTheta, + 0, + 0, + 0, + 1 + ]; + }; + var IDENTITY_MATRIX = rotationMatrix(0); + var matrixToBytes = (matrix) => { + return [ + fixed_16_16(matrix[0]), + fixed_16_16(matrix[1]), + fixed_2_30(matrix[2]), + fixed_16_16(matrix[3]), + fixed_16_16(matrix[4]), + fixed_2_30(matrix[5]), + fixed_16_16(matrix[6]), + fixed_16_16(matrix[7]), + fixed_2_30(matrix[8]) + ]; + }; + var box = (type, contents, children) => ({ + type, + contents: contents && new Uint8Array(contents.flat(10)), + children + }); + var fullBox = (type, version, flags, contents, children) => box( + type, + [u8(version), u24(flags), contents ?? []], + children + ); + var ftyp = (details) => { + let minorVersion = 512; + if (details.fragmented) return box("ftyp", [ + ascii("iso5"), + // Major brand + u32(minorVersion), + // Minor version + // Compatible brands + ascii("iso5"), + ascii("iso6"), + ascii("mp41") + ]); + return box("ftyp", [ + ascii("isom"), + // Major brand + u32(minorVersion), + // Minor version + // Compatible brands + ascii("isom"), + details.holdsAvc ? ascii("avc1") : [], + ascii("mp41") + ]); + }; + var mdat = (reserveLargeSize) => ({ type: "mdat", largeSize: reserveLargeSize }); + var free = (size) => ({ type: "free", size }); + var moov = (trackDatas, creationTime, fragmented = false) => box("moov", void 0, [ + mvhd(creationTime, trackDatas), + ...trackDatas.map((x) => trak(x, creationTime)), + fragmented ? mvex(trackDatas) : null + ]); + var mvhd = (creationTime, trackDatas) => { + let duration = intoTimescale(Math.max( + 0, + ...trackDatas.filter((x) => x.samples.length > 0).map((x) => { + const lastSample = lastPresentedSample(x.samples); + return lastSample.presentationTimestamp + lastSample.duration; + }) + ), GLOBAL_TIMESCALE); + let nextTrackId = Math.max(...trackDatas.map((x) => x.track.id)) + 1; + let needsU64 = !isU32(creationTime) || !isU32(duration); + let u32OrU64 = needsU64 ? u64 : u32; + return fullBox("mvhd", +needsU64, 0, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(GLOBAL_TIMESCALE), + // Timescale + u32OrU64(duration), + // Duration + fixed_16_16(1), + // Preferred rate + fixed_8_8(1), + // Preferred volume + Array(10).fill(0), + // Reserved + matrixToBytes(IDENTITY_MATRIX), + // Matrix + Array(24).fill(0), + // Pre-defined + u32(nextTrackId) + // Next track ID + ]); + }; + var trak = (trackData, creationTime) => box("trak", void 0, [ + tkhd(trackData, creationTime), + mdia(trackData, creationTime) + ]); + var tkhd = (trackData, creationTime) => { + let lastSample = lastPresentedSample(trackData.samples); + let durationInGlobalTimescale = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + GLOBAL_TIMESCALE + ); + let needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale); + let u32OrU64 = needsU64 ? u64 : u32; + let matrix; + if (trackData.type === "video") { + const rotation = trackData.track.source.metadata.rotation; + matrix = rotation === void 0 || typeof rotation === "number" ? rotationMatrix(rotation ?? 0) : rotation; + } else { + matrix = IDENTITY_MATRIX; + } + return fullBox("tkhd", +needsU64, 3, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(trackData.track.id), + // Track ID + u32(0), + // Reserved + u32OrU64(durationInGlobalTimescale), + // Duration + Array(8).fill(0), + // Reserved + u16(0), + // Layer + u16(0), + // Alternate group + fixed_8_8(trackData.type === "audio" ? 1 : 0), + // Volume + u16(0), + // Reserved + matrixToBytes(matrix), + // Matrix + fixed_16_16(trackData.type === "video" ? trackData.info.width : 0), + // Track width + fixed_16_16(trackData.type === "video" ? trackData.info.height : 0) + // Track height + ]); + }; + var mdia = (trackData, creationTime) => box("mdia", void 0, [ + mdhd(trackData, creationTime), + hdlr(trackData.type === "video" ? "vide" : "soun"), + minf(trackData) + ]); + var mdhd = (trackData, creationTime) => { + let lastSample = lastPresentedSample(trackData.samples); + let localDuration = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + trackData.timescale + ); + let needsU64 = !isU32(creationTime) || !isU32(localDuration); + let u32OrU64 = needsU64 ? u64 : u32; + return fullBox("mdhd", +needsU64, 0, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(trackData.timescale), + // Timescale + u32OrU64(localDuration), + // Duration + u16(21956), + // Language ("und", undetermined) + u16(0) + // Quality + ]); + }; + var hdlr = (componentSubtype) => fullBox("hdlr", 0, 0, [ + ascii("mhlr"), + // Component type + ascii(componentSubtype), + // Component subtype + u32(0), + // Component manufacturer + u32(0), + // Component flags + u32(0), + // Component flags mask + ascii("mp4-muxer-hdlr", true) + // Component name + ]); + var minf = (trackData) => box("minf", void 0, [ + trackData.type === "video" ? vmhd() : smhd(), + dinf(), + stbl(trackData) + ]); + var vmhd = () => fullBox("vmhd", 0, 1, [ + u16(0), + // Graphics mode + u16(0), + // Opcolor R + u16(0), + // Opcolor G + u16(0) + // Opcolor B + ]); + var smhd = () => fullBox("smhd", 0, 0, [ + u16(0), + // Balance + u16(0) + // Reserved + ]); + var dinf = () => box("dinf", void 0, [ + dref() + ]); + var dref = () => fullBox("dref", 0, 0, [ + u32(1) + // Entry count + ], [ + url() + ]); + var url = () => fullBox("url ", 0, 1); + var stbl = (trackData) => { + const needsCtts = trackData.compositionTimeOffsetTable.length > 1 || trackData.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0); + return box("stbl", void 0, [ + stsd(trackData), + stts(trackData), + stss(trackData), + stsc(trackData), + stsz(trackData), + stco(trackData), + needsCtts ? ctts(trackData) : null + ]); + }; + var stsd = (trackData) => fullBox("stsd", 0, 0, [ + u32(1) + // Entry count + ], [ + trackData.type === "video" ? videoSampleDescription( + VIDEO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData + ) : soundSampleDescription( + AUDIO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData + ) + ]); + var videoSampleDescription = (compressionType, trackData) => box(compressionType, [ + Array(6).fill(0), + // Reserved + u16(1), + // Data reference index + u16(0), + // Pre-defined + u16(0), + // Reserved + Array(12).fill(0), + // Pre-defined + u16(trackData.info.width), + // Width + u16(trackData.info.height), + // Height + u32(4718592), + // Horizontal resolution + u32(4718592), + // Vertical resolution + u32(0), + // Reserved + u16(1), + // Frame count + Array(32).fill(0), + // Compressor name + u16(24), + // Depth + i16(65535) + // Pre-defined + ], [ + VIDEO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) + ]); + var avcC = (trackData) => trackData.info.decoderConfig && box("avcC", [ + // For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here + ...new Uint8Array(trackData.info.decoderConfig.description) + ]); + var hvcC = (trackData) => trackData.info.decoderConfig && box("hvcC", [ + // For HEVC, description is a HEVCDecoderConfigurationRecord, so nothing else to do here + ...new Uint8Array(trackData.info.decoderConfig.description) + ]); + var vpcC = (trackData) => { + if (!trackData.info.decoderConfig) { + return null; + } + let decoderConfig = trackData.info.decoderConfig; + if (!decoderConfig.colorSpace) { + throw new Error(`'colorSpace' is required in the decoder config for VP8/VP9.`); + } + let parts = decoderConfig.codec.split("."); + let profile = Number(parts[1]); + let level = Number(parts[2]); + let bitDepth = Number(parts[3]); + let chromaSubsampling = 0; + let thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange); + let colourPrimaries = 2; + let transferCharacteristics = 2; + let matrixCoefficients = 2; + return fullBox("vpcC", 1, 0, [ + u8(profile), + // Profile + u8(level), + // Level + u8(thirdByte), + // Bit depth, chroma subsampling, full range + u8(colourPrimaries), + // Colour primaries + u8(transferCharacteristics), + // Transfer characteristics + u8(matrixCoefficients), + // Matrix coefficients + u16(0) + // Codec initialization data size + ]); + }; + var av1C = () => { + let marker = 1; + let version = 1; + let firstByte = (marker << 7) + version; + return box("av1C", [ + firstByte, + 0, + 0, + 0 + ]); + }; + var soundSampleDescription = (compressionType, trackData) => box(compressionType, [ + Array(6).fill(0), + // Reserved + u16(1), + // Data reference index + u16(0), + // Version + u16(0), + // Revision level + u32(0), + // Vendor + u16(trackData.info.numberOfChannels), + // Number of channels + u16(16), + // Sample size (bits) + u16(0), + // Compression ID + u16(0), + // Packet size + fixed_16_16(trackData.info.sampleRate) + // Sample rate + ], [ + AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) + ]); + var esds = (trackData) => { + let description = new Uint8Array(trackData.info.decoderConfig.description); + return fullBox("esds", 0, 0, [ + // https://stackoverflow.com/a/54803118 + u32(58753152), + // TAG(3) = Object Descriptor ([2]) + u8(32 + description.byteLength), + // length of this OD (which includes the next 2 tags) + u16(1), + // ES_ID = 1 + u8(0), + // flags etc = 0 + u32(75530368), + // TAG(4) = ES Descriptor ([2]) embedded in above OD + u8(18 + description.byteLength), + // length of this ESD + u8(64), + // MPEG-4 Audio + u8(21), + // stream type(6bits)=5 audio, flags(2bits)=1 + u24(0), + // 24bit buffer size + u32(130071), + // max bitrate + u32(130071), + // avg bitrate + u32(92307584), + // TAG(5) = ASC ([2],[3]) embedded in above OD + u8(description.byteLength), + // length + ...description, + u32(109084800), + // TAG(6) + u8(1), + // length + u8(2) + // data + ]); + }; + var dOps = (trackData) => { + let preskip = 3840; + let gain = 0; + 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."); + } + const view2 = ArrayBuffer.isView(description) ? new DataView(description.buffer, description.byteOffset, description.byteLength) : new DataView(description); + preskip = view2.getUint16(10, true); + gain = view2.getInt16(14, true); + } + return box("dOps", [ + u8(0), + // Version + u8(trackData.info.numberOfChannels), + // OutputChannelCount + u16(preskip), + u32(trackData.info.sampleRate), + // InputSampleRate + fixed_8_8(gain), + // OutputGain + u8(0) + // ChannelMappingFamily + ]); + }; + var stts = (trackData) => { + return fullBox("stts", 0, 0, [ + u32(trackData.timeToSampleTable.length), + // Number of entries + trackData.timeToSampleTable.map((x) => [ + // Time-to-sample table + u32(x.sampleCount), + // Sample count + u32(x.sampleDelta) + // Sample duration + ]) + ]); + }; + var stss = (trackData) => { + if (trackData.samples.every((x) => x.type === "key")) return null; + let keySamples = [...trackData.samples.entries()].filter(([, sample]) => sample.type === "key"); + return fullBox("stss", 0, 0, [ + u32(keySamples.length), + // Number of entries + keySamples.map(([index]) => u32(index + 1)) + // Sync sample table + ]); + }; + var stsc = (trackData) => { + return fullBox("stsc", 0, 0, [ + u32(trackData.compactlyCodedChunkTable.length), + // Number of entries + trackData.compactlyCodedChunkTable.map((x) => [ + // Sample-to-chunk table + u32(x.firstChunk), + // First chunk + u32(x.samplesPerChunk), + // Samples per chunk + u32(1) + // Sample description index + ]) + ]); + }; + var stsz = (trackData) => fullBox("stsz", 0, 0, [ + u32(0), + // Sample size (0 means non-constant size) + u32(trackData.samples.length), + // Number of entries + trackData.samples.map((x) => u32(x.size)) + // Sample size table + ]); + var stco = (trackData) => { + if (trackData.finalizedChunks.length > 0 && last(trackData.finalizedChunks).offset >= 2 ** 32) { + return fullBox("co64", 0, 0, [ + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((x) => u64(x.offset)) + // Chunk offset table + ]); + } + return fullBox("stco", 0, 0, [ + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((x) => u32(x.offset)) + // Chunk offset table + ]); + }; + var ctts = (trackData) => { + return fullBox("ctts", 0, 0, [ + u32(trackData.compositionTimeOffsetTable.length), + // Number of entries + trackData.compositionTimeOffsetTable.map((x) => [ + // Time-to-sample table + u32(x.sampleCount), + // Sample count + u32(x.sampleCompositionTimeOffset) + // Sample offset + ]) + ]); + }; + var mvex = (trackDatas) => { + return box("mvex", void 0, trackDatas.map(trex)); + }; + var trex = (trackData) => { + return fullBox("trex", 0, 0, [ + u32(trackData.track.id), + // Track ID + u32(1), + // Default sample description index + u32(0), + // Default sample duration + u32(0), + // Default sample size + u32(0) + // Default sample flags + ]); + }; + var moof = (sequenceNumber, trackDatas) => { + return box("moof", void 0, [ + mfhd(sequenceNumber), + ...trackDatas.map(traf) + ]); + }; + var mfhd = (sequenceNumber) => { + return fullBox("mfhd", 0, 0, [ + u32(sequenceNumber) + // Sequence number + ]); + }; + var fragmentSampleFlags = (sample) => { + let byte1 = 0; + let byte2 = 0; + let byte3 = 0; + let byte4 = 0; + let sampleIsDifferenceSample = sample.type === "delta"; + byte2 |= +sampleIsDifferenceSample; + if (sampleIsDifferenceSample) { + byte1 |= 1; + } else { + byte1 |= 2; + } + return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4; + }; + var traf = (trackData) => { + return box("traf", void 0, [ + tfhd(trackData), + tfdt(trackData), + trun(trackData) + ]); + }; + var tfhd = (trackData) => { + assert(trackData.currentChunk); + let tfFlags = 0; + tfFlags |= 8; + tfFlags |= 16; + tfFlags |= 32; + tfFlags |= 131072; + let referenceSample = trackData.currentChunk.samples[1] ?? trackData.currentChunk.samples[0]; + let referenceSampleInfo = { + duration: referenceSample.timescaleUnitsToNextSample, + size: referenceSample.size, + flags: fragmentSampleFlags(referenceSample) + }; + return fullBox("tfhd", 0, tfFlags, [ + u32(trackData.track.id), + // Track ID + u32(referenceSampleInfo.duration), + // Default sample duration + u32(referenceSampleInfo.size), + // Default sample size + u32(referenceSampleInfo.flags) + // Default sample flags + ]); + }; + var tfdt = (trackData) => { + assert(trackData.currentChunk); + return fullBox("tfdt", 1, 0, [ + u64(intoTimescale(trackData.currentChunk.startTimestamp, trackData.timescale)) + // Base Media Decode Time + ]); + }; + var trun = (trackData) => { + assert(trackData.currentChunk); + let allSampleDurations = trackData.currentChunk.samples.map((x) => x.timescaleUnitsToNextSample); + let allSampleSizes = trackData.currentChunk.samples.map((x) => x.size); + let allSampleFlags = trackData.currentChunk.samples.map(fragmentSampleFlags); + let allSampleCompositionTimeOffsets = trackData.currentChunk.samples.map((x) => intoTimescale(x.presentationTimestamp - x.decodeTimestamp, trackData.timescale)); + let uniqueSampleDurations = new Set(allSampleDurations); + let uniqueSampleSizes = new Set(allSampleSizes); + let uniqueSampleFlags = new Set(allSampleFlags); + let uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets); + let firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1]; + let sampleDurationPresent = uniqueSampleDurations.size > 1; + let sampleSizePresent = uniqueSampleSizes.size > 1; + let sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1; + let sampleCompositionTimeOffsetsPresent = uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some((x) => x !== 0); + let flags = 0; + flags |= 1; + flags |= 4 * +firstSampleFlagsPresent; + flags |= 256 * +sampleDurationPresent; + flags |= 512 * +sampleSizePresent; + flags |= 1024 * +sampleFlagsPresent; + flags |= 2048 * +sampleCompositionTimeOffsetsPresent; + return fullBox("trun", 1, flags, [ + u32(trackData.currentChunk.samples.length), + // Sample count + u32(trackData.currentChunk.offset - trackData.currentChunk.moofOffset || 0), + // Data offset + firstSampleFlagsPresent ? u32(allSampleFlags[0]) : [], + trackData.currentChunk.samples.map((_, i) => [ + sampleDurationPresent ? u32(allSampleDurations[i]) : [], + // Sample duration + sampleSizePresent ? u32(allSampleSizes[i]) : [], + // Sample size + sampleFlagsPresent ? u32(allSampleFlags[i]) : [], + // Sample flags + // Sample composition time offsets + sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]) : [] + ]) + ]); + }; + var mfra = (trackDatas) => { + return box("mfra", void 0, [ + ...trackDatas.map(tfra), + mfro() + ]); + }; + var tfra = (trackData, trackIndex) => { + let version = 1; + return fullBox("tfra", version, 0, [ + u32(trackData.track.id), + // Track ID + u32(63), + // This specifies that traf number, trun number and sample number are 32-bit ints + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((chunk) => [ + u64(intoTimescale(chunk.startTimestamp, trackData.timescale)), + // Time + u64(chunk.moofOffset), + // moof offset + u32(trackIndex + 1), + // traf number + u32(1), + // trun number + u32(1) + // Sample number + ]) + ]); + }; + var mfro = () => { + return fullBox("mfro", 0, 0, [ + // This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box + // is known + u32(0) + // Size + ]); + }; + var VIDEO_CODEC_TO_BOX_NAME = { + "avc": "avc1", + "hevc": "hvc1", + "vp8": "vp08", + "vp9": "vp09", + "av1": "av01" + }; + var VIDEO_CODEC_TO_CONFIGURATION_BOX = { + "avc": avcC, + "hevc": hvcC, + "vp8": vpcC, + "vp9": vpcC, + "av1": av1C + }; + var AUDIO_CODEC_TO_BOX_NAME = { + "aac": "mp4a", + "opus": "Opus" + }; + var AUDIO_CODEC_TO_CONFIGURATION_BOX = { + "aac": esds, + "opus": dOps + }; + + // src/muxer.ts + var Muxer = class { + constructor(output) { + this.output = output; + } + }; + + // src/isobmff/isobmff_muxer.ts + var GLOBAL_TIMESCALE = 1e3; + var TIMESTAMP_OFFSET = 2082844800; + var intoTimescale = (timeInSeconds, timescale, round = true) => { + let value = timeInSeconds * timescale; + return round ? Math.round(value) : value; + }; + var IsobmffMuxer = class extends Muxer { + constructor(output, format) { + super(output); + this.#helper = new Uint8Array(8); + this.#helperView = new DataView(this.#helper.buffer); + /** + * Stores the position from the start of the file to where boxes elements have been written. This is used to + * rewrite/edit elements that were already added before, and to measure sizes of things. + */ + this.offsets = /* @__PURE__ */ new WeakMap(); + this.#ftypSize = null; + this.#mdat = null; + this.#trackDatas = []; + this.#creationTime = Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET; + this.#finalizedChunks = []; + this.#nextFragmentNumber = 1; + this.#writer = output.writer; + this.#format = format; + } + #writer; + #format; + #helper; + #helperView; + #ftypSize; + #mdat; + #trackDatas; + #creationTime; + #finalizedChunks; + #nextFragmentNumber; + writeU32(value) { + this.#helperView.setUint32(0, value, false); + this.#writer.write(this.#helper.subarray(0, 4)); + } + writeU64(value) { + this.#helperView.setUint32(0, Math.floor(value / 2 ** 32), false); + this.#helperView.setUint32(4, value, false); + this.#writer.write(this.#helper.subarray(0, 8)); + } + writeAscii(text) { + for (let i = 0; i < text.length; i++) { + this.#helperView.setUint8(i % 8, text.charCodeAt(i)); + if (i % 8 === 7) this.#writer.write(this.#helper); + } + if (text.length % 8 !== 0) { + this.#writer.write(this.#helper.subarray(0, text.length % 8)); + } + } + writeBox(box2) { + this.offsets.set(box2, this.#writer.getPos()); + if (box2.contents && !box2.children) { + this.writeBoxHeader(box2, box2.size ?? box2.contents.byteLength + 8); + this.#writer.write(box2.contents); + } else { + let startPos = this.#writer.getPos(); + this.writeBoxHeader(box2, 0); + if (box2.contents) this.#writer.write(box2.contents); + if (box2.children) { + for (let child of box2.children) if (child) this.writeBox(child); + } + let endPos = this.#writer.getPos(); + let size = box2.size ?? endPos - startPos; + this.#writer.seek(startPos); + this.writeBoxHeader(box2, size); + this.#writer.seek(endPos); + } + } + writeBoxHeader(box2, size) { + this.writeU32(box2.largeSize ? 1 : size); + this.writeAscii(box2.type); + if (box2.largeSize) this.writeU64(size); + } + measureBoxHeader(box2) { + return 8 + (box2.largeSize ? 8 : 0); + } + patchBox(box2) { + const boxOffset = this.offsets.get(box2); + assert(boxOffset !== void 0); + let endPos = this.#writer.getPos(); + this.#writer.seek(boxOffset); + this.writeBox(box2); + this.#writer.seek(endPos); + } + measureBox(box2) { + if (box2.contents && !box2.children) { + let headerSize = this.measureBoxHeader(box2); + return headerSize + box2.contents.byteLength; + } else { + let result = this.measureBoxHeader(box2); + if (box2.contents) result += box2.contents.byteLength; + if (box2.children) { + for (let child of box2.children) if (child) result += this.measureBox(child); + } + return result; + } + } + start() { + this.#writeHeader(); + } + #writeHeader() { + const holdsAvc = this.output.tracks.some((x) => x.type === "video" && x.source.codec === "avc"); + this.writeBox(ftyp({ + holdsAvc, + fragmented: this.#format.options.fastStart === "fragmented" + })); + this.#ftypSize = this.#writer.getPos(); + if (this.#format.options.fastStart === "in-memory") { + this.#mdat = mdat(false); + } else if (this.#format.options.fastStart === "fragmented") { + } else { + if (typeof this.#format.options.fastStart === "object") { + let moovSizeUpperBound = this.#computeMoovSizeUpperBound(); + this.#writer.seek(this.#writer.getPos() + moovSizeUpperBound); + } + this.#mdat = mdat(true); + this.writeBox(this.#mdat); + } + 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; + upperBound += (4 + 4) * Math.ceil(2 / 3 * n); + upperBound += 4 * n; + upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n); + upperBound += 4 * n; + upperBound += 8 * n; + } + upperBound += 4096; + return upperBound; + } + #getVideoTrackData(track, chunk, meta) { + const existingTrackData = this.#trackDatas.find((x) => x.track === track); + if (existingTrackData) { + return existingTrackData; + } + assert(meta); + assert(meta.decoderConfig); + assert(meta.decoderConfig.codedWidth); + assert(meta.decoderConfig.codedHeight); + const newTrackData = { + track, + type: "video", + info: { + width: meta.decoderConfig.codedWidth, + height: meta.decoderConfig.codedHeight, + decoderConfig: meta.decoderConfig + }, + timescale: track.source.metadata.frameRate ?? 57600, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + this.#trackDatas.push(newTrackData); + this.#trackDatas.sort((a, b) => a.track.id - b.track.id); + return newTrackData; + } + #getAudioTrackData(track, chunk, meta) { + const existingTrackData = this.#trackDatas.find((x) => x.track === track); + if (existingTrackData) { + return existingTrackData; + } + assert(meta); + assert(meta.decoderConfig); + const newTrackData = { + track, + type: "audio", + info: { + numberOfChannels: meta.decoderConfig.numberOfChannels, + sampleRate: meta.decoderConfig.sampleRate, + decoderConfig: meta.decoderConfig + }, + timescale: meta.decoderConfig.sampleRate, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + this.#trackDatas.push(newTrackData); + this.#trackDatas.sort((a, b) => a.track.id - b.track.id); + return newTrackData; + } + addEncodedVideoChunk(track, chunk, meta, compositionTimeOffset) { + const trackData = this.#getVideoTrackData(track, chunk, meta); + if (typeof this.#format.options.fastStart === "object" && trackData.samples.length === this.#format.options.fastStart.expectedVideoChunks) { + throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${this.#format.options.fastStart.expectedVideoChunks}).`); + } + let videoSample = this.#createSampleForTrack(trackData, chunk, compositionTimeOffset); + if (this.#format.options.fastStart === "fragmented") { + trackData.sampleQueue.push(videoSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, videoSample); + } + } + addEncodedAudioChunk(track, chunk, meta) { + const trackData = this.#getAudioTrackData(track, chunk, meta); + if (typeof this.#format.options.fastStart === "object" && trackData.samples.length === this.#format.options.fastStart.expectedAudioChunks) { + throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${this.#format.options.fastStart.expectedAudioChunks}).`); + } + let audioSample = this.#createSampleForTrack(trackData, chunk); + if (this.#format.options.fastStart === "fragmented") { + trackData.sampleQueue.push(audioSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, audioSample); + } + } + #createSampleForTrack(trackData, chunk, compositionTimeOffset) { + let presentationTimestampInSeconds = chunk.timestamp / 1e6; + let decodeTimestampInSeconds = (chunk.timestamp - (compositionTimeOffset ?? 0)) / 1e6; + let durationInSeconds = (chunk.duration ?? 0) / 1e6; + let adjusted = this.#validateTimestamp(trackData, presentationTimestampInSeconds, decodeTimestampInSeconds); + presentationTimestampInSeconds = adjusted.presentationTimestamp; + decodeTimestampInSeconds = adjusted.decodeTimestamp; + let data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + let sample = { + presentationTimestamp: presentationTimestampInSeconds, + decodeTimestamp: decodeTimestampInSeconds, + duration: durationInSeconds, + data, + size: data.byteLength, + type: chunk.type, + // Will be refined once the next sample comes in + timescaleUnitsToNextSample: intoTimescale(durationInSeconds, trackData.timescale) + }; + return sample; + } + #addSampleToTrack(trackData, sample) { + if (this.#format.options.fastStart !== "fragmented") { + trackData.samples.push(sample); + } + const sampleCompositionTimeOffset = intoTimescale(sample.presentationTimestamp - sample.decodeTimestamp, trackData.timescale); + if (trackData.lastTimescaleUnits !== null) { + assert(trackData.lastSample); + let timescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false); + let delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits); + trackData.lastTimescaleUnits += delta; + trackData.lastSample.timescaleUnitsToNextSample = delta; + if (this.#format.options.fastStart !== "fragmented") { + let lastTableEntry = last(trackData.timeToSampleTable); + assert(lastTableEntry); + if (lastTableEntry.sampleCount === 1) { + lastTableEntry.sampleDelta = delta; + lastTableEntry.sampleCount++; + } else if (lastTableEntry.sampleDelta === delta) { + lastTableEntry.sampleCount++; + } else { + lastTableEntry.sampleCount--; + trackData.timeToSampleTable.push({ + sampleCount: 2, + sampleDelta: delta + }); + } + const lastCompositionTimeOffsetTableEntry = last(trackData.compositionTimeOffsetTable); + assert(lastCompositionTimeOffsetTableEntry); + if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) { + lastCompositionTimeOffsetTableEntry.sampleCount++; + } else { + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset + }); + } + } + } else { + trackData.lastTimescaleUnits = 0; + if (this.#format.options.fastStart !== "fragmented") { + trackData.timeToSampleTable.push({ + sampleCount: 1, + sampleDelta: intoTimescale(sample.duration, trackData.timescale) + }); + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset + }); + } + } + trackData.lastSample = sample; + let beginNewChunk = false; + if (!trackData.currentChunk) { + beginNewChunk = true; + } else { + let currentChunkDuration = sample.presentationTimestamp - trackData.currentChunk.startTimestamp; + if (this.#format.options.fastStart === "fragmented") { + const keyFrameQueuedEverywhere = this.#trackDatas.every((otherTrackData) => { + if (trackData === otherTrackData) { + return sample.type === "key"; + } + const firstQueuedSample = otherTrackData.sampleQueue[0]; + return firstQueuedSample && firstQueuedSample.type === "key"; + }); + if (currentChunkDuration >= 1 && keyFrameQueuedEverywhere) { + beginNewChunk = true; + this.#finalizeFragment(); + } + } else { + beginNewChunk = currentChunkDuration >= 0.5; + } + } + if (beginNewChunk) { + if (trackData.currentChunk) { + this.#finalizeCurrentChunk(trackData); + } + trackData.currentChunk = { + startTimestamp: sample.presentationTimestamp, + samples: [], + offset: null, + moofOffset: null + }; + } + assert(trackData.currentChunk); + trackData.currentChunk.samples.push(sample); + } + #validateTimestamp(trackData, presentationTimestamp, decodeTimestamp) { + if (trackData.firstDecodeTimestamp === null) { + trackData.firstDecodeTimestamp = decodeTimestamp; + } + decodeTimestamp -= trackData.firstDecodeTimestamp; + presentationTimestamp -= trackData.firstDecodeTimestamp; + if (decodeTimestamp < trackData.lastDecodeTimestamp) { + throw new Error( + `Timestamps must be monotonically increasing (timestamp went from ${trackData.lastDecodeTimestamp}s to ${decodeTimestamp}s).` + ); + } + trackData.lastDecodeTimestamp = decodeTimestamp; + return { presentationTimestamp, decodeTimestamp }; + } + #finalizeCurrentChunk(trackData) { + assert(this.#format.options.fastStart !== "fragmented"); + if (!trackData.currentChunk) return; + trackData.finalizedChunks.push(trackData.currentChunk); + this.#finalizedChunks.push(trackData.currentChunk); + if (trackData.compactlyCodedChunkTable.length === 0 || last(trackData.compactlyCodedChunkTable).samplesPerChunk !== trackData.currentChunk.samples.length) { + trackData.compactlyCodedChunkTable.push({ + firstChunk: trackData.finalizedChunks.length, + // 1-indexed + samplesPerChunk: trackData.currentChunk.samples.length + }); + } + if (this.#format.options.fastStart === "in-memory") { + trackData.currentChunk.offset = 0; + return; + } + trackData.currentChunk.offset = this.#writer.getPos(); + for (let sample of trackData.currentChunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; + } + this.#writer.flush(); + } + #interleaveSamples() { + assert(this.#format.options.fastStart === "fragmented"); + if (this.#trackDatas.length < this.output.tracks.length) { + return; + } + outer: + while (true) { + let trackWithMinDecodeTimestamp = null; + let minDecodeTimestamp = Infinity; + for (let trackData of this.#trackDatas) { + if (trackData.sampleQueue.length === 0) { + break outer; + } + if (trackData.sampleQueue[0].decodeTimestamp < minDecodeTimestamp) { + trackWithMinDecodeTimestamp = trackData; + minDecodeTimestamp = trackData.sampleQueue[0].decodeTimestamp; + } + } + if (!trackWithMinDecodeTimestamp) { + break; + } + let sample = trackWithMinDecodeTimestamp.sampleQueue.shift(); + this.#addSampleToTrack(trackWithMinDecodeTimestamp, sample); + } + } + #finalizeFragment(flushWriter = true) { + assert(this.#format.options.fastStart === "fragmented"); + let fragmentNumber = this.#nextFragmentNumber++; + if (fragmentNumber === 1) { + let movieBox = moov(this.#trackDatas, this.#creationTime, true); + this.writeBox(movieBox); + } + let moofOffset = this.#writer.getPos(); + let moofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(moofBox); + { + let mdatBox = mdat(false); + let totalTrackSampleSize = 0; + for (let trackData of this.#trackDatas) { + assert(trackData.currentChunk); + for (let sample of trackData.currentChunk.samples) { + totalTrackSampleSize += sample.size; + } + } + let mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + if (mdatSize >= 2 ** 32) { + mdatBox.largeSize = true; + mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + } + mdatBox.size = mdatSize; + this.writeBox(mdatBox); + } + for (let trackData of this.#trackDatas) { + trackData.currentChunk.offset = this.#writer.getPos(); + trackData.currentChunk.moofOffset = moofOffset; + for (let sample of trackData.currentChunk.samples) { + this.#writer.write(sample.data); + sample.data = null; + } + } + let endPos = this.#writer.getPos(); + this.#writer.seek(this.offsets.get(moofBox)); + let newMoofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(newMoofBox); + this.#writer.seek(endPos); + for (let trackData of this.#trackDatas) { + trackData.finalizedChunks.push(trackData.currentChunk); + this.#finalizedChunks.push(trackData.currentChunk); + trackData.currentChunk = null; + } + if (flushWriter) { + this.#writer.flush(); + } + } + /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */ + finalize() { + if (this.#format.options.fastStart === "fragmented") { + for (let trackData of this.#trackDatas) { + for (let sample of trackData.sampleQueue) { + this.#addSampleToTrack(trackData, sample); + } + } + this.#finalizeFragment(false); + } else { + for (let trackData of this.#trackDatas) { + this.#finalizeCurrentChunk(trackData); + } + } + if (this.#format.options.fastStart === "in-memory") { + assert(this.#mdat); + let mdatSize; + for (let i = 0; i < 2; i++) { + let movieBox2 = moov(this.#trackDatas, this.#creationTime); + let movieBoxSize = this.measureBox(movieBox2); + mdatSize = this.measureBox(this.#mdat); + let currentChunkPos = this.#writer.getPos() + movieBoxSize + mdatSize; + for (let chunk of this.#finalizedChunks) { + chunk.offset = currentChunkPos; + for (let { data } of chunk.samples) { + assert(data); + currentChunkPos += data.byteLength; + mdatSize += data.byteLength; + } + } + if (currentChunkPos < 2 ** 32) break; + if (mdatSize >= 2 ** 32) this.#mdat.largeSize = true; + } + let movieBox = moov(this.#trackDatas, this.#creationTime); + this.writeBox(movieBox); + this.#mdat.size = mdatSize; + this.writeBox(this.#mdat); + for (let chunk of this.#finalizedChunks) { + for (let sample of chunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; + } + } + } else if (this.#format.options.fastStart === "fragmented") { + let startPos = this.#writer.getPos(); + let mfraBox = mfra(this.#trackDatas); + this.writeBox(mfraBox); + let mfraBoxSize = this.#writer.getPos() - startPos; + this.#writer.seek(this.#writer.getPos() - 4); + this.writeU32(mfraBoxSize); + } else { + assert(this.#mdat); + assert(this.#ftypSize !== null); + let mdatPos = this.offsets.get(this.#mdat); + assert(mdatPos !== void 0); + let mdatSize = this.#writer.getPos() - mdatPos; + this.#mdat.size = mdatSize; + this.#mdat.largeSize = mdatSize >= 2 ** 32; + this.patchBox(this.#mdat); + let movieBox = moov(this.#trackDatas, this.#creationTime); + if (typeof this.#format.options.fastStart === "object") { + this.#writer.seek(this.#ftypSize); + this.writeBox(movieBox); + let remainingBytes = mdatPos - this.#writer.getPos(); + this.writeBox(free(remainingBytes)); + } else { + this.writeBox(movieBox); + } + } + } + }; + + // src/output_format.ts + var OutputFormat = class { + }; + var Mp4OutputFormat = class extends OutputFormat { + constructor(options) { + super(); + this.options = options; + } + createMuxer(output) { + return new IsobmffMuxer(output, this); + } + }; + + // src/writer.ts + var Writer = class { + }; + var ArrayBufferTargetWriter = class extends Writer { + #pos = 0; + #target; + #buffer = new ArrayBuffer(2 ** 16); + #bytes = new Uint8Array(this.#buffer); + #maxPos = 0; + constructor(target) { + super(); + this.#target = target; + } + #ensureSize(size) { + let newLength = this.#buffer.byteLength; + while (newLength < size) newLength *= 2; + if (newLength === this.#buffer.byteLength) return; + let newBuffer = new ArrayBuffer(newLength); + let newBytes = new Uint8Array(newBuffer); + newBytes.set(this.#bytes, 0); + this.#buffer = newBuffer; + this.#bytes = newBytes; + } + write(data) { + this.#ensureSize(this.#pos + data.byteLength); + this.#bytes.set(data, this.#pos); + this.#pos += data.byteLength; + this.#maxPos = Math.max(this.#maxPos, this.#pos); + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + flush() { + } + finalize() { + this.#ensureSize(this.#pos); + this.#target.buffer = this.#buffer.slice(0, Math.max(this.#maxPos, this.#pos)); + } + }; + var StreamTargetWriter = class extends Writer { + #pos = 0; + #target; + #sections = []; + constructor(target) { + super(); + this.#target = target; + } + write(data) { + this.#sections.push({ + data: data.slice(), + start: this.#pos + }); + this.#pos += data.byteLength; + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + flush() { + if (this.#sections.length === 0) return; + let chunks = []; + let sorted = [...this.#sections].sort((a, b) => a.start - b.start); + chunks.push({ + start: sorted[0].start, + size: sorted[0].data.byteLength + }); + for (let i = 1; i < sorted.length; i++) { + let lastChunk = chunks[chunks.length - 1]; + let section = sorted[i]; + if (section.start <= lastChunk.start + lastChunk.size) { + lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start); + } else { + chunks.push({ + start: section.start, + size: section.data.byteLength + }); + } + } + for (let chunk of chunks) { + chunk.data = new Uint8Array(chunk.size); + for (let section of this.#sections) { + if (chunk.start <= section.start && section.start < chunk.start + chunk.size) { + chunk.data.set(section.data, section.start - chunk.start); + } + } + this.#target.options.onData?.(chunk.data, chunk.start); + } + this.#sections.length = 0; + } + finalize() { + } + }; + var DEFAULT_CHUNK_SIZE = 2 ** 24; + var MAX_CHUNKS_AT_ONCE = 2; + var ChunkedStreamTargetWriter = class extends Writer { + #pos = 0; + #target; + #chunkSize; + /** + * The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out. + * A chunk is flushed if all of its contents have been written. + */ + #chunks = []; + constructor(target) { + super(); + this.#target = target; + this.#chunkSize = target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE; + if (!Number.isInteger(this.#chunkSize) || this.#chunkSize < 2 ** 10) { + throw new Error("Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024."); + } + } + write(data) { + this.#writeDataIntoChunks(data, this.#pos); + this.#flushChunks(); + this.#pos += data.byteLength; + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + #writeDataIntoChunks(data, position) { + let chunkIndex = this.#chunks.findIndex((x) => x.start <= position && position < x.start + this.#chunkSize); + if (chunkIndex === -1) chunkIndex = this.#createChunk(position); + let chunk = this.#chunks[chunkIndex]; + let relativePosition = position - chunk.start; + let toWrite = data.subarray(0, Math.min(this.#chunkSize - relativePosition, data.byteLength)); + chunk.data.set(toWrite, relativePosition); + let section = { + start: relativePosition, + end: relativePosition + toWrite.byteLength + }; + this.#insertSectionIntoChunk(chunk, section); + if (chunk.written[0].start === 0 && chunk.written[0].end === this.#chunkSize) { + chunk.shouldFlush = true; + } + if (this.#chunks.length > MAX_CHUNKS_AT_ONCE) { + for (let i = 0; i < this.#chunks.length - 1; i++) { + this.#chunks[i].shouldFlush = true; + } + this.#flushChunks(); + } + if (toWrite.byteLength < data.byteLength) { + this.#writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength); + } + } + #insertSectionIntoChunk(chunk, section) { + let low = 0; + let high = chunk.written.length - 1; + let index = -1; + while (low <= high) { + let mid = Math.floor(low + (high - low + 1) / 2); + if (chunk.written[mid].start <= section.start) { + low = mid + 1; + index = mid; + } else { + high = mid - 1; + } + } + chunk.written.splice(index + 1, 0, section); + if (index === -1 || chunk.written[index].end < section.start) index++; + while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) { + chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end); + chunk.written.splice(index + 1, 1); + } + } + #createChunk(includesPosition) { + let start = Math.floor(includesPosition / this.#chunkSize) * this.#chunkSize; + let chunk = { + start, + data: new Uint8Array(this.#chunkSize), + written: [], + shouldFlush: false + }; + this.#chunks.push(chunk); + this.#chunks.sort((a, b) => a.start - b.start); + return this.#chunks.indexOf(chunk); + } + #flushChunks(force = false) { + for (let i = 0; i < this.#chunks.length; i++) { + let chunk = this.#chunks[i]; + if (!chunk.shouldFlush && !force) continue; + for (let section of chunk.written) { + this.#target.options.onData?.( + chunk.data.subarray(section.start, section.end), + chunk.start + section.start + ); + } + this.#chunks.splice(i--, 1); + } + } + flush() { + } + finalize() { + this.#flushChunks(true); + } + }; + var FileSystemWritableFileStreamTargetWriter = class extends ChunkedStreamTargetWriter { + constructor(target) { + super(new StreamTarget({ + onData: (data, position) => target.stream.write({ + type: "write", + data, + position + }), + chunkSize: target.options?.chunkSize + })); + } + }; + + // src/target.ts + var isTarget = Symbol("isTarget"); + isTarget; + var Target = class { + }; + var ArrayBufferTarget2 = class extends Target { + constructor() { + super(...arguments); + this.buffer = null; + } + createWriter() { + return new ArrayBufferTargetWriter(this); + } + }; + var StreamTarget = class extends Target { + constructor(options) { + super(); + this.options = options; + if (typeof options !== "object") { + throw new TypeError("StreamTarget requires an options object to be passed to its constructor."); + } + if (options.onData) { + if (typeof options.onData !== "function") { + throw new TypeError("options.onData, when provided, must be a function."); + } + if (options.onData.length < 2) { + throw new TypeError( + "options.onData, when provided, must be a function that takes in at least two arguments (data and position). Ignoring the position argument, which specifies the byte offset at which the data is to be written, can lead to broken outputs." + ); + } + } + if (options.chunked !== void 0 && typeof options.chunked !== "boolean") { + throw new TypeError("options.chunked, when provided, must be a boolean."); + } + if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError("options.chunkSize, when provided, must be a positive integer."); + } + } + createWriter() { + return this.options.chunked ? new ChunkedStreamTargetWriter(this) : new StreamTargetWriter(this); + } + }; + var FileSystemWritableFileStreamTarget2 = class extends Target { + constructor(stream, options) { + super(); + this.stream = stream; + this.options = options; + if (!(stream instanceof FileSystemWritableFileStream)) { + throw new TypeError("FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance."); + } + if (options !== void 0 && typeof options !== "object") { + throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object."); + } + if (options) { + if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError("options.chunkSize, when provided, must be a positive integer"); + } + } + } + createWriter() { + return new FileSystemWritableFileStreamTargetWriter(this); + } + }; + return __toCommonJS(src_exports); })(); if (typeof module === "object" && typeof module.exports === "object") Object.assign(module.exports, Metamuxer) diff --git a/dist/metamuxer.min.js b/dist/metamuxer.min.js index eb12a67..8d5f5c9 100644 --- a/dist/metamuxer.min.js +++ b/dist/metamuxer.min.js @@ -1,2 +1,2 @@ -"use strict";var Metamuxer=(()=>{console.log("hi");})(); +"use strict";var Metamuxer=(()=>{var re=Object.defineProperty;var xe=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var ke=Object.prototype.hasOwnProperty;var Se=(t,r)=>{for(var e in r)re(t,e,{get:r[e],enumerable:!0})},we=(t,r,e,s)=>{if(r&&typeof r=="object"||typeof r=="function")for(let o of ye(r))!ke.call(t,o)&&o!==e&&re(t,o,{get:()=>r[o],enumerable:!(s=xe(r,o))||s.enumerable});return t};var ve=t=>we(re({},"__esModule",{value:!0}),t);var gt={};Se(gt,{ArrayBufferTarget:()=>ee,AudioBufferSource:()=>$,AudioDataSource:()=>R,CanvasSource:()=>W,FileSystemWritableFileStreamTarget:()=>te,MediaStreamAudioTrackSource:()=>H,MediaStreamVideoTrackSource:()=>D,Mp4OutputFormat:()=>G,Output:()=>Q,StreamTarget:()=>I,Target:()=>O,VideoFrameSource:()=>_});var le=(t,r,e)=>{if(t==="avc"){let s=100;r<=768&&e<=432?s=66:r<=1920&&e<=1080&&(s=77);let o=0,i=r>1920||e>1080?50:41,a=s.toString(16).padStart(2,"0"),l=o.toString(16).padStart(2,"0"),u=i.toString(16).padStart(2,"0");return`avc1.${a}${l}${u}`}else if(t==="hevc"){let s=0,o=1,i=Array(32).fill(0);i[o]=1;let a=parseInt(i.reverse().join(""),2).toString(16).replace(/^0+/,""),l="L",u=120;return r<=1280&&e<=720?u=93:r<=1920&&e<=1080?u=120:r<=3840&&e<=2160?u=150:(l="H",u=180),`hev1.${s===0?"":String.fromCharCode(65+s-1)}${o}.${a}.${l}${u}.B0`}else{if(t==="vp8")return"vp8";if(t==="vp9"){let s="00",o;return r<=854&&e<=480?o="21":r<=1280&&e<=720?o="31":r<=1920&&e<=1080?o="41":r<=3840&&e<=2160?o="51":o="61",`vp09.${s}.${o}.08`}else if(t==="av1"){let o;return r<=854&&e<=480?o="01":r<=1280&&e<=720?o="03":r<=1920&&e<=1080?o="04":r<=3840&&e<=2160?o="07":o="09",`av01.0.${o}M.08`}}throw new Error(`Unhandled codec '${t}'.`)},de=(t,r,e)=>{if(t==="aac")return r>=2&&e<=24e3?"mp4a.40.29":e<=24e3?"mp4a.40.5":"mp4a.40.2";if(t==="opus")return"opus";if(t==="vorbis")return"vorbis";throw new Error(`Unhandled codec '${t}'.`)};function c(t){if(!t)throw new Error("Assertion failed.")}var z=t=>t&&t[t.length-1],w=t=>t>=0&&t<2**32;var v=class{constructor(r,e){this.connectedTrack=null;this.codec=r,this.metadata=e}ensureNotFinalizing(){if(this.connectedTrack?.output.finalizing)throw new Error("Cannot call digest after output has started finalizing.")}start(){}async flush(){}},M=class{constructor(r,e){this.connectedTrack=null;this.codec=r,this.metadata=e}ensureNotFinalizing(){if(this.connectedTrack?.output.finalizing)throw new Error("Cannot call digest after output has started finalizing.")}start(){}async flush(){}};var Ae=5,V=class{constructor(r,e){this.source=r;this.codecConfig=e;this.encoder=null;this.lastMultipleOfKeyFrameInterval=-1}digest(r){this.source.ensureNotFinalizing(),this.ensureEncoder(r),c(this.encoder);let e=Math.floor(r.timestamp/1e6/Ae);this.encoder.encode(r,{keyFrame:e!==this.lastMultipleOfKeyFrameInterval}),this.lastMultipleOfKeyFrameInterval=e}ensureEncoder(r){this.encoder||(this.encoder=new VideoEncoder({output:(e,s)=>this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack,e,s),error:e=>console.error(e)}),this.encoder.configure({codec:le(this.codecConfig.codec,r.codedWidth,r.codedHeight),width:r.codedWidth,height:r.codedHeight,bitrate:this.codecConfig.bitrate}))}async flush(){return this.encoder?.flush()}},_=class extends v{constructor(r,e={}){super(r.codec,e),this.encoder=new V(this,r)}digest(r){this.encoder.digest(r)}flush(){return this.encoder.flush()}},W=class extends v{constructor(e,s,o={}){super(s.codec,o);this.canvas=e;this.encoder=new V(this,s)}digest(e,s=0){let o=new VideoFrame(this.canvas,{timestamp:Math.round(1e6*e),duration:Math.round(1e6*s)});this.encoder.digest(o),o.close()}flush(){return this.encoder.flush()}},D=class extends v{constructor(e,s,o={}){super(s.codec,o);this.track=e;this.abortController=null;this.encoder=new V(this,s)}start(){this.abortController=new AbortController;let e=new MediaStreamTrackProcessor({track:this.track}),s=new WritableStream({write:o=>{this.encoder.digest(o),o.close()}});e.readable.pipeTo(s,{signal:this.abortController.signal}).catch(o=>{o instanceof DOMException&&o.name==="AbortError"||console.error("Pipe error:",o)})}async flush(){this.abortController&&(this.abortController.abort(),this.abortController=null),await this.encoder.flush()}};var B=class{constructor(r,e){this.source=r;this.codecConfig=e;this.encoder=null}digest(r){this.source.ensureNotFinalizing(),this.ensureEncoder(r),c(this.encoder),this.encoder.encode(r)}ensureEncoder(r){this.encoder||(this.encoder=new AudioEncoder({output:(e,s)=>this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack,e,s),error:e=>console.error(e)}),this.encoder.configure({codec:de(this.codecConfig.codec,r.numberOfChannels,r.sampleRate),numberOfChannels:r.numberOfChannels,sampleRate:r.sampleRate,bitrate:this.codecConfig.bitrate}))}async flush(){return this.encoder?.flush()}},R=class extends M{constructor(r,e={}){super(r.codec,e),this.encoder=new B(this,r)}digest(r){this.encoder.digest(r)}flush(){return this.encoder.flush()}},$=class extends M{constructor(e,s={}){super(e.codec,s);this.accumulatedFrameCount=0;this.encoder=new B(this,e)}digest(e){let s=e.numberOfChannels,o=e.sampleRate,i=e.length,a=new Float32Array(s*i);for(let u=0;u{this.encoder.digest(o),o.close()}});e.readable.pipeTo(s,{signal:this.abortController.signal}).catch(o=>{o instanceof DOMException&&o.name==="AbortError"||console.error("Pipe error:",o)})}async flush(){this.abortController&&(this.abortController.abort(),this.abortController=null),await this.encoder.flush()}};var Q=class{constructor(r){this.tracks=[];this.started=!1;this.finalizing=!1;this.writer=r.target.createWriter(),this.muxer=r.format.createMuxer(this)}addTrack(r){if(this.started)throw new Error("Cannot add track after output has started.");if(r.connectedTrack)throw new Error("Source is already used for a track.");let e={id:this.tracks.length+1,output:this,type:r instanceof v?"video":"audio",source:r};this.tracks.push(e),r.connectedTrack=e}start(){if(this.started)throw new Error("Output already started.");this.started=!0,this.muxer.start();for(let r of this.tracks)r.source.start()}async finalize(){if(this.finalizing)throw new Error("Cannot call finalize twice.");this.finalizing=!0;let r=this.tracks.map(e=>e.source.flush());await Promise.all(r),this.muxer.finalize(),this.writer.flush(),this.writer.finalize()}};var d=new Uint8Array(8),x=new DataView(d.buffer),b=t=>[(t%256+256)%256],m=t=>(x.setUint16(0,t,!1),[d[0],d[1]]),Oe=t=>(x.setInt16(0,t,!1),[d[0],d[1]]),fe=t=>(x.setUint32(0,t,!1),[d[1],d[2],d[3]]),n=t=>(x.setUint32(0,t,!1),[d[0],d[1],d[2],d[3]]),ze=t=>(x.setInt32(0,t,!1),[d[0],d[1],d[2],d[3]]),A=t=>(x.setUint32(0,Math.floor(t/2**32),!1),x.setUint32(4,t,!1),[d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7]]),oe=t=>(x.setInt16(0,2**8*t,!1),[d[0],d[1]]),g=t=>(x.setInt32(0,2**16*t,!1),[d[0],d[1],d[2],d[3]]),se=t=>(x.setInt32(0,2**30*t,!1),[d[0],d[1],d[2],d[3]]),C=(t,r=!1)=>{let e=Array(t.length).fill(null).map((s,o)=>t.charCodeAt(o));return r&&e.push(0),e},ie=t=>{let r=null;for(let e of t)(!r||e.presentationTimestamp>r.presentationTimestamp)&&(r=e);return r},me=t=>{let r=t*(Math.PI/180),e=Math.cos(r),s=Math.sin(r);return[e,s,0,-s,e,0,0,0,1]},he=me(0),pe=t=>[g(t[0]),g(t[1]),se(t[2]),g(t[3]),g(t[4]),se(t[5]),g(t[6]),g(t[7]),se(t[8])],p=(t,r,e)=>({type:t,contents:r&&new Uint8Array(r.flat(10)),children:e}),h=(t,r,e,s,o)=>p(t,[b(r),fe(e),s??[]],o),be=t=>{let r=512;return t.fragmented?p("ftyp",[C("iso5"),n(r),C("iso5"),C("iso6"),C("mp41")]):p("ftyp",[C("isom"),n(r),C("isom"),t.holdsAvc?C("avc1"):[],C("mp41")])},q=t=>({type:"mdat",largeSize:t}),Ce=t=>({type:"free",size:t}),U=(t,r,e=!1)=>p("moov",void 0,[Ie(r,t),...t.map(s=>Ee(s,r)),e?rt(t):null]),Ie=(t,r)=>{let e=T(Math.max(0,...r.filter(a=>a.samples.length>0).map(a=>{let l=ie(a.samples);return l.presentationTimestamp+l.duration})),j),s=Math.max(...r.map(a=>a.track.id))+1,o=!w(t)||!w(e),i=o?A:n;return h("mvhd",+o,0,[i(t),i(t),n(j),i(e),g(1),oe(1),Array(10).fill(0),pe(he),Array(24).fill(0),n(s)])},Ee=(t,r)=>p("trak",void 0,[Me(t,r),Ve(t,r)]),Me=(t,r)=>{let e=ie(t.samples),s=T(e?e.presentationTimestamp+e.duration:0,j),o=!w(r)||!w(s),i=o?A:n,a;if(t.type==="video"){let l=t.track.source.metadata.rotation;a=l===void 0||typeof l=="number"?me(l??0):l}else a=he;return h("tkhd",+o,3,[i(r),i(r),n(t.track.id),n(0),i(s),Array(8).fill(0),m(0),m(0),oe(t.type==="audio"?1:0),m(0),pe(a),g(t.type==="video"?t.info.width:0),g(t.type==="video"?t.info.height:0)])},Ve=(t,r)=>p("mdia",void 0,[Be(t,r),Ue(t.type==="video"?"vide":"soun"),Fe(t)]),Be=(t,r)=>{let e=ie(t.samples),s=T(e?e.presentationTimestamp+e.duration:0,t.timescale),o=!w(r)||!w(s),i=o?A:n;return h("mdhd",+o,0,[i(r),i(r),n(t.timescale),i(s),m(21956),m(0)])},Ue=t=>h("hdlr",0,0,[C("mhlr"),C(t),n(0),n(0),n(0),C("mp4-muxer-hdlr",!0)]),Fe=t=>p("minf",void 0,[t.type==="video"?Pe():Ne(),Le(),De(t)]),Pe=()=>h("vmhd",0,1,[m(0),m(0),m(0),m(0)]),Ne=()=>h("smhd",0,0,[m(0),m(0)]),Le=()=>p("dinf",void 0,[_e()]),_e=()=>h("dref",0,0,[n(1)],[We()]),We=()=>h("url ",0,1),De=t=>{let r=t.compositionTimeOffsetTable.length>1||t.compositionTimeOffsetTable.some(e=>e.sampleCompositionTimeOffset!==0);return p("stbl",void 0,[Re(t),Ge(t),Ye(t),Ze(t),Je(t),et(t),r?tt(t):null])},Re=t=>h("stsd",0,0,[n(1)],[t.type==="video"?$e(ct[t.track.source.codec],t):qe(mt[t.track.source.codec],t)]),$e=(t,r)=>p(t,[Array(6).fill(0),m(1),m(0),m(0),Array(12).fill(0),m(r.info.width),m(r.info.height),n(4718592),n(4718592),n(0),m(1),Array(32).fill(0),m(24),Oe(65535)],[ft[r.track.source.codec](r)]),He=t=>t.info.decoderConfig&&p("avcC",[...new Uint8Array(t.info.decoderConfig.description)]),Qe=t=>t.info.decoderConfig&&p("hvcC",[...new Uint8Array(t.info.decoderConfig.description)]),ce=t=>{if(!t.info.decoderConfig)return null;let r=t.info.decoderConfig;if(!r.colorSpace)throw new Error("'colorSpace' is required in the decoder config for VP8/VP9.");let e=r.codec.split("."),s=Number(e[1]),o=Number(e[2]),l=(Number(e[3])<<4)+(0<<1)+Number(r.colorSpace.fullRange);return h("vpcC",1,0,[b(s),b(o),b(l),b(2),b(2),b(2),m(0)])},je=()=>{let e=(1<<7)+1;return p("av1C",[e,0,0,0])},qe=(t,r)=>p(t,[Array(6).fill(0),m(1),m(0),m(0),n(0),m(r.info.numberOfChannels),m(16),m(0),m(0),g(r.info.sampleRate)],[ht[r.track.source.codec](r)]),Ke=t=>{let r=new Uint8Array(t.info.decoderConfig.description);return h("esds",0,0,[n(58753152),b(32+r.byteLength),m(1),b(0),n(75530368),b(18+r.byteLength),b(64),b(21),fe(0),n(130071),n(130071),n(92307584),b(r.byteLength),...r,n(109084800),b(1),b(2)])},Xe=t=>{let r=3840,e=0,s=t.info.decoderConfig?.description;if(s){if(s.byteLength<18)throw new TypeError("Invalid decoder description provided for Opus; must be at least 18 bytes long.");let o=ArrayBuffer.isView(s)?new DataView(s.buffer,s.byteOffset,s.byteLength):new DataView(s);r=o.getUint16(10,!0),e=o.getInt16(14,!0)}return p("dOps",[b(0),b(t.info.numberOfChannels),m(r),n(t.info.sampleRate),oe(e),b(0)])},Ge=t=>h("stts",0,0,[n(t.timeToSampleTable.length),t.timeToSampleTable.map(r=>[n(r.sampleCount),n(r.sampleDelta)])]),Ye=t=>{if(t.samples.every(e=>e.type==="key"))return null;let r=[...t.samples.entries()].filter(([,e])=>e.type==="key");return h("stss",0,0,[n(r.length),r.map(([e])=>n(e+1))])},Ze=t=>h("stsc",0,0,[n(t.compactlyCodedChunkTable.length),t.compactlyCodedChunkTable.map(r=>[n(r.firstChunk),n(r.samplesPerChunk),n(1)])]),Je=t=>h("stsz",0,0,[n(0),n(t.samples.length),t.samples.map(r=>n(r.size))]),et=t=>t.finalizedChunks.length>0&&z(t.finalizedChunks).offset>=2**32?h("co64",0,0,[n(t.finalizedChunks.length),t.finalizedChunks.map(r=>A(r.offset))]):h("stco",0,0,[n(t.finalizedChunks.length),t.finalizedChunks.map(r=>n(r.offset))]),tt=t=>h("ctts",0,0,[n(t.compositionTimeOffsetTable.length),t.compositionTimeOffsetTable.map(r=>[n(r.sampleCount),n(r.sampleCompositionTimeOffset)])]),rt=t=>p("mvex",void 0,t.map(st)),st=t=>h("trex",0,0,[n(t.track.id),n(1),n(0),n(0),n(0)]),ne=(t,r)=>p("moof",void 0,[ot(t),...r.map(it)]),ot=t=>h("mfhd",0,0,[n(t)]),Te=t=>{let r=0,e=0,s=0,o=0,i=t.type==="delta";return e|=+i,i?r|=1:r|=2,r<<24|e<<16|s<<8|o},it=t=>p("traf",void 0,[nt(t),at(t),ut(t)]),nt=t=>{c(t.currentChunk);let r=0;r|=8,r|=16,r|=32,r|=131072;let e=t.currentChunk.samples[1]??t.currentChunk.samples[0],s={duration:e.timescaleUnitsToNextSample,size:e.size,flags:Te(e)};return h("tfhd",0,r,[n(t.track.id),n(s.duration),n(s.size),n(s.flags)])},at=t=>(c(t.currentChunk),h("tfdt",1,0,[A(T(t.currentChunk.startTimestamp,t.timescale))])),ut=t=>{c(t.currentChunk);let r=t.currentChunk.samples.map(k=>k.timescaleUnitsToNextSample),e=t.currentChunk.samples.map(k=>k.size),s=t.currentChunk.samples.map(Te),o=t.currentChunk.samples.map(k=>T(k.presentationTimestamp-k.decodeTimestamp,t.timescale)),i=new Set(r),a=new Set(e),l=new Set(s),u=new Set(o),f=l.size===2&&s[0]!==s[1],y=i.size>1,E=a.size>1,N=!f&&l.size>1,ue=u.size>1||[...u].some(k=>k!==0),S=0;return S|=1,S|=4*+f,S|=256*+y,S|=512*+E,S|=1024*+N,S|=2048*+ue,h("trun",1,S,[n(t.currentChunk.samples.length),n(t.currentChunk.offset-t.currentChunk.moofOffset||0),f?n(s[0]):[],t.currentChunk.samples.map((k,L)=>[y?n(r[L]):[],E?n(e[L]):[],N?n(s[L]):[],ue?ze(o[L]):[]])])},ge=t=>p("mfra",void 0,[...t.map(lt),dt()]),lt=(t,r)=>h("tfra",1,0,[n(t.track.id),n(63),n(t.finalizedChunks.length),t.finalizedChunks.map(s=>[A(T(s.startTimestamp,t.timescale)),A(s.moofOffset),n(r+1),n(1),n(1)])]),dt=()=>h("mfro",0,0,[n(0)]),ct={avc:"avc1",hevc:"hvc1",vp8:"vp08",vp9:"vp09",av1:"av01"},ft={avc:He,hevc:Qe,vp8:ce,vp9:ce,av1:je},mt={aac:"mp4a",opus:"Opus"},ht={aac:Ke,opus:Xe};var K=class{constructor(r){this.output=r}};var j=1e3,pt=2082844800,T=(t,r,e=!0)=>{let s=t*r;return e?Math.round(s):s},X=class extends K{constructor(e,s){super(e);this.#r=new Uint8Array(8);this.#o=new DataView(this.#r.buffer);this.offsets=new WeakMap;this.#n=null;this.#i=null;this.#s=[];this.#a=Math.floor(Date.now()/1e3)+pt;this.#u=[];this.#h=1;this.#e=e.writer,this.#t=s}#e;#t;#r;#o;#n;#i;#s;#a;#u;#h;writeU32(e){this.#o.setUint32(0,e,!1),this.#e.write(this.#r.subarray(0,4))}writeU64(e){this.#o.setUint32(0,Math.floor(e/2**32),!1),this.#o.setUint32(4,e,!1),this.#e.write(this.#r.subarray(0,8))}writeAscii(e){for(let s=0;ss.type==="video"&&s.source.codec==="avc");if(this.writeBox(be({holdsAvc:e,fragmented:this.#t.options.fastStart==="fragmented"})),this.#n=this.#e.getPos(),this.#t.options.fastStart==="in-memory")this.#i=q(!1);else if(this.#t.options.fastStart!=="fragmented"){if(typeof this.#t.options.fastStart=="object"){let s=this.#b();this.#e.seek(this.#e.getPos()+s)}this.#i=q(!0),this.writeBox(this.#i)}this.#e.flush()}#b(){c(typeof this.#t.options.fastStart=="object");let e=0,s=[this.#t.options.fastStart.expectedVideoChunks,this.#t.options.fastStart.expectedAudioChunks];for(let o of s)o&&(e+=8*Math.ceil(2/3*o),e+=4*o,e+=12*Math.ceil(2/3*o),e+=4*o,e+=8*o);return e+=4096,e}#C(e,s,o){let i=this.#s.find(l=>l.track===e);if(i)return i;c(o),c(o.decoderConfig),c(o.decoderConfig.codedWidth),c(o.decoderConfig.codedHeight);let a={track:e,type:"video",info:{width:o.decoderConfig.codedWidth,height:o.decoderConfig.codedHeight,decoderConfig:o.decoderConfig},timescale:e.source.metadata.frameRate??57600,samples:[],sampleQueue:[],firstDecodeTimestamp:null,lastDecodeTimestamp:-1,timeToSampleTable:[],compositionTimeOffsetTable:[],lastTimescaleUnits:null,lastSample:null,finalizedChunks:[],currentChunk:null,compactlyCodedChunkTable:[]};return this.#s.push(a),this.#s.sort((l,u)=>l.track.id-u.track.id),a}#T(e,s,o){let i=this.#s.find(l=>l.track===e);if(i)return i;c(o),c(o.decoderConfig);let a={track:e,type:"audio",info:{numberOfChannels:o.decoderConfig.numberOfChannels,sampleRate:o.decoderConfig.sampleRate,decoderConfig:o.decoderConfig},timescale:o.decoderConfig.sampleRate,samples:[],sampleQueue:[],firstDecodeTimestamp:null,lastDecodeTimestamp:-1,timeToSampleTable:[],compositionTimeOffsetTable:[],lastTimescaleUnits:null,lastSample:null,finalizedChunks:[],currentChunk:null,compactlyCodedChunkTable:[]};return this.#s.push(a),this.#s.sort((l,u)=>l.track.id-u.track.id),a}addEncodedVideoChunk(e,s,o,i){let a=this.#C(e,s,o);if(typeof this.#t.options.fastStart=="object"&&a.samples.length===this.#t.options.fastStart.expectedVideoChunks)throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${this.#t.options.fastStart.expectedVideoChunks}).`);let l=this.#d(a,s,i);this.#t.options.fastStart==="fragmented"?(a.sampleQueue.push(l),this.#f()):this.#l(a,l)}addEncodedAudioChunk(e,s,o){let i=this.#T(e,s,o);if(typeof this.#t.options.fastStart=="object"&&i.samples.length===this.#t.options.fastStart.expectedAudioChunks)throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${this.#t.options.fastStart.expectedAudioChunks}).`);let a=this.#d(i,s);this.#t.options.fastStart==="fragmented"?(i.sampleQueue.push(a),this.#f()):this.#l(i,a)}#d(e,s,o){let i=s.timestamp/1e6,a=(s.timestamp-(o??0))/1e6,l=(s.duration??0)/1e6,u=this.#g(e,i,a);i=u.presentationTimestamp,a=u.decodeTimestamp;let f=new Uint8Array(s.byteLength);return s.copyTo(f),{presentationTimestamp:i,decodeTimestamp:a,duration:l,data:f,size:f.byteLength,type:s.type,timescaleUnitsToNextSample:T(l,e.timescale)}}#l(e,s){this.#t.options.fastStart!=="fragmented"&&e.samples.push(s);let o=T(s.presentationTimestamp-s.decodeTimestamp,e.timescale);if(e.lastTimescaleUnits!==null){c(e.lastSample);let a=T(s.decodeTimestamp,e.timescale,!1),l=Math.round(a-e.lastTimescaleUnits);if(e.lastTimescaleUnits+=l,e.lastSample.timescaleUnitsToNextSample=l,this.#t.options.fastStart!=="fragmented"){let u=z(e.timeToSampleTable);c(u),u.sampleCount===1?(u.sampleDelta=l,u.sampleCount++):u.sampleDelta===l?u.sampleCount++:(u.sampleCount--,e.timeToSampleTable.push({sampleCount:2,sampleDelta:l}));let f=z(e.compositionTimeOffsetTable);c(f),f.sampleCompositionTimeOffset===o?f.sampleCount++:e.compositionTimeOffsetTable.push({sampleCount:1,sampleCompositionTimeOffset:o})}}else e.lastTimescaleUnits=0,this.#t.options.fastStart!=="fragmented"&&(e.timeToSampleTable.push({sampleCount:1,sampleDelta:T(s.duration,e.timescale)}),e.compositionTimeOffsetTable.push({sampleCount:1,sampleCompositionTimeOffset:o}));e.lastSample=s;let i=!1;if(!e.currentChunk)i=!0;else{let a=s.presentationTimestamp-e.currentChunk.startTimestamp;if(this.#t.options.fastStart==="fragmented"){let l=this.#s.every(u=>{if(e===u)return s.type==="key";let f=u.sampleQueue[0];return f&&f.type==="key"});a>=1&&l&&(i=!0,this.#m())}else i=a>=.5}i&&(e.currentChunk&&this.#c(e),e.currentChunk={startTimestamp:s.presentationTimestamp,samples:[],offset:null,moofOffset:null}),c(e.currentChunk),e.currentChunk.samples.push(s)}#g(e,s,o){if(e.firstDecodeTimestamp===null&&(e.firstDecodeTimestamp=o),o-=e.firstDecodeTimestamp,s-=e.firstDecodeTimestamp,o=2**32&&(u.largeSize=!0,y=this.measureBox(u)+f),u.size=y,this.writeBox(u)}for(let u of this.#s){u.currentChunk.offset=this.#e.getPos(),u.currentChunk.moofOffset=o;for(let f of u.currentChunk.samples)this.#e.write(f.data),f.data=null}let a=this.#e.getPos();this.#e.seek(this.offsets.get(i));let l=ne(s,this.#s);this.writeBox(l),this.#e.seek(a);for(let u of this.#s)u.finalizedChunks.push(u.currentChunk),this.#u.push(u.currentChunk),u.currentChunk=null;e&&this.#e.flush()}finalize(){if(this.#t.options.fastStart==="fragmented"){for(let e of this.#s)for(let s of e.sampleQueue)this.#l(e,s);this.#m(!1)}else for(let e of this.#s)this.#c(e);if(this.#t.options.fastStart==="in-memory"){c(this.#i);let e;for(let o=0;o<2;o++){let i=U(this.#s,this.#a),a=this.measureBox(i);e=this.measureBox(this.#i);let l=this.#e.getPos()+a+e;for(let u of this.#u){u.offset=l;for(let{data:f}of u.samples)c(f),l+=f.byteLength,e+=f.byteLength}if(l<2**32)break;e>=2**32&&(this.#i.largeSize=!0)}let s=U(this.#s,this.#a);this.writeBox(s),this.#i.size=e,this.writeBox(this.#i);for(let o of this.#u)for(let i of o.samples)c(i.data),this.#e.write(i.data),i.data=null}else if(this.#t.options.fastStart==="fragmented"){let e=this.#e.getPos(),s=ge(this.#s);this.writeBox(s);let o=this.#e.getPos()-e;this.#e.seek(this.#e.getPos()-4),this.writeU32(o)}else{c(this.#i),c(this.#n!==null);let e=this.offsets.get(this.#i);c(e!==void 0);let s=this.#e.getPos()-e;this.#i.size=s,this.#i.largeSize=s>=2**32,this.patchBox(this.#i);let o=U(this.#s,this.#a);if(typeof this.#t.options.fastStart=="object"){this.#e.seek(this.#n),this.writeBox(o);let i=e-this.#e.getPos();this.writeBox(Ce(i))}else this.writeBox(o)}}};var ae=class{},G=class extends ae{constructor(e){super();this.options=e}createMuxer(e){return new X(e,this)}};var F=class{},Y=class extends F{#e=0;#t;#r=new ArrayBuffer(2**16);#o=new Uint8Array(this.#r);#n=0;constructor(r){super(),this.#t=r}#i(r){let e=this.#r.byteLength;for(;es.start-o.start);r.push({start:e[0].start,size:e[0].data.byteLength});for(let s=1;su.start<=e&&eCt){for(let u=0;u=r.written[i+1].start;)r.written[i].end=Math.max(r.written[i].end,r.written[i+1].end),r.written.splice(i+1,1)}#s(r){let s={start:Math.floor(r/this.#r)*this.#r,data:new Uint8Array(this.#r),written:[],shouldFlush:!1};return this.#o.push(s),this.#o.sort((o,i)=>o.start-i.start),this.#o.indexOf(s)}#a(r=!1){for(let e=0;er.stream.write({type:"write",data:e,position:s}),chunkSize:r.options?.chunkSize}))}};var Tt=Symbol("isTarget");Tt;var O=class{},ee=class extends O{constructor(){super(...arguments);this.buffer=null}createWriter(){return new Y(this)}},I=class extends O{constructor(e){super();this.options=e;if(typeof e!="object")throw new TypeError("StreamTarget requires an options object to be passed to its constructor.");if(e.onData){if(typeof e.onData!="function")throw new TypeError("options.onData, when provided, must be a function.");if(e.onData.length<2)throw new TypeError("options.onData, when provided, must be a function that takes in at least two arguments (data and position). Ignoring the position argument, which specifies the byte offset at which the data is to be written, can lead to broken outputs.")}if(e.chunked!==void 0&&typeof e.chunked!="boolean")throw new TypeError("options.chunked, when provided, must be a boolean.");if(e.chunkSize!==void 0&&(!Number.isInteger(e.chunkSize)||e.chunkSize<=0))throw new TypeError("options.chunkSize, when provided, must be a positive integer.")}createWriter(){return this.options.chunked?new P(this):new Z(this)}},te=class extends O{constructor(e,s){super();this.stream=e;this.options=s;if(!(e instanceof FileSystemWritableFileStream))throw new TypeError("FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance.");if(s!==void 0&&typeof s!="object")throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object.");if(s&&s.chunkSize!==void 0&&(!Number.isInteger(s.chunkSize)||s.chunkSize<=0))throw new TypeError("options.chunkSize, when provided, must be a positive integer")}createWriter(){return new J(this)}};return ve(gt);})(); if (typeof module === "object" && typeof module.exports === "object") Object.assign(module.exports, Metamuxer) diff --git a/dist/metamuxer.min.mjs b/dist/metamuxer.min.mjs index d914c60..4b5899d 100644 --- a/dist/metamuxer.min.mjs +++ b/dist/metamuxer.min.mjs @@ -1 +1 @@ -console.log("hi"); +var ue=(t,r,e)=>{if(t==="avc"){let s=100;r<=768&&e<=432?s=66:r<=1920&&e<=1080&&(s=77);let o=0,i=r>1920||e>1080?50:41,a=s.toString(16).padStart(2,"0"),l=o.toString(16).padStart(2,"0"),u=i.toString(16).padStart(2,"0");return`avc1.${a}${l}${u}`}else if(t==="hevc"){let s=0,o=1,i=Array(32).fill(0);i[o]=1;let a=parseInt(i.reverse().join(""),2).toString(16).replace(/^0+/,""),l="L",u=120;return r<=1280&&e<=720?u=93:r<=1920&&e<=1080?u=120:r<=3840&&e<=2160?u=150:(l="H",u=180),`hev1.${s===0?"":String.fromCharCode(65+s-1)}${o}.${a}.${l}${u}.B0`}else{if(t==="vp8")return"vp8";if(t==="vp9"){let s="00",o;return r<=854&&e<=480?o="21":r<=1280&&e<=720?o="31":r<=1920&&e<=1080?o="41":r<=3840&&e<=2160?o="51":o="61",`vp09.${s}.${o}.08`}else if(t==="av1"){let o;return r<=854&&e<=480?o="01":r<=1280&&e<=720?o="03":r<=1920&&e<=1080?o="04":r<=3840&&e<=2160?o="07":o="09",`av01.0.${o}M.08`}}throw new Error(`Unhandled codec '${t}'.`)},le=(t,r,e)=>{if(t==="aac")return r>=2&&e<=24e3?"mp4a.40.29":e<=24e3?"mp4a.40.5":"mp4a.40.2";if(t==="opus")return"opus";if(t==="vorbis")return"vorbis";throw new Error(`Unhandled codec '${t}'.`)};function c(t){if(!t)throw new Error("Assertion failed.")}var O=t=>t&&t[t.length-1],w=t=>t>=0&&t<2**32;var v=class{constructor(r,e){this.connectedTrack=null;this.codec=r,this.metadata=e}ensureNotFinalizing(){if(this.connectedTrack?.output.finalizing)throw new Error("Cannot call digest after output has started finalizing.")}start(){}async flush(){}},E=class{constructor(r,e){this.connectedTrack=null;this.codec=r,this.metadata=e}ensureNotFinalizing(){if(this.connectedTrack?.output.finalizing)throw new Error("Cannot call digest after output has started finalizing.")}start(){}async flush(){}};var ge=5,M=class{constructor(r,e){this.source=r;this.codecConfig=e;this.encoder=null;this.lastMultipleOfKeyFrameInterval=-1}digest(r){this.source.ensureNotFinalizing(),this.ensureEncoder(r),c(this.encoder);let e=Math.floor(r.timestamp/1e6/ge);this.encoder.encode(r,{keyFrame:e!==this.lastMultipleOfKeyFrameInterval}),this.lastMultipleOfKeyFrameInterval=e}ensureEncoder(r){this.encoder||(this.encoder=new VideoEncoder({output:(e,s)=>this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack,e,s),error:e=>console.error(e)}),this.encoder.configure({codec:ue(this.codecConfig.codec,r.codedWidth,r.codedHeight),width:r.codedWidth,height:r.codedHeight,bitrate:this.codecConfig.bitrate}))}async flush(){return this.encoder?.flush()}},j=class extends v{constructor(r,e={}){super(r.codec,e),this.encoder=new M(this,r)}digest(r){this.encoder.digest(r)}flush(){return this.encoder.flush()}},q=class extends v{constructor(e,s,o={}){super(s.codec,o);this.canvas=e;this.encoder=new M(this,s)}digest(e,s=0){let o=new VideoFrame(this.canvas,{timestamp:Math.round(1e6*e),duration:Math.round(1e6*s)});this.encoder.digest(o),o.close()}flush(){return this.encoder.flush()}},K=class extends v{constructor(e,s,o={}){super(s.codec,o);this.track=e;this.abortController=null;this.encoder=new M(this,s)}start(){this.abortController=new AbortController;let e=new MediaStreamTrackProcessor({track:this.track}),s=new WritableStream({write:o=>{this.encoder.digest(o),o.close()}});e.readable.pipeTo(s,{signal:this.abortController.signal}).catch(o=>{o instanceof DOMException&&o.name==="AbortError"||console.error("Pipe error:",o)})}async flush(){this.abortController&&(this.abortController.abort(),this.abortController=null),await this.encoder.flush()}};var V=class{constructor(r,e){this.source=r;this.codecConfig=e;this.encoder=null}digest(r){this.source.ensureNotFinalizing(),this.ensureEncoder(r),c(this.encoder),this.encoder.encode(r)}ensureEncoder(r){this.encoder||(this.encoder=new AudioEncoder({output:(e,s)=>this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack,e,s),error:e=>console.error(e)}),this.encoder.configure({codec:le(this.codecConfig.codec,r.numberOfChannels,r.sampleRate),numberOfChannels:r.numberOfChannels,sampleRate:r.sampleRate,bitrate:this.codecConfig.bitrate}))}async flush(){return this.encoder?.flush()}},X=class extends E{constructor(r,e={}){super(r.codec,e),this.encoder=new V(this,r)}digest(r){this.encoder.digest(r)}flush(){return this.encoder.flush()}},G=class extends E{constructor(e,s={}){super(e.codec,s);this.accumulatedFrameCount=0;this.encoder=new V(this,e)}digest(e){let s=e.numberOfChannels,o=e.sampleRate,i=e.length,a=new Float32Array(s*i);for(let u=0;u{this.encoder.digest(o),o.close()}});e.readable.pipeTo(s,{signal:this.abortController.signal}).catch(o=>{o instanceof DOMException&&o.name==="AbortError"||console.error("Pipe error:",o)})}async flush(){this.abortController&&(this.abortController.abort(),this.abortController=null),await this.encoder.flush()}};var Z=class{constructor(r){this.tracks=[];this.started=!1;this.finalizing=!1;this.writer=r.target.createWriter(),this.muxer=r.format.createMuxer(this)}addTrack(r){if(this.started)throw new Error("Cannot add track after output has started.");if(r.connectedTrack)throw new Error("Source is already used for a track.");let e={id:this.tracks.length+1,output:this,type:r instanceof v?"video":"audio",source:r};this.tracks.push(e),r.connectedTrack=e}start(){if(this.started)throw new Error("Output already started.");this.started=!0,this.muxer.start();for(let r of this.tracks)r.source.start()}async finalize(){if(this.finalizing)throw new Error("Cannot call finalize twice.");this.finalizing=!0;let r=this.tracks.map(e=>e.source.flush());await Promise.all(r),this.muxer.finalize(),this.writer.flush(),this.writer.finalize()}};var d=new Uint8Array(8),x=new DataView(d.buffer),b=t=>[(t%256+256)%256],m=t=>(x.setUint16(0,t,!1),[d[0],d[1]]),xe=t=>(x.setInt16(0,t,!1),[d[0],d[1]]),ce=t=>(x.setUint32(0,t,!1),[d[1],d[2],d[3]]),n=t=>(x.setUint32(0,t,!1),[d[0],d[1],d[2],d[3]]),ye=t=>(x.setInt32(0,t,!1),[d[0],d[1],d[2],d[3]]),A=t=>(x.setUint32(0,Math.floor(t/2**32),!1),x.setUint32(4,t,!1),[d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7]]),ee=t=>(x.setInt16(0,2**8*t,!1),[d[0],d[1]]),g=t=>(x.setInt32(0,2**16*t,!1),[d[0],d[1],d[2],d[3]]),J=t=>(x.setInt32(0,2**30*t,!1),[d[0],d[1],d[2],d[3]]),C=(t,r=!1)=>{let e=Array(t.length).fill(null).map((s,o)=>t.charCodeAt(o));return r&&e.push(0),e},te=t=>{let r=null;for(let e of t)(!r||e.presentationTimestamp>r.presentationTimestamp)&&(r=e);return r},fe=t=>{let r=t*(Math.PI/180),e=Math.cos(r),s=Math.sin(r);return[e,s,0,-s,e,0,0,0,1]},me=fe(0),he=t=>[g(t[0]),g(t[1]),J(t[2]),g(t[3]),g(t[4]),J(t[5]),g(t[6]),g(t[7]),J(t[8])],p=(t,r,e)=>({type:t,contents:r&&new Uint8Array(r.flat(10)),children:e}),h=(t,r,e,s,o)=>p(t,[b(r),ce(e),s??[]],o),pe=t=>{let r=512;return t.fragmented?p("ftyp",[C("iso5"),n(r),C("iso5"),C("iso6"),C("mp41")]):p("ftyp",[C("isom"),n(r),C("isom"),t.holdsAvc?C("avc1"):[],C("mp41")])},W=t=>({type:"mdat",largeSize:t}),be=t=>({type:"free",size:t}),B=(t,r,e=!1)=>p("moov",void 0,[ke(r,t),...t.map(s=>Se(s,r)),e?Xe(t):null]),ke=(t,r)=>{let e=T(Math.max(0,...r.filter(a=>a.samples.length>0).map(a=>{let l=te(a.samples);return l.presentationTimestamp+l.duration})),_),s=Math.max(...r.map(a=>a.track.id))+1,o=!w(t)||!w(e),i=o?A:n;return h("mvhd",+o,0,[i(t),i(t),n(_),i(e),g(1),ee(1),Array(10).fill(0),he(me),Array(24).fill(0),n(s)])},Se=(t,r)=>p("trak",void 0,[we(t,r),ve(t,r)]),we=(t,r)=>{let e=te(t.samples),s=T(e?e.presentationTimestamp+e.duration:0,_),o=!w(r)||!w(s),i=o?A:n,a;if(t.type==="video"){let l=t.track.source.metadata.rotation;a=l===void 0||typeof l=="number"?fe(l??0):l}else a=me;return h("tkhd",+o,3,[i(r),i(r),n(t.track.id),n(0),i(s),Array(8).fill(0),m(0),m(0),ee(t.type==="audio"?1:0),m(0),he(a),g(t.type==="video"?t.info.width:0),g(t.type==="video"?t.info.height:0)])},ve=(t,r)=>p("mdia",void 0,[Ae(t,r),Oe(t.type==="video"?"vide":"soun"),ze(t)]),Ae=(t,r)=>{let e=te(t.samples),s=T(e?e.presentationTimestamp+e.duration:0,t.timescale),o=!w(r)||!w(s),i=o?A:n;return h("mdhd",+o,0,[i(r),i(r),n(t.timescale),i(s),m(21956),m(0)])},Oe=t=>h("hdlr",0,0,[C("mhlr"),C(t),n(0),n(0),n(0),C("mp4-muxer-hdlr",!0)]),ze=t=>p("minf",void 0,[t.type==="video"?Ie():Ee(),Me(),Ue(t)]),Ie=()=>h("vmhd",0,1,[m(0),m(0),m(0),m(0)]),Ee=()=>h("smhd",0,0,[m(0),m(0)]),Me=()=>p("dinf",void 0,[Ve()]),Ve=()=>h("dref",0,0,[n(1)],[Be()]),Be=()=>h("url ",0,1),Ue=t=>{let r=t.compositionTimeOffsetTable.length>1||t.compositionTimeOffsetTable.some(e=>e.sampleCompositionTimeOffset!==0);return p("stbl",void 0,[Fe(t),$e(t),He(t),Qe(t),je(t),qe(t),r?Ke(t):null])},Fe=t=>h("stsd",0,0,[n(1)],[t.type==="video"?Pe(ot[t.track.source.codec],t):We(nt[t.track.source.codec],t)]),Pe=(t,r)=>p(t,[Array(6).fill(0),m(1),m(0),m(0),Array(12).fill(0),m(r.info.width),m(r.info.height),n(4718592),n(4718592),n(0),m(1),Array(32).fill(0),m(24),xe(65535)],[it[r.track.source.codec](r)]),Ne=t=>t.info.decoderConfig&&p("avcC",[...new Uint8Array(t.info.decoderConfig.description)]),Le=t=>t.info.decoderConfig&&p("hvcC",[...new Uint8Array(t.info.decoderConfig.description)]),de=t=>{if(!t.info.decoderConfig)return null;let r=t.info.decoderConfig;if(!r.colorSpace)throw new Error("'colorSpace' is required in the decoder config for VP8/VP9.");let e=r.codec.split("."),s=Number(e[1]),o=Number(e[2]),l=(Number(e[3])<<4)+(0<<1)+Number(r.colorSpace.fullRange);return h("vpcC",1,0,[b(s),b(o),b(l),b(2),b(2),b(2),m(0)])},_e=()=>{let e=(1<<7)+1;return p("av1C",[e,0,0,0])},We=(t,r)=>p(t,[Array(6).fill(0),m(1),m(0),m(0),n(0),m(r.info.numberOfChannels),m(16),m(0),m(0),g(r.info.sampleRate)],[at[r.track.source.codec](r)]),De=t=>{let r=new Uint8Array(t.info.decoderConfig.description);return h("esds",0,0,[n(58753152),b(32+r.byteLength),m(1),b(0),n(75530368),b(18+r.byteLength),b(64),b(21),ce(0),n(130071),n(130071),n(92307584),b(r.byteLength),...r,n(109084800),b(1),b(2)])},Re=t=>{let r=3840,e=0,s=t.info.decoderConfig?.description;if(s){if(s.byteLength<18)throw new TypeError("Invalid decoder description provided for Opus; must be at least 18 bytes long.");let o=ArrayBuffer.isView(s)?new DataView(s.buffer,s.byteOffset,s.byteLength):new DataView(s);r=o.getUint16(10,!0),e=o.getInt16(14,!0)}return p("dOps",[b(0),b(t.info.numberOfChannels),m(r),n(t.info.sampleRate),ee(e),b(0)])},$e=t=>h("stts",0,0,[n(t.timeToSampleTable.length),t.timeToSampleTable.map(r=>[n(r.sampleCount),n(r.sampleDelta)])]),He=t=>{if(t.samples.every(e=>e.type==="key"))return null;let r=[...t.samples.entries()].filter(([,e])=>e.type==="key");return h("stss",0,0,[n(r.length),r.map(([e])=>n(e+1))])},Qe=t=>h("stsc",0,0,[n(t.compactlyCodedChunkTable.length),t.compactlyCodedChunkTable.map(r=>[n(r.firstChunk),n(r.samplesPerChunk),n(1)])]),je=t=>h("stsz",0,0,[n(0),n(t.samples.length),t.samples.map(r=>n(r.size))]),qe=t=>t.finalizedChunks.length>0&&O(t.finalizedChunks).offset>=2**32?h("co64",0,0,[n(t.finalizedChunks.length),t.finalizedChunks.map(r=>A(r.offset))]):h("stco",0,0,[n(t.finalizedChunks.length),t.finalizedChunks.map(r=>n(r.offset))]),Ke=t=>h("ctts",0,0,[n(t.compositionTimeOffsetTable.length),t.compositionTimeOffsetTable.map(r=>[n(r.sampleCount),n(r.sampleCompositionTimeOffset)])]),Xe=t=>p("mvex",void 0,t.map(Ge)),Ge=t=>h("trex",0,0,[n(t.track.id),n(1),n(0),n(0),n(0)]),re=(t,r)=>p("moof",void 0,[Ye(t),...r.map(Ze)]),Ye=t=>h("mfhd",0,0,[n(t)]),Ce=t=>{let r=0,e=0,s=0,o=0,i=t.type==="delta";return e|=+i,i?r|=1:r|=2,r<<24|e<<16|s<<8|o},Ze=t=>p("traf",void 0,[Je(t),et(t),tt(t)]),Je=t=>{c(t.currentChunk);let r=0;r|=8,r|=16,r|=32,r|=131072;let e=t.currentChunk.samples[1]??t.currentChunk.samples[0],s={duration:e.timescaleUnitsToNextSample,size:e.size,flags:Ce(e)};return h("tfhd",0,r,[n(t.track.id),n(s.duration),n(s.size),n(s.flags)])},et=t=>(c(t.currentChunk),h("tfdt",1,0,[A(T(t.currentChunk.startTimestamp,t.timescale))])),tt=t=>{c(t.currentChunk);let r=t.currentChunk.samples.map(k=>k.timescaleUnitsToNextSample),e=t.currentChunk.samples.map(k=>k.size),s=t.currentChunk.samples.map(Ce),o=t.currentChunk.samples.map(k=>T(k.presentationTimestamp-k.decodeTimestamp,t.timescale)),i=new Set(r),a=new Set(e),l=new Set(s),u=new Set(o),f=l.size===2&&s[0]!==s[1],y=i.size>1,I=a.size>1,N=!f&&l.size>1,ae=u.size>1||[...u].some(k=>k!==0),S=0;return S|=1,S|=4*+f,S|=256*+y,S|=512*+I,S|=1024*+N,S|=2048*+ae,h("trun",1,S,[n(t.currentChunk.samples.length),n(t.currentChunk.offset-t.currentChunk.moofOffset||0),f?n(s[0]):[],t.currentChunk.samples.map((k,L)=>[y?n(r[L]):[],I?n(e[L]):[],N?n(s[L]):[],ae?ye(o[L]):[]])])},Te=t=>p("mfra",void 0,[...t.map(rt),st()]),rt=(t,r)=>h("tfra",1,0,[n(t.track.id),n(63),n(t.finalizedChunks.length),t.finalizedChunks.map(s=>[A(T(s.startTimestamp,t.timescale)),A(s.moofOffset),n(r+1),n(1),n(1)])]),st=()=>h("mfro",0,0,[n(0)]),ot={avc:"avc1",hevc:"hvc1",vp8:"vp08",vp9:"vp09",av1:"av01"},it={avc:Ne,hevc:Le,vp8:de,vp9:de,av1:_e},nt={aac:"mp4a",opus:"Opus"},at={aac:De,opus:Re};var D=class{constructor(r){this.output=r}};var _=1e3,ut=2082844800,T=(t,r,e=!0)=>{let s=t*r;return e?Math.round(s):s},R=class extends D{constructor(e,s){super(e);this.#r=new Uint8Array(8);this.#o=new DataView(this.#r.buffer);this.offsets=new WeakMap;this.#n=null;this.#i=null;this.#s=[];this.#a=Math.floor(Date.now()/1e3)+ut;this.#u=[];this.#h=1;this.#e=e.writer,this.#t=s}#e;#t;#r;#o;#n;#i;#s;#a;#u;#h;writeU32(e){this.#o.setUint32(0,e,!1),this.#e.write(this.#r.subarray(0,4))}writeU64(e){this.#o.setUint32(0,Math.floor(e/2**32),!1),this.#o.setUint32(4,e,!1),this.#e.write(this.#r.subarray(0,8))}writeAscii(e){for(let s=0;ss.type==="video"&&s.source.codec==="avc");if(this.writeBox(pe({holdsAvc:e,fragmented:this.#t.options.fastStart==="fragmented"})),this.#n=this.#e.getPos(),this.#t.options.fastStart==="in-memory")this.#i=W(!1);else if(this.#t.options.fastStart!=="fragmented"){if(typeof this.#t.options.fastStart=="object"){let s=this.#b();this.#e.seek(this.#e.getPos()+s)}this.#i=W(!0),this.writeBox(this.#i)}this.#e.flush()}#b(){c(typeof this.#t.options.fastStart=="object");let e=0,s=[this.#t.options.fastStart.expectedVideoChunks,this.#t.options.fastStart.expectedAudioChunks];for(let o of s)o&&(e+=8*Math.ceil(2/3*o),e+=4*o,e+=12*Math.ceil(2/3*o),e+=4*o,e+=8*o);return e+=4096,e}#C(e,s,o){let i=this.#s.find(l=>l.track===e);if(i)return i;c(o),c(o.decoderConfig),c(o.decoderConfig.codedWidth),c(o.decoderConfig.codedHeight);let a={track:e,type:"video",info:{width:o.decoderConfig.codedWidth,height:o.decoderConfig.codedHeight,decoderConfig:o.decoderConfig},timescale:e.source.metadata.frameRate??57600,samples:[],sampleQueue:[],firstDecodeTimestamp:null,lastDecodeTimestamp:-1,timeToSampleTable:[],compositionTimeOffsetTable:[],lastTimescaleUnits:null,lastSample:null,finalizedChunks:[],currentChunk:null,compactlyCodedChunkTable:[]};return this.#s.push(a),this.#s.sort((l,u)=>l.track.id-u.track.id),a}#T(e,s,o){let i=this.#s.find(l=>l.track===e);if(i)return i;c(o),c(o.decoderConfig);let a={track:e,type:"audio",info:{numberOfChannels:o.decoderConfig.numberOfChannels,sampleRate:o.decoderConfig.sampleRate,decoderConfig:o.decoderConfig},timescale:o.decoderConfig.sampleRate,samples:[],sampleQueue:[],firstDecodeTimestamp:null,lastDecodeTimestamp:-1,timeToSampleTable:[],compositionTimeOffsetTable:[],lastTimescaleUnits:null,lastSample:null,finalizedChunks:[],currentChunk:null,compactlyCodedChunkTable:[]};return this.#s.push(a),this.#s.sort((l,u)=>l.track.id-u.track.id),a}addEncodedVideoChunk(e,s,o,i){let a=this.#C(e,s,o);if(typeof this.#t.options.fastStart=="object"&&a.samples.length===this.#t.options.fastStart.expectedVideoChunks)throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${this.#t.options.fastStart.expectedVideoChunks}).`);let l=this.#d(a,s,i);this.#t.options.fastStart==="fragmented"?(a.sampleQueue.push(l),this.#f()):this.#l(a,l)}addEncodedAudioChunk(e,s,o){let i=this.#T(e,s,o);if(typeof this.#t.options.fastStart=="object"&&i.samples.length===this.#t.options.fastStart.expectedAudioChunks)throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${this.#t.options.fastStart.expectedAudioChunks}).`);let a=this.#d(i,s);this.#t.options.fastStart==="fragmented"?(i.sampleQueue.push(a),this.#f()):this.#l(i,a)}#d(e,s,o){let i=s.timestamp/1e6,a=(s.timestamp-(o??0))/1e6,l=(s.duration??0)/1e6,u=this.#g(e,i,a);i=u.presentationTimestamp,a=u.decodeTimestamp;let f=new Uint8Array(s.byteLength);return s.copyTo(f),{presentationTimestamp:i,decodeTimestamp:a,duration:l,data:f,size:f.byteLength,type:s.type,timescaleUnitsToNextSample:T(l,e.timescale)}}#l(e,s){this.#t.options.fastStart!=="fragmented"&&e.samples.push(s);let o=T(s.presentationTimestamp-s.decodeTimestamp,e.timescale);if(e.lastTimescaleUnits!==null){c(e.lastSample);let a=T(s.decodeTimestamp,e.timescale,!1),l=Math.round(a-e.lastTimescaleUnits);if(e.lastTimescaleUnits+=l,e.lastSample.timescaleUnitsToNextSample=l,this.#t.options.fastStart!=="fragmented"){let u=O(e.timeToSampleTable);c(u),u.sampleCount===1?(u.sampleDelta=l,u.sampleCount++):u.sampleDelta===l?u.sampleCount++:(u.sampleCount--,e.timeToSampleTable.push({sampleCount:2,sampleDelta:l}));let f=O(e.compositionTimeOffsetTable);c(f),f.sampleCompositionTimeOffset===o?f.sampleCount++:e.compositionTimeOffsetTable.push({sampleCount:1,sampleCompositionTimeOffset:o})}}else e.lastTimescaleUnits=0,this.#t.options.fastStart!=="fragmented"&&(e.timeToSampleTable.push({sampleCount:1,sampleDelta:T(s.duration,e.timescale)}),e.compositionTimeOffsetTable.push({sampleCount:1,sampleCompositionTimeOffset:o}));e.lastSample=s;let i=!1;if(!e.currentChunk)i=!0;else{let a=s.presentationTimestamp-e.currentChunk.startTimestamp;if(this.#t.options.fastStart==="fragmented"){let l=this.#s.every(u=>{if(e===u)return s.type==="key";let f=u.sampleQueue[0];return f&&f.type==="key"});a>=1&&l&&(i=!0,this.#m())}else i=a>=.5}i&&(e.currentChunk&&this.#c(e),e.currentChunk={startTimestamp:s.presentationTimestamp,samples:[],offset:null,moofOffset:null}),c(e.currentChunk),e.currentChunk.samples.push(s)}#g(e,s,o){if(e.firstDecodeTimestamp===null&&(e.firstDecodeTimestamp=o),o-=e.firstDecodeTimestamp,s-=e.firstDecodeTimestamp,o=2**32&&(u.largeSize=!0,y=this.measureBox(u)+f),u.size=y,this.writeBox(u)}for(let u of this.#s){u.currentChunk.offset=this.#e.getPos(),u.currentChunk.moofOffset=o;for(let f of u.currentChunk.samples)this.#e.write(f.data),f.data=null}let a=this.#e.getPos();this.#e.seek(this.offsets.get(i));let l=re(s,this.#s);this.writeBox(l),this.#e.seek(a);for(let u of this.#s)u.finalizedChunks.push(u.currentChunk),this.#u.push(u.currentChunk),u.currentChunk=null;e&&this.#e.flush()}finalize(){if(this.#t.options.fastStart==="fragmented"){for(let e of this.#s)for(let s of e.sampleQueue)this.#l(e,s);this.#m(!1)}else for(let e of this.#s)this.#c(e);if(this.#t.options.fastStart==="in-memory"){c(this.#i);let e;for(let o=0;o<2;o++){let i=B(this.#s,this.#a),a=this.measureBox(i);e=this.measureBox(this.#i);let l=this.#e.getPos()+a+e;for(let u of this.#u){u.offset=l;for(let{data:f}of u.samples)c(f),l+=f.byteLength,e+=f.byteLength}if(l<2**32)break;e>=2**32&&(this.#i.largeSize=!0)}let s=B(this.#s,this.#a);this.writeBox(s),this.#i.size=e,this.writeBox(this.#i);for(let o of this.#u)for(let i of o.samples)c(i.data),this.#e.write(i.data),i.data=null}else if(this.#t.options.fastStart==="fragmented"){let e=this.#e.getPos(),s=Te(this.#s);this.writeBox(s);let o=this.#e.getPos()-e;this.#e.seek(this.#e.getPos()-4),this.writeU32(o)}else{c(this.#i),c(this.#n!==null);let e=this.offsets.get(this.#i);c(e!==void 0);let s=this.#e.getPos()-e;this.#i.size=s,this.#i.largeSize=s>=2**32,this.patchBox(this.#i);let o=B(this.#s,this.#a);if(typeof this.#t.options.fastStart=="object"){this.#e.seek(this.#n),this.writeBox(o);let i=e-this.#e.getPos();this.writeBox(be(i))}else this.writeBox(o)}}};var se=class{},oe=class extends se{constructor(e){super();this.options=e}createMuxer(e){return new R(e,this)}};var U=class{},$=class extends U{#e=0;#t;#r=new ArrayBuffer(2**16);#o=new Uint8Array(this.#r);#n=0;constructor(r){super(),this.#t=r}#i(r){let e=this.#r.byteLength;for(;es.start-o.start);r.push({start:e[0].start,size:e[0].data.byteLength});for(let s=1;su.start<=e&&edt){for(let u=0;u=r.written[i+1].start;)r.written[i].end=Math.max(r.written[i].end,r.written[i+1].end),r.written.splice(i+1,1)}#s(r){let s={start:Math.floor(r/this.#r)*this.#r,data:new Uint8Array(this.#r),written:[],shouldFlush:!1};return this.#o.push(s),this.#o.sort((o,i)=>o.start-i.start),this.#o.indexOf(s)}#a(r=!1){for(let e=0;er.stream.write({type:"write",data:e,position:s}),chunkSize:r.options?.chunkSize}))}};var ct=Symbol("isTarget");ct;var z=class{},ie=class extends z{constructor(){super(...arguments);this.buffer=null}createWriter(){return new $(this)}},P=class extends z{constructor(e){super();this.options=e;if(typeof e!="object")throw new TypeError("StreamTarget requires an options object to be passed to its constructor.");if(e.onData){if(typeof e.onData!="function")throw new TypeError("options.onData, when provided, must be a function.");if(e.onData.length<2)throw new TypeError("options.onData, when provided, must be a function that takes in at least two arguments (data and position). Ignoring the position argument, which specifies the byte offset at which the data is to be written, can lead to broken outputs.")}if(e.chunked!==void 0&&typeof e.chunked!="boolean")throw new TypeError("options.chunked, when provided, must be a boolean.");if(e.chunkSize!==void 0&&(!Number.isInteger(e.chunkSize)||e.chunkSize<=0))throw new TypeError("options.chunkSize, when provided, must be a positive integer.")}createWriter(){return this.options.chunked?new F(this):new H(this)}},ne=class extends z{constructor(e,s){super();this.stream=e;this.options=s;if(!(e instanceof FileSystemWritableFileStream))throw new TypeError("FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance.");if(s!==void 0&&typeof s!="object")throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object.");if(s&&s.chunkSize!==void 0&&(!Number.isInteger(s.chunkSize)||s.chunkSize<=0))throw new TypeError("options.chunkSize, when provided, must be a positive integer")}createWriter(){return new Q(this)}};export{ie as ArrayBufferTarget,G as AudioBufferSource,X as AudioDataSource,q as CanvasSource,ne as FileSystemWritableFileStreamTarget,Y as MediaStreamAudioTrackSource,K as MediaStreamVideoTrackSource,oe as Mp4OutputFormat,Z as Output,P as StreamTarget,z as Target,j as VideoFrameSource}; diff --git a/dist/metamuxer.mjs b/dist/metamuxer.mjs index 8ac0165..15d4e02 100644 --- a/dist/metamuxer.mjs +++ b/dist/metamuxer.mjs @@ -1,2 +1,1965 @@ -// src/index.ts -console.log("hi"); +// src/codec.ts +var buildVideoCodecString = (codec, width, height) => { + if (codec === "avc") { + let profileIndication = 100; + if (width <= 768 && height <= 432) { + profileIndication = 66; + } else if (width <= 1920 && height <= 1080) { + profileIndication = 77; + } + const profileCompatibility = 0; + const levelIndication = width > 1920 || height > 1080 ? 50 : 41; + const hexProfileIndication = profileIndication.toString(16).padStart(2, "0"); + const hexProfileCompatibility = profileCompatibility.toString(16).padStart(2, "0"); + const hexLevelIndication = levelIndication.toString(16).padStart(2, "0"); + return `avc1.${hexProfileIndication}${hexProfileCompatibility}${hexLevelIndication}`; + } else if (codec === "hevc") { + let profileSpace = 0; + let profileIdc = 1; + const compatibilityFlags = Array(32).fill(0); + compatibilityFlags[profileIdc] = 1; + const compatibilityHex = parseInt(compatibilityFlags.reverse().join(""), 2).toString(16).replace(/^0+/, ""); + let tier = "L"; + let level = 120; + if (width <= 1280 && height <= 720) { + level = 93; + } else if (width <= 1920 && height <= 1080) { + level = 120; + } else if (width <= 3840 && height <= 2160) { + level = 150; + } else { + tier = "H"; + level = 180; + } + const constraintFlags = "B0"; + const profilePrefix = profileSpace === 0 ? "" : String.fromCharCode(65 + profileSpace - 1); + return `hev1.${profilePrefix}${profileIdc}.${compatibilityHex}.${tier}${level}.${constraintFlags}`; + } else if (codec === "vp8") { + return "vp8"; + } else if (codec === "vp9") { + const profile = "00"; + let level; + if (width <= 854 && height <= 480) { + level = "21"; + } else if (width <= 1280 && height <= 720) { + level = "31"; + } else if (width <= 1920 && height <= 1080) { + level = "41"; + } else if (width <= 3840 && height <= 2160) { + level = "51"; + } else { + level = "61"; + } + const bitDepth = "08"; + return `vp09.${profile}.${level}.${bitDepth}`; + } else if (codec === "av1") { + const profile = 0; + let level; + if (width <= 854 && height <= 480) { + level = "01"; + } else if (width <= 1280 && height <= 720) { + level = "03"; + } else if (width <= 1920 && height <= 1080) { + level = "04"; + } else if (width <= 3840 && height <= 2160) { + level = "07"; + } else { + level = "09"; + } + const tier = "M"; + const bitDepth = "08"; + return `av01.${profile}.${level}${tier}.${bitDepth}`; + } + throw new Error(`Unhandled codec '${codec}'.`); +}; +var buildAudioCodecString = (codec, numberOfChannels, sampleRate) => { + if (codec === "aac") { + if (numberOfChannels >= 2 && sampleRate <= 24e3) { + return "mp4a.40.29"; + } + if (sampleRate <= 24e3) { + return "mp4a.40.5"; + } + return "mp4a.40.2"; + } else if (codec === "opus") { + return "opus"; + } else if (codec === "vorbis") { + return "vorbis"; + } + throw new Error(`Unhandled codec '${codec}'.`); +}; + +// src/misc.ts +function assert(x) { + if (!x) { + throw new Error("Assertion failed."); + } +} +var last = (arr) => { + return arr && arr[arr.length - 1]; +}; +var isU32 = (value) => { + return value >= 0 && value < 2 ** 32; +}; + +// src/source.ts +var VideoSource = class { + constructor(codec, metadata) { + this.connectedTrack = null; + this.codec = codec; + this.metadata = metadata; + } + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error("Cannot call digest after output has started finalizing."); + } + } + start() { + } + async flush() { + } +}; +var AudioSource = class { + constructor(codec, metadata) { + this.connectedTrack = null; + this.codec = codec; + this.metadata = metadata; + } + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error("Cannot call digest after output has started finalizing."); + } + } + start() { + } + async flush() { + } +}; +var KEY_FRAME_INTERVAL = 5; +var VideoEncoderWrapper = class { + constructor(source, codecConfig) { + this.source = source; + this.codecConfig = codecConfig; + this.encoder = null; + this.lastMultipleOfKeyFrameInterval = -1; + } + digest(videoFrame) { + this.source.ensureNotFinalizing(); + this.ensureEncoder(videoFrame); + assert(this.encoder); + const multipleOfKeyFrameInterval = Math.floor(videoFrame.timestamp / 1e6 / KEY_FRAME_INTERVAL); + this.encoder.encode(videoFrame, { keyFrame: multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval }); + this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; + } + ensureEncoder(videoFrame) { + if (this.encoder) { + return; + } + this.encoder = new VideoEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error) + // TODO + }); + this.encoder.configure({ + codec: buildVideoCodecString(this.codecConfig.codec, videoFrame.codedWidth, videoFrame.codedHeight), + width: videoFrame.codedWidth, + height: videoFrame.codedHeight, + bitrate: this.codecConfig.bitrate + }); + } + async flush() { + return this.encoder?.flush(); + } +}; +var VideoFrameSource = class extends VideoSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + digest(videoFrame) { + this.encoder.digest(videoFrame); + } + flush() { + return this.encoder.flush(); + } +}; +var CanvasSource = class extends VideoSource { + constructor(canvas, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.canvas = canvas; + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + digest(timestamp, duration = 0) { + const frame = new VideoFrame(this.canvas, { + timestamp: Math.round(1e6 * timestamp), + duration: Math.round(1e6 * duration) + }); + this.encoder.digest(frame); + frame.close(); + } + flush() { + return this.encoder.flush(); + } +}; +var MediaStreamVideoTrackSource = class extends VideoSource { + constructor(track, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.track = track; + this.abortController = null; + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + start() { + this.abortController = new AbortController(); + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (videoFrame) => { + this.encoder.digest(videoFrame); + videoFrame.close(); + } + }); + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return; + console.error("Pipe error:", err); + }); + } + async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + await this.encoder.flush(); + } +}; +var AudioEncoderWrapper = class { + constructor(source, codecConfig) { + this.source = source; + this.codecConfig = codecConfig; + this.encoder = null; + } + digest(audioData) { + this.source.ensureNotFinalizing(); + this.ensureEncoder(audioData); + assert(this.encoder); + this.encoder.encode(audioData); + } + ensureEncoder(audioData) { + if (this.encoder) { + return; + } + this.encoder = new AudioEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error) + // TODO + }); + this.encoder.configure({ + codec: buildAudioCodecString(this.codecConfig.codec, audioData.numberOfChannels, audioData.sampleRate), + numberOfChannels: audioData.numberOfChannels, + sampleRate: audioData.sampleRate, + bitrate: this.codecConfig.bitrate + }); + } + async flush() { + return this.encoder?.flush(); + } +}; +var AudioDataSource = class extends AudioSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + digest(audioData) { + this.encoder.digest(audioData); + } + flush() { + return this.encoder.flush(); + } +}; +var AudioBufferSource = class extends AudioSource { + constructor(codecConfig, options = {}) { + super(codecConfig.codec, options); + this.accumulatedFrameCount = 0; + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + digest(audioBuffer) { + const numberOfChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numberOfFrames = audioBuffer.length; + const data = new Float32Array(numberOfChannels * numberOfFrames); + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = audioBuffer.getChannelData(channel); + data.set(channelData, channel * numberOfFrames); + } + const audioData = new AudioData({ + format: "f32-planar", + sampleRate, + numberOfFrames, + numberOfChannels, + timestamp: Math.round(1e6 * this.accumulatedFrameCount / sampleRate), + data + }); + this.encoder.digest(audioData); + audioData.close(); + this.accumulatedFrameCount += numberOfFrames; + } + flush() { + return this.encoder.flush(); + } +}; +var MediaStreamAudioTrackSource = class extends AudioSource { + constructor(track, codecConfig, options = {}) { + super(codecConfig.codec, options); + this.track = track; + this.abortController = null; + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + start() { + this.abortController = new AbortController(); + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (audioData) => { + this.encoder.digest(audioData); + audioData.close(); + } + }); + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return; + console.error("Pipe error:", err); + }); + } + async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + await this.encoder.flush(); + } +}; + +// src/output.ts +var Output = class { + constructor(options) { + this.tracks = []; + this.started = false; + this.finalizing = false; + this.writer = options.target.createWriter(); + this.muxer = options.format.createMuxer(this); + } + addTrack(source) { + if (this.started) { + throw new Error("Cannot add track after output has started."); + } + if (source.connectedTrack) { + throw new Error("Source is already used for a track."); + } + const track = { + id: this.tracks.length + 1, + output: this, + type: source instanceof VideoSource ? "video" : "audio", + source + }; + this.tracks.push(track); + source.connectedTrack = track; + } + start() { + if (this.started) { + throw new Error("Output already started."); + } + this.started = true; + this.muxer.start(); + for (const track of this.tracks) { + track.source.start(); + } + } + async finalize() { + if (this.finalizing) { + throw new Error("Cannot call finalize twice."); + } + this.finalizing = true; + const promises = this.tracks.map((x) => x.source.flush()); + await Promise.all(promises); + this.muxer.finalize(); + this.writer.flush(); + this.writer.finalize(); + } +}; + +// src/isobmff/isobmff_boxes.ts +var bytes = new Uint8Array(8); +var view = new DataView(bytes.buffer); +var u8 = (value) => { + return [(value % 256 + 256) % 256]; +}; +var u16 = (value) => { + view.setUint16(0, value, false); + return [bytes[0], bytes[1]]; +}; +var i16 = (value) => { + view.setInt16(0, value, false); + return [bytes[0], bytes[1]]; +}; +var u24 = (value) => { + view.setUint32(0, value, false); + return [bytes[1], bytes[2], bytes[3]]; +}; +var u32 = (value) => { + view.setUint32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; +}; +var i32 = (value) => { + view.setInt32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; +}; +var u64 = (value) => { + view.setUint32(0, Math.floor(value / 2 ** 32), false); + view.setUint32(4, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]]; +}; +var fixed_8_8 = (value) => { + view.setInt16(0, 2 ** 8 * value, false); + return [bytes[0], bytes[1]]; +}; +var fixed_16_16 = (value) => { + view.setInt32(0, 2 ** 16 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; +}; +var fixed_2_30 = (value) => { + view.setInt32(0, 2 ** 30 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]]; +}; +var ascii = (text, nullTerminated = false) => { + let bytes2 = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i)); + if (nullTerminated) bytes2.push(0); + return bytes2; +}; +var lastPresentedSample = (samples) => { + let result = null; + for (let sample of samples) { + if (!result || sample.presentationTimestamp > result.presentationTimestamp) { + result = sample; + } + } + return result; +}; +var rotationMatrix = (rotationInDegrees) => { + let theta = rotationInDegrees * (Math.PI / 180); + let cosTheta = Math.cos(theta); + let sinTheta = Math.sin(theta); + return [ + cosTheta, + sinTheta, + 0, + -sinTheta, + cosTheta, + 0, + 0, + 0, + 1 + ]; +}; +var IDENTITY_MATRIX = rotationMatrix(0); +var matrixToBytes = (matrix) => { + return [ + fixed_16_16(matrix[0]), + fixed_16_16(matrix[1]), + fixed_2_30(matrix[2]), + fixed_16_16(matrix[3]), + fixed_16_16(matrix[4]), + fixed_2_30(matrix[5]), + fixed_16_16(matrix[6]), + fixed_16_16(matrix[7]), + fixed_2_30(matrix[8]) + ]; +}; +var box = (type, contents, children) => ({ + type, + contents: contents && new Uint8Array(contents.flat(10)), + children +}); +var fullBox = (type, version, flags, contents, children) => box( + type, + [u8(version), u24(flags), contents ?? []], + children +); +var ftyp = (details) => { + let minorVersion = 512; + if (details.fragmented) return box("ftyp", [ + ascii("iso5"), + // Major brand + u32(minorVersion), + // Minor version + // Compatible brands + ascii("iso5"), + ascii("iso6"), + ascii("mp41") + ]); + return box("ftyp", [ + ascii("isom"), + // Major brand + u32(minorVersion), + // Minor version + // Compatible brands + ascii("isom"), + details.holdsAvc ? ascii("avc1") : [], + ascii("mp41") + ]); +}; +var mdat = (reserveLargeSize) => ({ type: "mdat", largeSize: reserveLargeSize }); +var free = (size) => ({ type: "free", size }); +var moov = (trackDatas, creationTime, fragmented = false) => box("moov", void 0, [ + mvhd(creationTime, trackDatas), + ...trackDatas.map((x) => trak(x, creationTime)), + fragmented ? mvex(trackDatas) : null +]); +var mvhd = (creationTime, trackDatas) => { + let duration = intoTimescale(Math.max( + 0, + ...trackDatas.filter((x) => x.samples.length > 0).map((x) => { + const lastSample = lastPresentedSample(x.samples); + return lastSample.presentationTimestamp + lastSample.duration; + }) + ), GLOBAL_TIMESCALE); + let nextTrackId = Math.max(...trackDatas.map((x) => x.track.id)) + 1; + let needsU64 = !isU32(creationTime) || !isU32(duration); + let u32OrU64 = needsU64 ? u64 : u32; + return fullBox("mvhd", +needsU64, 0, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(GLOBAL_TIMESCALE), + // Timescale + u32OrU64(duration), + // Duration + fixed_16_16(1), + // Preferred rate + fixed_8_8(1), + // Preferred volume + Array(10).fill(0), + // Reserved + matrixToBytes(IDENTITY_MATRIX), + // Matrix + Array(24).fill(0), + // Pre-defined + u32(nextTrackId) + // Next track ID + ]); +}; +var trak = (trackData, creationTime) => box("trak", void 0, [ + tkhd(trackData, creationTime), + mdia(trackData, creationTime) +]); +var tkhd = (trackData, creationTime) => { + let lastSample = lastPresentedSample(trackData.samples); + let durationInGlobalTimescale = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + GLOBAL_TIMESCALE + ); + let needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale); + let u32OrU64 = needsU64 ? u64 : u32; + let matrix; + if (trackData.type === "video") { + const rotation = trackData.track.source.metadata.rotation; + matrix = rotation === void 0 || typeof rotation === "number" ? rotationMatrix(rotation ?? 0) : rotation; + } else { + matrix = IDENTITY_MATRIX; + } + return fullBox("tkhd", +needsU64, 3, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(trackData.track.id), + // Track ID + u32(0), + // Reserved + u32OrU64(durationInGlobalTimescale), + // Duration + Array(8).fill(0), + // Reserved + u16(0), + // Layer + u16(0), + // Alternate group + fixed_8_8(trackData.type === "audio" ? 1 : 0), + // Volume + u16(0), + // Reserved + matrixToBytes(matrix), + // Matrix + fixed_16_16(trackData.type === "video" ? trackData.info.width : 0), + // Track width + fixed_16_16(trackData.type === "video" ? trackData.info.height : 0) + // Track height + ]); +}; +var mdia = (trackData, creationTime) => box("mdia", void 0, [ + mdhd(trackData, creationTime), + hdlr(trackData.type === "video" ? "vide" : "soun"), + minf(trackData) +]); +var mdhd = (trackData, creationTime) => { + let lastSample = lastPresentedSample(trackData.samples); + let localDuration = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + trackData.timescale + ); + let needsU64 = !isU32(creationTime) || !isU32(localDuration); + let u32OrU64 = needsU64 ? u64 : u32; + return fullBox("mdhd", +needsU64, 0, [ + u32OrU64(creationTime), + // Creation time + u32OrU64(creationTime), + // Modification time + u32(trackData.timescale), + // Timescale + u32OrU64(localDuration), + // Duration + u16(21956), + // Language ("und", undetermined) + u16(0) + // Quality + ]); +}; +var hdlr = (componentSubtype) => fullBox("hdlr", 0, 0, [ + ascii("mhlr"), + // Component type + ascii(componentSubtype), + // Component subtype + u32(0), + // Component manufacturer + u32(0), + // Component flags + u32(0), + // Component flags mask + ascii("mp4-muxer-hdlr", true) + // Component name +]); +var minf = (trackData) => box("minf", void 0, [ + trackData.type === "video" ? vmhd() : smhd(), + dinf(), + stbl(trackData) +]); +var vmhd = () => fullBox("vmhd", 0, 1, [ + u16(0), + // Graphics mode + u16(0), + // Opcolor R + u16(0), + // Opcolor G + u16(0) + // Opcolor B +]); +var smhd = () => fullBox("smhd", 0, 0, [ + u16(0), + // Balance + u16(0) + // Reserved +]); +var dinf = () => box("dinf", void 0, [ + dref() +]); +var dref = () => fullBox("dref", 0, 0, [ + u32(1) + // Entry count +], [ + url() +]); +var url = () => fullBox("url ", 0, 1); +var stbl = (trackData) => { + const needsCtts = trackData.compositionTimeOffsetTable.length > 1 || trackData.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0); + return box("stbl", void 0, [ + stsd(trackData), + stts(trackData), + stss(trackData), + stsc(trackData), + stsz(trackData), + stco(trackData), + needsCtts ? ctts(trackData) : null + ]); +}; +var stsd = (trackData) => fullBox("stsd", 0, 0, [ + u32(1) + // Entry count +], [ + trackData.type === "video" ? videoSampleDescription( + VIDEO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData + ) : soundSampleDescription( + AUDIO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData + ) +]); +var videoSampleDescription = (compressionType, trackData) => box(compressionType, [ + Array(6).fill(0), + // Reserved + u16(1), + // Data reference index + u16(0), + // Pre-defined + u16(0), + // Reserved + Array(12).fill(0), + // Pre-defined + u16(trackData.info.width), + // Width + u16(trackData.info.height), + // Height + u32(4718592), + // Horizontal resolution + u32(4718592), + // Vertical resolution + u32(0), + // Reserved + u16(1), + // Frame count + Array(32).fill(0), + // Compressor name + u16(24), + // Depth + i16(65535) + // Pre-defined +], [ + VIDEO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) +]); +var avcC = (trackData) => trackData.info.decoderConfig && box("avcC", [ + // For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here + ...new Uint8Array(trackData.info.decoderConfig.description) +]); +var hvcC = (trackData) => trackData.info.decoderConfig && box("hvcC", [ + // For HEVC, description is a HEVCDecoderConfigurationRecord, so nothing else to do here + ...new Uint8Array(trackData.info.decoderConfig.description) +]); +var vpcC = (trackData) => { + if (!trackData.info.decoderConfig) { + return null; + } + let decoderConfig = trackData.info.decoderConfig; + if (!decoderConfig.colorSpace) { + throw new Error(`'colorSpace' is required in the decoder config for VP8/VP9.`); + } + let parts = decoderConfig.codec.split("."); + let profile = Number(parts[1]); + let level = Number(parts[2]); + let bitDepth = Number(parts[3]); + let chromaSubsampling = 0; + let thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange); + let colourPrimaries = 2; + let transferCharacteristics = 2; + let matrixCoefficients = 2; + return fullBox("vpcC", 1, 0, [ + u8(profile), + // Profile + u8(level), + // Level + u8(thirdByte), + // Bit depth, chroma subsampling, full range + u8(colourPrimaries), + // Colour primaries + u8(transferCharacteristics), + // Transfer characteristics + u8(matrixCoefficients), + // Matrix coefficients + u16(0) + // Codec initialization data size + ]); +}; +var av1C = () => { + let marker = 1; + let version = 1; + let firstByte = (marker << 7) + version; + return box("av1C", [ + firstByte, + 0, + 0, + 0 + ]); +}; +var soundSampleDescription = (compressionType, trackData) => box(compressionType, [ + Array(6).fill(0), + // Reserved + u16(1), + // Data reference index + u16(0), + // Version + u16(0), + // Revision level + u32(0), + // Vendor + u16(trackData.info.numberOfChannels), + // Number of channels + u16(16), + // Sample size (bits) + u16(0), + // Compression ID + u16(0), + // Packet size + fixed_16_16(trackData.info.sampleRate) + // Sample rate +], [ + AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) +]); +var esds = (trackData) => { + let description = new Uint8Array(trackData.info.decoderConfig.description); + return fullBox("esds", 0, 0, [ + // https://stackoverflow.com/a/54803118 + u32(58753152), + // TAG(3) = Object Descriptor ([2]) + u8(32 + description.byteLength), + // length of this OD (which includes the next 2 tags) + u16(1), + // ES_ID = 1 + u8(0), + // flags etc = 0 + u32(75530368), + // TAG(4) = ES Descriptor ([2]) embedded in above OD + u8(18 + description.byteLength), + // length of this ESD + u8(64), + // MPEG-4 Audio + u8(21), + // stream type(6bits)=5 audio, flags(2bits)=1 + u24(0), + // 24bit buffer size + u32(130071), + // max bitrate + u32(130071), + // avg bitrate + u32(92307584), + // TAG(5) = ASC ([2],[3]) embedded in above OD + u8(description.byteLength), + // length + ...description, + u32(109084800), + // TAG(6) + u8(1), + // length + u8(2) + // data + ]); +}; +var dOps = (trackData) => { + let preskip = 3840; + let gain = 0; + 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."); + } + const view2 = ArrayBuffer.isView(description) ? new DataView(description.buffer, description.byteOffset, description.byteLength) : new DataView(description); + preskip = view2.getUint16(10, true); + gain = view2.getInt16(14, true); + } + return box("dOps", [ + u8(0), + // Version + u8(trackData.info.numberOfChannels), + // OutputChannelCount + u16(preskip), + u32(trackData.info.sampleRate), + // InputSampleRate + fixed_8_8(gain), + // OutputGain + u8(0) + // ChannelMappingFamily + ]); +}; +var stts = (trackData) => { + return fullBox("stts", 0, 0, [ + u32(trackData.timeToSampleTable.length), + // Number of entries + trackData.timeToSampleTable.map((x) => [ + // Time-to-sample table + u32(x.sampleCount), + // Sample count + u32(x.sampleDelta) + // Sample duration + ]) + ]); +}; +var stss = (trackData) => { + if (trackData.samples.every((x) => x.type === "key")) return null; + let keySamples = [...trackData.samples.entries()].filter(([, sample]) => sample.type === "key"); + return fullBox("stss", 0, 0, [ + u32(keySamples.length), + // Number of entries + keySamples.map(([index]) => u32(index + 1)) + // Sync sample table + ]); +}; +var stsc = (trackData) => { + return fullBox("stsc", 0, 0, [ + u32(trackData.compactlyCodedChunkTable.length), + // Number of entries + trackData.compactlyCodedChunkTable.map((x) => [ + // Sample-to-chunk table + u32(x.firstChunk), + // First chunk + u32(x.samplesPerChunk), + // Samples per chunk + u32(1) + // Sample description index + ]) + ]); +}; +var stsz = (trackData) => fullBox("stsz", 0, 0, [ + u32(0), + // Sample size (0 means non-constant size) + u32(trackData.samples.length), + // Number of entries + trackData.samples.map((x) => u32(x.size)) + // Sample size table +]); +var stco = (trackData) => { + if (trackData.finalizedChunks.length > 0 && last(trackData.finalizedChunks).offset >= 2 ** 32) { + return fullBox("co64", 0, 0, [ + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((x) => u64(x.offset)) + // Chunk offset table + ]); + } + return fullBox("stco", 0, 0, [ + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((x) => u32(x.offset)) + // Chunk offset table + ]); +}; +var ctts = (trackData) => { + return fullBox("ctts", 0, 0, [ + u32(trackData.compositionTimeOffsetTable.length), + // Number of entries + trackData.compositionTimeOffsetTable.map((x) => [ + // Time-to-sample table + u32(x.sampleCount), + // Sample count + u32(x.sampleCompositionTimeOffset) + // Sample offset + ]) + ]); +}; +var mvex = (trackDatas) => { + return box("mvex", void 0, trackDatas.map(trex)); +}; +var trex = (trackData) => { + return fullBox("trex", 0, 0, [ + u32(trackData.track.id), + // Track ID + u32(1), + // Default sample description index + u32(0), + // Default sample duration + u32(0), + // Default sample size + u32(0) + // Default sample flags + ]); +}; +var moof = (sequenceNumber, trackDatas) => { + return box("moof", void 0, [ + mfhd(sequenceNumber), + ...trackDatas.map(traf) + ]); +}; +var mfhd = (sequenceNumber) => { + return fullBox("mfhd", 0, 0, [ + u32(sequenceNumber) + // Sequence number + ]); +}; +var fragmentSampleFlags = (sample) => { + let byte1 = 0; + let byte2 = 0; + let byte3 = 0; + let byte4 = 0; + let sampleIsDifferenceSample = sample.type === "delta"; + byte2 |= +sampleIsDifferenceSample; + if (sampleIsDifferenceSample) { + byte1 |= 1; + } else { + byte1 |= 2; + } + return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4; +}; +var traf = (trackData) => { + return box("traf", void 0, [ + tfhd(trackData), + tfdt(trackData), + trun(trackData) + ]); +}; +var tfhd = (trackData) => { + assert(trackData.currentChunk); + let tfFlags = 0; + tfFlags |= 8; + tfFlags |= 16; + tfFlags |= 32; + tfFlags |= 131072; + let referenceSample = trackData.currentChunk.samples[1] ?? trackData.currentChunk.samples[0]; + let referenceSampleInfo = { + duration: referenceSample.timescaleUnitsToNextSample, + size: referenceSample.size, + flags: fragmentSampleFlags(referenceSample) + }; + return fullBox("tfhd", 0, tfFlags, [ + u32(trackData.track.id), + // Track ID + u32(referenceSampleInfo.duration), + // Default sample duration + u32(referenceSampleInfo.size), + // Default sample size + u32(referenceSampleInfo.flags) + // Default sample flags + ]); +}; +var tfdt = (trackData) => { + assert(trackData.currentChunk); + return fullBox("tfdt", 1, 0, [ + u64(intoTimescale(trackData.currentChunk.startTimestamp, trackData.timescale)) + // Base Media Decode Time + ]); +}; +var trun = (trackData) => { + assert(trackData.currentChunk); + let allSampleDurations = trackData.currentChunk.samples.map((x) => x.timescaleUnitsToNextSample); + let allSampleSizes = trackData.currentChunk.samples.map((x) => x.size); + let allSampleFlags = trackData.currentChunk.samples.map(fragmentSampleFlags); + let allSampleCompositionTimeOffsets = trackData.currentChunk.samples.map((x) => intoTimescale(x.presentationTimestamp - x.decodeTimestamp, trackData.timescale)); + let uniqueSampleDurations = new Set(allSampleDurations); + let uniqueSampleSizes = new Set(allSampleSizes); + let uniqueSampleFlags = new Set(allSampleFlags); + let uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets); + let firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1]; + let sampleDurationPresent = uniqueSampleDurations.size > 1; + let sampleSizePresent = uniqueSampleSizes.size > 1; + let sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1; + let sampleCompositionTimeOffsetsPresent = uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some((x) => x !== 0); + let flags = 0; + flags |= 1; + flags |= 4 * +firstSampleFlagsPresent; + flags |= 256 * +sampleDurationPresent; + flags |= 512 * +sampleSizePresent; + flags |= 1024 * +sampleFlagsPresent; + flags |= 2048 * +sampleCompositionTimeOffsetsPresent; + return fullBox("trun", 1, flags, [ + u32(trackData.currentChunk.samples.length), + // Sample count + u32(trackData.currentChunk.offset - trackData.currentChunk.moofOffset || 0), + // Data offset + firstSampleFlagsPresent ? u32(allSampleFlags[0]) : [], + trackData.currentChunk.samples.map((_, i) => [ + sampleDurationPresent ? u32(allSampleDurations[i]) : [], + // Sample duration + sampleSizePresent ? u32(allSampleSizes[i]) : [], + // Sample size + sampleFlagsPresent ? u32(allSampleFlags[i]) : [], + // Sample flags + // Sample composition time offsets + sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]) : [] + ]) + ]); +}; +var mfra = (trackDatas) => { + return box("mfra", void 0, [ + ...trackDatas.map(tfra), + mfro() + ]); +}; +var tfra = (trackData, trackIndex) => { + let version = 1; + return fullBox("tfra", version, 0, [ + u32(trackData.track.id), + // Track ID + u32(63), + // This specifies that traf number, trun number and sample number are 32-bit ints + u32(trackData.finalizedChunks.length), + // Number of entries + trackData.finalizedChunks.map((chunk) => [ + u64(intoTimescale(chunk.startTimestamp, trackData.timescale)), + // Time + u64(chunk.moofOffset), + // moof offset + u32(trackIndex + 1), + // traf number + u32(1), + // trun number + u32(1) + // Sample number + ]) + ]); +}; +var mfro = () => { + return fullBox("mfro", 0, 0, [ + // This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box + // is known + u32(0) + // Size + ]); +}; +var VIDEO_CODEC_TO_BOX_NAME = { + "avc": "avc1", + "hevc": "hvc1", + "vp8": "vp08", + "vp9": "vp09", + "av1": "av01" +}; +var VIDEO_CODEC_TO_CONFIGURATION_BOX = { + "avc": avcC, + "hevc": hvcC, + "vp8": vpcC, + "vp9": vpcC, + "av1": av1C +}; +var AUDIO_CODEC_TO_BOX_NAME = { + "aac": "mp4a", + "opus": "Opus" +}; +var AUDIO_CODEC_TO_CONFIGURATION_BOX = { + "aac": esds, + "opus": dOps +}; + +// src/muxer.ts +var Muxer = class { + constructor(output) { + this.output = output; + } +}; + +// src/isobmff/isobmff_muxer.ts +var GLOBAL_TIMESCALE = 1e3; +var TIMESTAMP_OFFSET = 2082844800; +var intoTimescale = (timeInSeconds, timescale, round = true) => { + let value = timeInSeconds * timescale; + return round ? Math.round(value) : value; +}; +var IsobmffMuxer = class extends Muxer { + constructor(output, format) { + super(output); + this.#helper = new Uint8Array(8); + this.#helperView = new DataView(this.#helper.buffer); + /** + * Stores the position from the start of the file to where boxes elements have been written. This is used to + * rewrite/edit elements that were already added before, and to measure sizes of things. + */ + this.offsets = /* @__PURE__ */ new WeakMap(); + this.#ftypSize = null; + this.#mdat = null; + this.#trackDatas = []; + this.#creationTime = Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET; + this.#finalizedChunks = []; + this.#nextFragmentNumber = 1; + this.#writer = output.writer; + this.#format = format; + } + #writer; + #format; + #helper; + #helperView; + #ftypSize; + #mdat; + #trackDatas; + #creationTime; + #finalizedChunks; + #nextFragmentNumber; + writeU32(value) { + this.#helperView.setUint32(0, value, false); + this.#writer.write(this.#helper.subarray(0, 4)); + } + writeU64(value) { + this.#helperView.setUint32(0, Math.floor(value / 2 ** 32), false); + this.#helperView.setUint32(4, value, false); + this.#writer.write(this.#helper.subarray(0, 8)); + } + writeAscii(text) { + for (let i = 0; i < text.length; i++) { + this.#helperView.setUint8(i % 8, text.charCodeAt(i)); + if (i % 8 === 7) this.#writer.write(this.#helper); + } + if (text.length % 8 !== 0) { + this.#writer.write(this.#helper.subarray(0, text.length % 8)); + } + } + writeBox(box2) { + this.offsets.set(box2, this.#writer.getPos()); + if (box2.contents && !box2.children) { + this.writeBoxHeader(box2, box2.size ?? box2.contents.byteLength + 8); + this.#writer.write(box2.contents); + } else { + let startPos = this.#writer.getPos(); + this.writeBoxHeader(box2, 0); + if (box2.contents) this.#writer.write(box2.contents); + if (box2.children) { + for (let child of box2.children) if (child) this.writeBox(child); + } + let endPos = this.#writer.getPos(); + let size = box2.size ?? endPos - startPos; + this.#writer.seek(startPos); + this.writeBoxHeader(box2, size); + this.#writer.seek(endPos); + } + } + writeBoxHeader(box2, size) { + this.writeU32(box2.largeSize ? 1 : size); + this.writeAscii(box2.type); + if (box2.largeSize) this.writeU64(size); + } + measureBoxHeader(box2) { + return 8 + (box2.largeSize ? 8 : 0); + } + patchBox(box2) { + const boxOffset = this.offsets.get(box2); + assert(boxOffset !== void 0); + let endPos = this.#writer.getPos(); + this.#writer.seek(boxOffset); + this.writeBox(box2); + this.#writer.seek(endPos); + } + measureBox(box2) { + if (box2.contents && !box2.children) { + let headerSize = this.measureBoxHeader(box2); + return headerSize + box2.contents.byteLength; + } else { + let result = this.measureBoxHeader(box2); + if (box2.contents) result += box2.contents.byteLength; + if (box2.children) { + for (let child of box2.children) if (child) result += this.measureBox(child); + } + return result; + } + } + start() { + this.#writeHeader(); + } + #writeHeader() { + const holdsAvc = this.output.tracks.some((x) => x.type === "video" && x.source.codec === "avc"); + this.writeBox(ftyp({ + holdsAvc, + fragmented: this.#format.options.fastStart === "fragmented" + })); + this.#ftypSize = this.#writer.getPos(); + if (this.#format.options.fastStart === "in-memory") { + this.#mdat = mdat(false); + } else if (this.#format.options.fastStart === "fragmented") { + } else { + if (typeof this.#format.options.fastStart === "object") { + let moovSizeUpperBound = this.#computeMoovSizeUpperBound(); + this.#writer.seek(this.#writer.getPos() + moovSizeUpperBound); + } + this.#mdat = mdat(true); + this.writeBox(this.#mdat); + } + 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; + upperBound += (4 + 4) * Math.ceil(2 / 3 * n); + upperBound += 4 * n; + upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n); + upperBound += 4 * n; + upperBound += 8 * n; + } + upperBound += 4096; + return upperBound; + } + #getVideoTrackData(track, chunk, meta) { + const existingTrackData = this.#trackDatas.find((x) => x.track === track); + if (existingTrackData) { + return existingTrackData; + } + assert(meta); + assert(meta.decoderConfig); + assert(meta.decoderConfig.codedWidth); + assert(meta.decoderConfig.codedHeight); + const newTrackData = { + track, + type: "video", + info: { + width: meta.decoderConfig.codedWidth, + height: meta.decoderConfig.codedHeight, + decoderConfig: meta.decoderConfig + }, + timescale: track.source.metadata.frameRate ?? 57600, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + this.#trackDatas.push(newTrackData); + this.#trackDatas.sort((a, b) => a.track.id - b.track.id); + return newTrackData; + } + #getAudioTrackData(track, chunk, meta) { + const existingTrackData = this.#trackDatas.find((x) => x.track === track); + if (existingTrackData) { + return existingTrackData; + } + assert(meta); + assert(meta.decoderConfig); + const newTrackData = { + track, + type: "audio", + info: { + numberOfChannels: meta.decoderConfig.numberOfChannels, + sampleRate: meta.decoderConfig.sampleRate, + decoderConfig: meta.decoderConfig + }, + timescale: meta.decoderConfig.sampleRate, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + this.#trackDatas.push(newTrackData); + this.#trackDatas.sort((a, b) => a.track.id - b.track.id); + return newTrackData; + } + addEncodedVideoChunk(track, chunk, meta, compositionTimeOffset) { + const trackData = this.#getVideoTrackData(track, chunk, meta); + if (typeof this.#format.options.fastStart === "object" && trackData.samples.length === this.#format.options.fastStart.expectedVideoChunks) { + throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${this.#format.options.fastStart.expectedVideoChunks}).`); + } + let videoSample = this.#createSampleForTrack(trackData, chunk, compositionTimeOffset); + if (this.#format.options.fastStart === "fragmented") { + trackData.sampleQueue.push(videoSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, videoSample); + } + } + addEncodedAudioChunk(track, chunk, meta) { + const trackData = this.#getAudioTrackData(track, chunk, meta); + if (typeof this.#format.options.fastStart === "object" && trackData.samples.length === this.#format.options.fastStart.expectedAudioChunks) { + throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${this.#format.options.fastStart.expectedAudioChunks}).`); + } + let audioSample = this.#createSampleForTrack(trackData, chunk); + if (this.#format.options.fastStart === "fragmented") { + trackData.sampleQueue.push(audioSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, audioSample); + } + } + #createSampleForTrack(trackData, chunk, compositionTimeOffset) { + let presentationTimestampInSeconds = chunk.timestamp / 1e6; + let decodeTimestampInSeconds = (chunk.timestamp - (compositionTimeOffset ?? 0)) / 1e6; + let durationInSeconds = (chunk.duration ?? 0) / 1e6; + let adjusted = this.#validateTimestamp(trackData, presentationTimestampInSeconds, decodeTimestampInSeconds); + presentationTimestampInSeconds = adjusted.presentationTimestamp; + decodeTimestampInSeconds = adjusted.decodeTimestamp; + let data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + let sample = { + presentationTimestamp: presentationTimestampInSeconds, + decodeTimestamp: decodeTimestampInSeconds, + duration: durationInSeconds, + data, + size: data.byteLength, + type: chunk.type, + // Will be refined once the next sample comes in + timescaleUnitsToNextSample: intoTimescale(durationInSeconds, trackData.timescale) + }; + return sample; + } + #addSampleToTrack(trackData, sample) { + if (this.#format.options.fastStart !== "fragmented") { + trackData.samples.push(sample); + } + const sampleCompositionTimeOffset = intoTimescale(sample.presentationTimestamp - sample.decodeTimestamp, trackData.timescale); + if (trackData.lastTimescaleUnits !== null) { + assert(trackData.lastSample); + let timescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false); + let delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits); + trackData.lastTimescaleUnits += delta; + trackData.lastSample.timescaleUnitsToNextSample = delta; + if (this.#format.options.fastStart !== "fragmented") { + let lastTableEntry = last(trackData.timeToSampleTable); + assert(lastTableEntry); + if (lastTableEntry.sampleCount === 1) { + lastTableEntry.sampleDelta = delta; + lastTableEntry.sampleCount++; + } else if (lastTableEntry.sampleDelta === delta) { + lastTableEntry.sampleCount++; + } else { + lastTableEntry.sampleCount--; + trackData.timeToSampleTable.push({ + sampleCount: 2, + sampleDelta: delta + }); + } + const lastCompositionTimeOffsetTableEntry = last(trackData.compositionTimeOffsetTable); + assert(lastCompositionTimeOffsetTableEntry); + if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) { + lastCompositionTimeOffsetTableEntry.sampleCount++; + } else { + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset + }); + } + } + } else { + trackData.lastTimescaleUnits = 0; + if (this.#format.options.fastStart !== "fragmented") { + trackData.timeToSampleTable.push({ + sampleCount: 1, + sampleDelta: intoTimescale(sample.duration, trackData.timescale) + }); + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset + }); + } + } + trackData.lastSample = sample; + let beginNewChunk = false; + if (!trackData.currentChunk) { + beginNewChunk = true; + } else { + let currentChunkDuration = sample.presentationTimestamp - trackData.currentChunk.startTimestamp; + if (this.#format.options.fastStart === "fragmented") { + const keyFrameQueuedEverywhere = this.#trackDatas.every((otherTrackData) => { + if (trackData === otherTrackData) { + return sample.type === "key"; + } + const firstQueuedSample = otherTrackData.sampleQueue[0]; + return firstQueuedSample && firstQueuedSample.type === "key"; + }); + if (currentChunkDuration >= 1 && keyFrameQueuedEverywhere) { + beginNewChunk = true; + this.#finalizeFragment(); + } + } else { + beginNewChunk = currentChunkDuration >= 0.5; + } + } + if (beginNewChunk) { + if (trackData.currentChunk) { + this.#finalizeCurrentChunk(trackData); + } + trackData.currentChunk = { + startTimestamp: sample.presentationTimestamp, + samples: [], + offset: null, + moofOffset: null + }; + } + assert(trackData.currentChunk); + trackData.currentChunk.samples.push(sample); + } + #validateTimestamp(trackData, presentationTimestamp, decodeTimestamp) { + if (trackData.firstDecodeTimestamp === null) { + trackData.firstDecodeTimestamp = decodeTimestamp; + } + decodeTimestamp -= trackData.firstDecodeTimestamp; + presentationTimestamp -= trackData.firstDecodeTimestamp; + if (decodeTimestamp < trackData.lastDecodeTimestamp) { + throw new Error( + `Timestamps must be monotonically increasing (timestamp went from ${trackData.lastDecodeTimestamp}s to ${decodeTimestamp}s).` + ); + } + trackData.lastDecodeTimestamp = decodeTimestamp; + return { presentationTimestamp, decodeTimestamp }; + } + #finalizeCurrentChunk(trackData) { + assert(this.#format.options.fastStart !== "fragmented"); + if (!trackData.currentChunk) return; + trackData.finalizedChunks.push(trackData.currentChunk); + this.#finalizedChunks.push(trackData.currentChunk); + if (trackData.compactlyCodedChunkTable.length === 0 || last(trackData.compactlyCodedChunkTable).samplesPerChunk !== trackData.currentChunk.samples.length) { + trackData.compactlyCodedChunkTable.push({ + firstChunk: trackData.finalizedChunks.length, + // 1-indexed + samplesPerChunk: trackData.currentChunk.samples.length + }); + } + if (this.#format.options.fastStart === "in-memory") { + trackData.currentChunk.offset = 0; + return; + } + trackData.currentChunk.offset = this.#writer.getPos(); + for (let sample of trackData.currentChunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; + } + this.#writer.flush(); + } + #interleaveSamples() { + assert(this.#format.options.fastStart === "fragmented"); + if (this.#trackDatas.length < this.output.tracks.length) { + return; + } + outer: + while (true) { + let trackWithMinDecodeTimestamp = null; + let minDecodeTimestamp = Infinity; + for (let trackData of this.#trackDatas) { + if (trackData.sampleQueue.length === 0) { + break outer; + } + if (trackData.sampleQueue[0].decodeTimestamp < minDecodeTimestamp) { + trackWithMinDecodeTimestamp = trackData; + minDecodeTimestamp = trackData.sampleQueue[0].decodeTimestamp; + } + } + if (!trackWithMinDecodeTimestamp) { + break; + } + let sample = trackWithMinDecodeTimestamp.sampleQueue.shift(); + this.#addSampleToTrack(trackWithMinDecodeTimestamp, sample); + } + } + #finalizeFragment(flushWriter = true) { + assert(this.#format.options.fastStart === "fragmented"); + let fragmentNumber = this.#nextFragmentNumber++; + if (fragmentNumber === 1) { + let movieBox = moov(this.#trackDatas, this.#creationTime, true); + this.writeBox(movieBox); + } + let moofOffset = this.#writer.getPos(); + let moofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(moofBox); + { + let mdatBox = mdat(false); + let totalTrackSampleSize = 0; + for (let trackData of this.#trackDatas) { + assert(trackData.currentChunk); + for (let sample of trackData.currentChunk.samples) { + totalTrackSampleSize += sample.size; + } + } + let mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + if (mdatSize >= 2 ** 32) { + mdatBox.largeSize = true; + mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + } + mdatBox.size = mdatSize; + this.writeBox(mdatBox); + } + for (let trackData of this.#trackDatas) { + trackData.currentChunk.offset = this.#writer.getPos(); + trackData.currentChunk.moofOffset = moofOffset; + for (let sample of trackData.currentChunk.samples) { + this.#writer.write(sample.data); + sample.data = null; + } + } + let endPos = this.#writer.getPos(); + this.#writer.seek(this.offsets.get(moofBox)); + let newMoofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(newMoofBox); + this.#writer.seek(endPos); + for (let trackData of this.#trackDatas) { + trackData.finalizedChunks.push(trackData.currentChunk); + this.#finalizedChunks.push(trackData.currentChunk); + trackData.currentChunk = null; + } + if (flushWriter) { + this.#writer.flush(); + } + } + /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */ + finalize() { + if (this.#format.options.fastStart === "fragmented") { + for (let trackData of this.#trackDatas) { + for (let sample of trackData.sampleQueue) { + this.#addSampleToTrack(trackData, sample); + } + } + this.#finalizeFragment(false); + } else { + for (let trackData of this.#trackDatas) { + this.#finalizeCurrentChunk(trackData); + } + } + if (this.#format.options.fastStart === "in-memory") { + assert(this.#mdat); + let mdatSize; + for (let i = 0; i < 2; i++) { + let movieBox2 = moov(this.#trackDatas, this.#creationTime); + let movieBoxSize = this.measureBox(movieBox2); + mdatSize = this.measureBox(this.#mdat); + let currentChunkPos = this.#writer.getPos() + movieBoxSize + mdatSize; + for (let chunk of this.#finalizedChunks) { + chunk.offset = currentChunkPos; + for (let { data } of chunk.samples) { + assert(data); + currentChunkPos += data.byteLength; + mdatSize += data.byteLength; + } + } + if (currentChunkPos < 2 ** 32) break; + if (mdatSize >= 2 ** 32) this.#mdat.largeSize = true; + } + let movieBox = moov(this.#trackDatas, this.#creationTime); + this.writeBox(movieBox); + this.#mdat.size = mdatSize; + this.writeBox(this.#mdat); + for (let chunk of this.#finalizedChunks) { + for (let sample of chunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; + } + } + } else if (this.#format.options.fastStart === "fragmented") { + let startPos = this.#writer.getPos(); + let mfraBox = mfra(this.#trackDatas); + this.writeBox(mfraBox); + let mfraBoxSize = this.#writer.getPos() - startPos; + this.#writer.seek(this.#writer.getPos() - 4); + this.writeU32(mfraBoxSize); + } else { + assert(this.#mdat); + assert(this.#ftypSize !== null); + let mdatPos = this.offsets.get(this.#mdat); + assert(mdatPos !== void 0); + let mdatSize = this.#writer.getPos() - mdatPos; + this.#mdat.size = mdatSize; + this.#mdat.largeSize = mdatSize >= 2 ** 32; + this.patchBox(this.#mdat); + let movieBox = moov(this.#trackDatas, this.#creationTime); + if (typeof this.#format.options.fastStart === "object") { + this.#writer.seek(this.#ftypSize); + this.writeBox(movieBox); + let remainingBytes = mdatPos - this.#writer.getPos(); + this.writeBox(free(remainingBytes)); + } else { + this.writeBox(movieBox); + } + } + } +}; + +// src/output_format.ts +var OutputFormat = class { +}; +var Mp4OutputFormat = class extends OutputFormat { + constructor(options) { + super(); + this.options = options; + } + createMuxer(output) { + return new IsobmffMuxer(output, this); + } +}; + +// src/writer.ts +var Writer = class { +}; +var ArrayBufferTargetWriter = class extends Writer { + #pos = 0; + #target; + #buffer = new ArrayBuffer(2 ** 16); + #bytes = new Uint8Array(this.#buffer); + #maxPos = 0; + constructor(target) { + super(); + this.#target = target; + } + #ensureSize(size) { + let newLength = this.#buffer.byteLength; + while (newLength < size) newLength *= 2; + if (newLength === this.#buffer.byteLength) return; + let newBuffer = new ArrayBuffer(newLength); + let newBytes = new Uint8Array(newBuffer); + newBytes.set(this.#bytes, 0); + this.#buffer = newBuffer; + this.#bytes = newBytes; + } + write(data) { + this.#ensureSize(this.#pos + data.byteLength); + this.#bytes.set(data, this.#pos); + this.#pos += data.byteLength; + this.#maxPos = Math.max(this.#maxPos, this.#pos); + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + flush() { + } + finalize() { + this.#ensureSize(this.#pos); + this.#target.buffer = this.#buffer.slice(0, Math.max(this.#maxPos, this.#pos)); + } +}; +var StreamTargetWriter = class extends Writer { + #pos = 0; + #target; + #sections = []; + constructor(target) { + super(); + this.#target = target; + } + write(data) { + this.#sections.push({ + data: data.slice(), + start: this.#pos + }); + this.#pos += data.byteLength; + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + flush() { + if (this.#sections.length === 0) return; + let chunks = []; + let sorted = [...this.#sections].sort((a, b) => a.start - b.start); + chunks.push({ + start: sorted[0].start, + size: sorted[0].data.byteLength + }); + for (let i = 1; i < sorted.length; i++) { + let lastChunk = chunks[chunks.length - 1]; + let section = sorted[i]; + if (section.start <= lastChunk.start + lastChunk.size) { + lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start); + } else { + chunks.push({ + start: section.start, + size: section.data.byteLength + }); + } + } + for (let chunk of chunks) { + chunk.data = new Uint8Array(chunk.size); + for (let section of this.#sections) { + if (chunk.start <= section.start && section.start < chunk.start + chunk.size) { + chunk.data.set(section.data, section.start - chunk.start); + } + } + this.#target.options.onData?.(chunk.data, chunk.start); + } + this.#sections.length = 0; + } + finalize() { + } +}; +var DEFAULT_CHUNK_SIZE = 2 ** 24; +var MAX_CHUNKS_AT_ONCE = 2; +var ChunkedStreamTargetWriter = class extends Writer { + #pos = 0; + #target; + #chunkSize; + /** + * The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out. + * A chunk is flushed if all of its contents have been written. + */ + #chunks = []; + constructor(target) { + super(); + this.#target = target; + this.#chunkSize = target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE; + if (!Number.isInteger(this.#chunkSize) || this.#chunkSize < 2 ** 10) { + throw new Error("Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024."); + } + } + write(data) { + this.#writeDataIntoChunks(data, this.#pos); + this.#flushChunks(); + this.#pos += data.byteLength; + } + seek(newPos) { + this.#pos = newPos; + } + getPos() { + return this.#pos; + } + #writeDataIntoChunks(data, position) { + let chunkIndex = this.#chunks.findIndex((x) => x.start <= position && position < x.start + this.#chunkSize); + if (chunkIndex === -1) chunkIndex = this.#createChunk(position); + let chunk = this.#chunks[chunkIndex]; + let relativePosition = position - chunk.start; + let toWrite = data.subarray(0, Math.min(this.#chunkSize - relativePosition, data.byteLength)); + chunk.data.set(toWrite, relativePosition); + let section = { + start: relativePosition, + end: relativePosition + toWrite.byteLength + }; + this.#insertSectionIntoChunk(chunk, section); + if (chunk.written[0].start === 0 && chunk.written[0].end === this.#chunkSize) { + chunk.shouldFlush = true; + } + if (this.#chunks.length > MAX_CHUNKS_AT_ONCE) { + for (let i = 0; i < this.#chunks.length - 1; i++) { + this.#chunks[i].shouldFlush = true; + } + this.#flushChunks(); + } + if (toWrite.byteLength < data.byteLength) { + this.#writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength); + } + } + #insertSectionIntoChunk(chunk, section) { + let low = 0; + let high = chunk.written.length - 1; + let index = -1; + while (low <= high) { + let mid = Math.floor(low + (high - low + 1) / 2); + if (chunk.written[mid].start <= section.start) { + low = mid + 1; + index = mid; + } else { + high = mid - 1; + } + } + chunk.written.splice(index + 1, 0, section); + if (index === -1 || chunk.written[index].end < section.start) index++; + while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) { + chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end); + chunk.written.splice(index + 1, 1); + } + } + #createChunk(includesPosition) { + let start = Math.floor(includesPosition / this.#chunkSize) * this.#chunkSize; + let chunk = { + start, + data: new Uint8Array(this.#chunkSize), + written: [], + shouldFlush: false + }; + this.#chunks.push(chunk); + this.#chunks.sort((a, b) => a.start - b.start); + return this.#chunks.indexOf(chunk); + } + #flushChunks(force = false) { + for (let i = 0; i < this.#chunks.length; i++) { + let chunk = this.#chunks[i]; + if (!chunk.shouldFlush && !force) continue; + for (let section of chunk.written) { + this.#target.options.onData?.( + chunk.data.subarray(section.start, section.end), + chunk.start + section.start + ); + } + this.#chunks.splice(i--, 1); + } + } + flush() { + } + finalize() { + this.#flushChunks(true); + } +}; +var FileSystemWritableFileStreamTargetWriter = class extends ChunkedStreamTargetWriter { + constructor(target) { + super(new StreamTarget({ + onData: (data, position) => target.stream.write({ + type: "write", + data, + position + }), + chunkSize: target.options?.chunkSize + })); + } +}; + +// src/target.ts +var isTarget = Symbol("isTarget"); +isTarget; +var Target = class { +}; +var ArrayBufferTarget2 = class extends Target { + constructor() { + super(...arguments); + this.buffer = null; + } + createWriter() { + return new ArrayBufferTargetWriter(this); + } +}; +var StreamTarget = class extends Target { + constructor(options) { + super(); + this.options = options; + if (typeof options !== "object") { + throw new TypeError("StreamTarget requires an options object to be passed to its constructor."); + } + if (options.onData) { + if (typeof options.onData !== "function") { + throw new TypeError("options.onData, when provided, must be a function."); + } + if (options.onData.length < 2) { + throw new TypeError( + "options.onData, when provided, must be a function that takes in at least two arguments (data and position). Ignoring the position argument, which specifies the byte offset at which the data is to be written, can lead to broken outputs." + ); + } + } + if (options.chunked !== void 0 && typeof options.chunked !== "boolean") { + throw new TypeError("options.chunked, when provided, must be a boolean."); + } + if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError("options.chunkSize, when provided, must be a positive integer."); + } + } + createWriter() { + return this.options.chunked ? new ChunkedStreamTargetWriter(this) : new StreamTargetWriter(this); + } +}; +var FileSystemWritableFileStreamTarget2 = class extends Target { + constructor(stream, options) { + super(); + this.stream = stream; + this.options = options; + if (!(stream instanceof FileSystemWritableFileStream)) { + throw new TypeError("FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance."); + } + if (options !== void 0 && typeof options !== "object") { + throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object."); + } + if (options) { + if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError("options.chunkSize, when provided, must be a positive integer"); + } + } + } + createWriter() { + return new FileSystemWritableFileStreamTargetWriter(this); + } +}; +export { + ArrayBufferTarget2 as ArrayBufferTarget, + AudioBufferSource, + AudioDataSource, + CanvasSource, + FileSystemWritableFileStreamTarget2 as FileSystemWritableFileStreamTarget, + MediaStreamAudioTrackSource, + MediaStreamVideoTrackSource, + Mp4OutputFormat, + Output, + StreamTarget, + Target, + VideoFrameSource +}; diff --git a/package-lock.json b/package-lock.json index bf5b8ef..ee8840e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.10", "@types/dom-webcodecs": "^0.1.11" }, "devDependencies": { @@ -400,6 +401,14 @@ "node": ">=18" } }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.10.tgz", + "integrity": "sha512-zUxMN2iShu7p3Fz5sqfvLp93qW/3sLs+RwXWWOkMb969hsuoVqUUokqrENjXqTMNmEEcVXKoHuMMbIGcWyrVVA==", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, "node_modules/@types/dom-webcodecs": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.11.tgz", diff --git a/package.json b/package.json index b703853..6c59f3a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "author": "", "license": "MIT", "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.10", "@types/dom-webcodecs": "^0.1.11" }, "devDependencies": { diff --git a/src/codec.ts b/src/codec.ts new file mode 100644 index 0000000..73a4df7 --- /dev/null +++ b/src/codec.ts @@ -0,0 +1,139 @@ +import { AudioCodec, VideoCodec } from "./source"; + +export const buildVideoCodecString = (codec: VideoCodec, width: number, height: number) => { + if (codec === 'avc') { + let profileIndication = 0x64; // Default to High Profile + + if (width <= 768 && height <= 432) { + profileIndication = 0x42; // Baseline for smaller videos + } else if (width <= 1920 && height <= 1080) { + profileIndication = 0x4D; // Main for HD + } + + const profileCompatibility = 0x00; + + // Default to Level 4.1 (0x29) for most content, only bump to Level 5.0 (0x32) for 4K content + const levelIndication = (width > 1920 || height > 1080) ? 0x32 : 0x29; + + const hexProfileIndication = profileIndication.toString(16).padStart(2, '0'); + const hexProfileCompatibility = profileCompatibility.toString(16).padStart(2, '0'); + const hexLevelIndication = levelIndication.toString(16).padStart(2, '0'); + + return `avc1.${hexProfileIndication}${hexProfileCompatibility}${hexLevelIndication}`; + } else if (codec === 'hevc') { + // Start with general_profile_space and general_profile_idc + let profileSpace = 0; // Assuming general_profile_space == 0 (most common) + let profileIdc = 1; // Assuming Main Profile (1) + + // Generate compatibility flags (32 bits in reverse order) + // For basic compatibility, we'll set the main profile bit + const compatibilityFlags = Array(32).fill(0); + compatibilityFlags[profileIdc] = 1; // Set bit for current profile + const compatibilityHex = parseInt(compatibilityFlags.reverse().join(''), 2) + .toString(16) + .replace(/^0+/, ''); // Remove leading zeroes + + // Determine tier and level based on resolution + let tier = 'L'; // L for Main Tier, H for High Tier + let level = 120; // Default level 4.0 (120) + + // Adjust level based on resolution (simplified) + if (width <= 1280 && height <= 720) { + level = 93; // Level 3.1 + } else if (width <= 1920 && height <= 1080) { + level = 120; // Level 4.0 + } else if (width <= 3840 && height <= 2160) { + level = 150; // Level 5.0 + } else { + tier = 'H'; // Use High Tier for very high resolutions + level = 180; // Level 6.0 + } + + // Generate constraint flags (6 bytes) + // Using B0 as a simple default (progressive source flag) + const constraintFlags = 'B0'; + + // Construct the final string following the format + // If profile_space is 0, start with the profile_idc directly + const profilePrefix = profileSpace === 0 ? '' : + String.fromCharCode(65 + profileSpace - 1); + + return `hev1.${profilePrefix}${profileIdc}.${compatibilityHex}.${tier}${level}.${constraintFlags}`; + } else if (codec === 'vp8') { + return 'vp8'; // Easy, this one + } else if (codec === 'vp9') { + // Default to Profile 0 (most common) + const profile = "00"; + + // Determine level based on resolution + // VP9 levels are specified as two digits: major.minor + let level; + if (width <= 854 && height <= 480) { + level = "21"; // Level 2.1 + } else if (width <= 1280 && height <= 720) { + level = "31"; // Level 3.1 + } else if (width <= 1920 && height <= 1080) { + level = "41"; // Level 4.1 + } else if (width <= 3840 && height <= 2160) { + level = "51"; // Level 5.1 + } else { + level = "61"; // Level 6.1 + } + + // Default to 8-bit depth + const bitDepth = "08"; + + return `vp09.${profile}.${level}.${bitDepth}`; + } else if (codec === 'av1') { + // Default to Main Profile (0) + const profile = 0; + + // Determine level based on resolution + // Using a simplified level selection based on common resolutions + let level; + if (width <= 854 && height <= 480) { + level = "01"; // Level 2.1 + } else if (width <= 1280 && height <= 720) { + level = "03"; // Level 2.3 + } else if (width <= 1920 && height <= 1080) { + level = "04"; // Level 3.0 + } else if (width <= 3840 && height <= 2160) { + level = "07"; // Level 4.0 + } else { + level = "09"; // Level 4.2 + } + + // Default to Main tier + const tier = "M"; + + // Default to 8-bit depth + const bitDepth = "08"; + + return `av01.${profile}.${level}${tier}.${bitDepth}`; + } + + throw new Error(`Unhandled codec '${codec}'.`); +}; + +export const buildAudioCodecString = (codec: AudioCodec, numberOfChannels: number, sampleRate: number) => { + if (codec === 'aac') { + // If stereo or higher channels and lower sample rate, likely using HE-AAC v2 with PS + if (numberOfChannels >= 2 && sampleRate <= 24000) { + return 'mp4a.40.29'; // HE-AAC v2 (AAC LC + SBR + PS) + } + + // If sample rate is low, likely using HE-AAC v1 with SBR + if (sampleRate <= 24000) { + return 'mp4a.40.5'; // HE-AAC v1 (AAC LC + SBR) + } + + // Default to standard AAC-LC for higher sample rates + return 'mp4a.40.2'; // AAC-LC + } else if (codec === 'opus') { + return 'opus'; // Easy, this one + } else if (codec === 'vorbis') { + return 'vorbis'; // Also easy, this one + } + + throw new Error(`Unhandled codec '${codec}'.`); +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0781663..bcb7405 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,4 @@ -console.log("hi") \ No newline at end of file +export { Output } from './output'; +export { Mp4OutputFormat } from './output_format'; +export { VideoFrameSource, CanvasSource, MediaStreamVideoTrackSource, AudioDataSource, AudioBufferSource, MediaStreamAudioTrackSource } from './source'; +export { Target, ArrayBufferTarget, StreamTarget, FileSystemWritableFileStreamTarget } from './target'; diff --git a/src/isobmff/isobmff_boxes.ts b/src/isobmff/isobmff_boxes.ts new file mode 100644 index 0000000..0ce4c51 --- /dev/null +++ b/src/isobmff/isobmff_boxes.ts @@ -0,0 +1,840 @@ +import { assert, isU32, last, TransformationMatrix } from '../misc'; +import { AudioCodec, AudioSource, VideoCodec, VideoSource } from '../source'; +import { GLOBAL_TIMESCALE, intoTimescale, IsobmffAudioTrackData, IsobmffTrackData, IsobmffVideoTrackData, Sample } from './isobmff_muxer'; + +let bytes = new Uint8Array(8); +let view = new DataView(bytes.buffer); + +const u8 = (value: number) => { + return [(value % 0x100 + 0x100) % 0x100]; +}; + +const u16 = (value: number) => { + view.setUint16(0, value, false); + return [bytes[0], bytes[1]] as number[]; +}; + +const i16 = (value: number) => { + view.setInt16(0, value, false); + return [bytes[0], bytes[1]] as number[]; +}; + +const u24 = (value: number) => { + view.setUint32(0, value, false); + return [bytes[1], bytes[2], bytes[3]] as number[]; +}; + +const u32 = (value: number) => { + view.setUint32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; +}; + +const i32 = (value: number) => { + view.setInt32(0, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; +}; + +const u64 = (value: number) => { + view.setUint32(0, Math.floor(value / 2**32), false); + view.setUint32(4, value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]] as number[]; +}; + +const fixed_8_8 = (value: number) => { + view.setInt16(0, 2**8 * value, false); + return [bytes[0], bytes[1]] as number[]; +}; + +const fixed_16_16 = (value: number) => { + view.setInt32(0, 2**16 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; +}; + +const fixed_2_30 = (value: number) => { + view.setInt32(0, 2**30 * value, false); + return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; +}; + +const ascii = (text: string, nullTerminated = false) => { + let bytes = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i)); + if (nullTerminated) bytes.push(0x00); + return bytes; +}; + +const lastPresentedSample = (samples: Sample[]) => { + let result: Sample | null = null; + + for (let sample of samples) { + if (!result || sample.presentationTimestamp > result.presentationTimestamp) { + result = sample; + } + } + + return result; +}; + +const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => { + let theta = rotationInDegrees * (Math.PI / 180); + let cosTheta = Math.cos(theta); + let sinTheta = Math.sin(theta); + + // Matrices are post-multiplied in ISOBMFF, meaning this is the transpose of your typical rotation matrix + return [ + cosTheta, sinTheta, 0, + -sinTheta, cosTheta, 0, + 0, 0, 1 + ]; +}; + +const IDENTITY_MATRIX = rotationMatrix(0); + +const matrixToBytes = (matrix: TransformationMatrix) => { + return [ + fixed_16_16(matrix[0]), fixed_16_16(matrix[1]), fixed_2_30(matrix[2]), + fixed_16_16(matrix[3]), fixed_16_16(matrix[4]), fixed_2_30(matrix[5]), + fixed_16_16(matrix[6]), fixed_16_16(matrix[7]), fixed_2_30(matrix[8]) + ]; +}; + +export interface Box { + type: string, + contents?: Uint8Array, + children?: (Box | null)[], + size?: number, + largeSize?: boolean +} + +type NestedNumberArray = (number | NestedNumberArray)[]; + +export const box = (type: string, contents?: NestedNumberArray, children?: (Box | null)[]): Box => ({ + type, + contents: contents && new Uint8Array(contents.flat(10) as number[]), + children +}); + +/** A FullBox always starts with a version byte, followed by three flag bytes. */ +export const fullBox = ( + type: string, + version: number, + flags: number, + contents?: NestedNumberArray, + children?: Box[] +) => box( + type, + [u8(version), u24(flags), contents ?? []], + children +); + +/** + * File Type Compatibility Box: Allows the reader to determine whether this is a type of file that the + * reader understands. + */ +export const ftyp = (details: { + holdsAvc: boolean, + fragmented: boolean +}) => { + // You can find the full logic for this at + // https://github.com/FFmpeg/FFmpeg/blob/de2fb43e785773738c660cdafb9309b1ef1bc80d/libavformat/movenc.c#L5518 + // Obviously, this lib only needs a small subset of that logic. + + let minorVersion = 0x200; + + if (details.fragmented) return box('ftyp', [ + ascii('iso5'), // Major brand + u32(minorVersion), // Minor version + // Compatible brands + ascii('iso5'), + ascii('iso6'), + ascii('mp41') + ]); + + return box('ftyp', [ + ascii('isom'), // Major brand + u32(minorVersion), // Minor version + // Compatible brands + ascii('isom'), + details.holdsAvc ? ascii('avc1') : [], + ascii('mp41') + ]); +}; + +/** Movie Sample Data Box. Contains the actual frames/samples of the media. */ +export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize }); + +/** Free Space Box: A box that designates unused space in the movie data file. */ +export const free = (size: number): Box => ({ type: 'free', size }); + +/** + * Movie Box: Used to specify the information that defines a movie - that is, the information that allows + * an application to interpret the sample data that is stored elsewhere. + */ +export const moov = (trackDatas: IsobmffTrackData[], creationTime: number, fragmented = false) => box('moov', undefined, [ + mvhd(creationTime, trackDatas), + ...trackDatas.map(x => trak(x, creationTime)), + fragmented ? mvex(trackDatas) : null +]); + +/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */ +export const mvhd = ( + creationTime: number, + trackDatas: IsobmffTrackData[] +) => { + let duration = intoTimescale(Math.max( + 0, + ...trackDatas. + filter(x => x.samples.length > 0). + map(x => { + const lastSample = lastPresentedSample(x.samples)!; + return lastSample.presentationTimestamp + lastSample.duration; + }) + ), GLOBAL_TIMESCALE); + let nextTrackId = Math.max(...trackDatas.map(x => x.track.id)) + 1; + + // Conditionally use u64 if u32 isn't enough + let needsU64 = !isU32(creationTime) || !isU32(duration); + let u32OrU64 = needsU64 ? u64 : u32; + + return fullBox('mvhd', +needsU64, 0, [ + u32OrU64(creationTime), // Creation time + u32OrU64(creationTime), // Modification time + u32(GLOBAL_TIMESCALE), // Timescale + u32OrU64(duration), // Duration + fixed_16_16(1), // Preferred rate + fixed_8_8(1), // Preferred volume + Array(10).fill(0), // Reserved + matrixToBytes(IDENTITY_MATRIX), // Matrix + Array(24).fill(0), // Pre-defined + u32(nextTrackId) // Next track ID + ]); +}; + +/** + * Track Box: Defines a single track of a movie. A movie may consist of one or more tracks. Each track is + * independent of the other tracks in the movie and carries its own temporal and spatial information. Each Track Box + * contains its associated Media Box. + */ +export const trak = (trackData: IsobmffTrackData, creationTime: number) => box('trak', undefined, [ + tkhd(trackData, creationTime), + mdia(trackData, creationTime) +]); + +/** Track Header Box: Specifies the characteristics of a single track within a movie. */ +export const tkhd = ( + trackData: IsobmffTrackData, + creationTime: number +) => { + let lastSample = lastPresentedSample(trackData.samples); + let durationInGlobalTimescale = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + GLOBAL_TIMESCALE + ); + + let needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale); + let u32OrU64 = needsU64 ? u64 : u32; + + let matrix: TransformationMatrix; + if (trackData.type === 'video') { + const rotation = trackData.track.source.metadata.rotation; + matrix = rotation === undefined || typeof rotation === 'number' ? rotationMatrix(rotation ?? 0) : rotation; + } else { + matrix = IDENTITY_MATRIX; + } + + return fullBox('tkhd', +needsU64, 3, [ + u32OrU64(creationTime), // Creation time + u32OrU64(creationTime), // Modification time + u32(trackData.track.id), // Track ID + u32(0), // Reserved + u32OrU64(durationInGlobalTimescale), // Duration + Array(8).fill(0), // Reserved + u16(0), // Layer + u16(0), // Alternate group + fixed_8_8(trackData.type === 'audio' ? 1 : 0), // Volume + u16(0), // Reserved + matrixToBytes(matrix), // Matrix + fixed_16_16(trackData.type === 'video' ? trackData.info.width : 0), // Track width + fixed_16_16(trackData.type === 'video' ? trackData.info.height : 0) // Track height + ]); +}; + +/** Media Box: Describes and define a track's media type and sample data. */ +export const mdia = (trackData: IsobmffTrackData, creationTime: number) => box('mdia', undefined, [ + mdhd(trackData, creationTime), + hdlr(trackData.type === 'video' ? 'vide' : 'soun'), + minf(trackData) +]); + +/** Media Header Box: Specifies the characteristics of a media, including timescale and duration. */ +export const mdhd = ( + trackData: IsobmffTrackData, + creationTime: number +) => { + let lastSample = lastPresentedSample(trackData.samples); + let localDuration = intoTimescale( + lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0, + trackData.timescale + ); + + let needsU64 = !isU32(creationTime) || !isU32(localDuration); + let u32OrU64 = needsU64 ? u64 : u32; + + return fullBox('mdhd', +needsU64, 0, [ + u32OrU64(creationTime), // Creation time + u32OrU64(creationTime), // Modification time + u32(trackData.timescale), // Timescale + u32OrU64(localDuration), // Duration + u16(0b01010101_11000100), // Language ("und", undetermined) + u16(0) // Quality + ]); +}; + +/** Handler Reference Box: Specifies the media handler component that is to be used to interpret the media's data. */ +export const hdlr = (componentSubtype: string) => fullBox('hdlr', 0, 0, [ + ascii('mhlr'), // Component type + ascii(componentSubtype), // Component subtype + u32(0), // Component manufacturer + u32(0), // Component flags + u32(0), // Component flags mask + ascii('mp4-muxer-hdlr', true) // Component name +]); + +/** + * Media Information Box: Stores handler-specific information for a track's media data. The media handler uses this + * information to map from media time to media data and to process the media data. + */ +export const minf = (trackData: IsobmffTrackData) => box('minf', undefined, [ + trackData.type === 'video' ? vmhd() : smhd(), + dinf(), + stbl(trackData) +]); + +/** Video Media Information Header Box: Defines specific color and graphics mode information. */ +export const vmhd = () => fullBox('vmhd', 0, 1, [ + u16(0), // Graphics mode + u16(0), // Opcolor R + u16(0), // Opcolor G + u16(0) // Opcolor B +]); + +/** Sound Media Information Header Box: Stores the sound media's control information, such as balance. */ +export const smhd = () => fullBox('smhd', 0, 0, [ + u16(0), // Balance + u16(0) // Reserved +]); + +/** + * Data Information Box: Contains information specifying the data handler component that provides access to the + * media data. The data handler component uses the Data Information Box to interpret the media's data. + */ +export const dinf = () => box('dinf', undefined, [ + dref() +]); + +/** + * Data Reference Box: Contains tabular data that instructs the data handler component how to access the media's data. + */ +export const dref = () => fullBox('dref', 0, 0, [ + u32(1) // Entry count +], [ + url() +]); + +export const url = () => fullBox('url ', 0, 1); // Self-reference flag enabled + +/** + * Sample Table Box: Contains information for converting from media time to sample number to sample location. This box + * also indicates how to interpret the sample (for example, whether to decompress the video data and, if so, how). + */ +export const stbl = (trackData: IsobmffTrackData) => { + const needsCtts = trackData.compositionTimeOffsetTable.length > 1 || + trackData.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0); + + return box('stbl', undefined, [ + stsd(trackData), + stts(trackData), + stss(trackData), + stsc(trackData), + stsz(trackData), + stco(trackData), + needsCtts ? ctts(trackData) : null + ]); +}; + +/** + * Sample Description Box: Stores information that allows you to decode samples in the media. The data stored in the + * sample description varies, depending on the media type. + */ +export const stsd = (trackData: IsobmffTrackData) => fullBox('stsd', 0, 0, [ + u32(1) // Entry count +], [ + trackData.type === 'video' + ? videoSampleDescription( + VIDEO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData as IsobmffVideoTrackData + ) + : soundSampleDescription( + AUDIO_CODEC_TO_BOX_NAME[trackData.track.source.codec], + trackData as IsobmffAudioTrackData + ) +]); + +/** Video Sample Description Box: Contains information that defines how to interpret video media data. */ +export const videoSampleDescription = ( + compressionType: string, + trackData: IsobmffVideoTrackData +) => box(compressionType, [ + Array(6).fill(0), // Reserved + u16(1), // Data reference index + u16(0), // Pre-defined + u16(0), // Reserved + Array(12).fill(0), // Pre-defined + u16(trackData.info.width), // Width + u16(trackData.info.height), // Height + u32(0x00480000), // Horizontal resolution + u32(0x00480000), // Vertical resolution + u32(0), // Reserved + u16(1), // Frame count + Array(32).fill(0), // Compressor name + u16(0x0018), // Depth + i16(0xffff) // Pre-defined +], [ + VIDEO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) +]); + +/** 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 + ...new Uint8Array(trackData.info.decoderConfig.description as ArrayBuffer) +]); + +/** 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 + ...new Uint8Array(trackData.info.decoderConfig.description as ArrayBuffer) +]); + +/** VP Configuration Box: Provides additional information to the decoder. */ +export const vpcC = (trackData: IsobmffVideoTrackData) => { + // Reference: https://www.webmproject.org/vp9/mp4/ + + if (!trackData.info.decoderConfig) { + return null; + } + + let decoderConfig = trackData.info.decoderConfig; + if (!decoderConfig.colorSpace) { + throw new Error(`'colorSpace' is required in the decoder config for VP8/VP9.`); + } + + let parts = decoderConfig.codec.split('.'); + let profile = Number(parts[1]); + let level = Number(parts[2]); + + let bitDepth = Number(parts[3]); + let chromaSubsampling = 0; + let thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange); + + // Set all to undetermined. We could determine them using the codec color space info, but there's no need. + let colourPrimaries = 2; + let transferCharacteristics = 2; + let matrixCoefficients = 2; + + return fullBox('vpcC', 1, 0, [ + u8(profile), // Profile + u8(level), // Level + u8(thirdByte), // Bit depth, chroma subsampling, full range + u8(colourPrimaries), // Colour primaries + u8(transferCharacteristics), // Transfer characteristics + u8(matrixCoefficients), // Matrix coefficients + u16(0) // Codec initialization data size + ]); +}; + +/** AV1 Configuration Box: Provides additional information to the decoder. */ +export const av1C = () => { + // Reference: https://aomediacodec.github.io/av1-isobmff/ + + let marker = 1; + let version = 1; + let firstByte = (marker << 7) + version; + + // The box contents are not correct like this, but its length is. Getting the values for the last three bytes + // requires peeking into the bitstream of the coded chunks. Might come back later. + return box('av1C', [ + firstByte, + 0, + 0, + 0 + ]); +}; + +/** Sound Sample Description Box: Contains information that defines how to interpret sound media data. */ +export const soundSampleDescription = ( + compressionType: string, + trackData: IsobmffAudioTrackData +) => box(compressionType, [ + Array(6).fill(0), // Reserved + u16(1), // Data reference index + u16(0), // Version + u16(0), // Revision level + u32(0), // Vendor + u16(trackData.info.numberOfChannels), // Number of channels + u16(16), // Sample size (bits) + u16(0), // Compression ID + u16(0), // Packet size + fixed_16_16(trackData.info.sampleRate) // Sample rate +], [ + AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source.codec](trackData) +]); + +/** MPEG-4 Elementary Stream Descriptor Box. */ +export const esds = (trackData: IsobmffAudioTrackData) => { + let description = new Uint8Array(trackData.info.decoderConfig.description as ArrayBuffer); + + // TODO Compact the 808080 stuff, it's superfluous + + 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 + ]); +}; + +/** Opus Specific Box. */ +export const dOps = (trackData: IsobmffAudioTrackData) => { + // Default PreSkip, should be at least 80 milliseconds worth of playback, measured in 48000 Hz samples + let preskip = 3840; + let gain = 0; + + // Read preskip and from codec private data from the encoder + // 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.'); + } + + const view = ArrayBuffer.isView(description) + ? new DataView(description.buffer, description.byteOffset, description.byteLength) + : new DataView(description); + preskip = view.getUint16(10, true); + gain = view.getInt16(14, true); + } + + return box('dOps', [ + u8(0), // Version + u8(trackData.info.numberOfChannels), // OutputChannelCount + u16(preskip), + u32(trackData.info.sampleRate), // InputSampleRate + fixed_8_8(gain), // OutputGain + u8(0) // ChannelMappingFamily + ]); +}; + +/** + * Time-To-Sample Box: Stores duration information for a media's samples, providing a mapping from a time in a media + * to the corresponding data sample. The table is compact, meaning that consecutive samples with the same time delta + * will be grouped. + */ +export const stts = (trackData: IsobmffTrackData) => { + return fullBox('stts', 0, 0, [ + u32(trackData.timeToSampleTable.length), // Number of entries + trackData.timeToSampleTable.map(x => [ // Time-to-sample table + u32(x.sampleCount), // Sample count + u32(x.sampleDelta) // Sample duration + ]) + ]); +}; + +/** Sync Sample Box: Identifies the key frames in the media, marking the random access points within a stream. */ +export const stss = (trackData: IsobmffTrackData) => { + if (trackData.samples.every(x => x.type === 'key')) return null; // No stss box -> every frame is a key frame + + let keySamples = [...trackData.samples.entries()].filter(([, sample]) => sample.type === 'key'); + return fullBox('stss', 0, 0, [ + u32(keySamples.length), // Number of entries + keySamples.map(([index]) => u32(index + 1)) // Sync sample table + ]); +}; + +/** + * Sample-To-Chunk Box: As samples are added to a media, they are collected into chunks that allow optimized data + * access. A chunk contains one or more samples. Chunks in a media may have different sizes, and the samples within a + * chunk may have different sizes. The Sample-To-Chunk Box stores chunk information for the samples in a media, stored + * in a compactly-coded fashion. + */ +export const stsc = (trackData: IsobmffTrackData) => { + return fullBox('stsc', 0, 0, [ + u32(trackData.compactlyCodedChunkTable.length), // Number of entries + trackData.compactlyCodedChunkTable.map(x => [ // Sample-to-chunk table + u32(x.firstChunk), // First chunk + u32(x.samplesPerChunk), // Samples per chunk + u32(1) // Sample description index + ]) + ]); +}; + +/** Sample Size Box: Specifies the byte size of each sample in the media. */ +export const stsz = (trackData: IsobmffTrackData) => fullBox('stsz', 0, 0, [ + u32(0), // Sample size (0 means non-constant size) + u32(trackData.samples.length), // Number of entries + trackData.samples.map(x => u32(x.size)) // Sample size table +]); + +/** Chunk Offset Box: Identifies the location of each chunk of data in the media's data stream, relative to the file. */ +export const stco = (trackData: IsobmffTrackData) => { + if (trackData.finalizedChunks.length > 0 && last(trackData.finalizedChunks)!.offset! >= 2**32) { + // If the file is large, use the co64 box + return fullBox('co64', 0, 0, [ + u32(trackData.finalizedChunks.length), // Number of entries + trackData.finalizedChunks.map(x => u64(x.offset!)) // Chunk offset table + ]); + } + + return fullBox('stco', 0, 0, [ + u32(trackData.finalizedChunks.length), // Number of entries + trackData.finalizedChunks.map(x => u32(x.offset!)) // Chunk offset table + ]); +}; + +/** Composition Time to Sample Box: Stores composition time offset information (PTS-DTS) for a + * media's samples. The table is compact, meaning that consecutive samples with the same time + * composition time offset will be grouped. */ +export const ctts = (trackData: IsobmffTrackData) => { + return fullBox('ctts', 0, 0, [ + u32(trackData.compositionTimeOffsetTable.length), // Number of entries + trackData.compositionTimeOffsetTable.map(x => [ // Time-to-sample table + u32(x.sampleCount), // Sample count + u32(x.sampleCompositionTimeOffset) // Sample offset + ]) + ]); +}; + +/** + * Movie Extends Box: This box signals to readers that the file is fragmented. Contains a single Track Extends Box + * for each track in the movie. + */ +export const mvex = (trackDatas: IsobmffTrackData[]) => { + return box('mvex', undefined, trackDatas.map(trex)); +}; + +/** Track Extends Box: Contains the default values used by the movie fragments. */ +export const trex = (trackData: IsobmffTrackData) => { + return fullBox('trex', 0, 0, [ + u32(trackData.track.id), // Track ID + u32(1), // Default sample description index + u32(0), // Default sample duration + u32(0), // Default sample size + u32(0) // Default sample flags + ]); +}; + +/** + * Movie Fragment Box: The movie fragments extend the presentation in time. They provide the information that would + * previously have been in the Movie Box. + */ +export const moof = (sequenceNumber: number, trackDatas: IsobmffTrackData[]) => { + return box('moof', undefined, [ + mfhd(sequenceNumber), + ...trackDatas.map(traf) + ]); +}; + +/** Movie Fragment Header Box: Contains a sequence number as a safety check. */ +export const mfhd = (sequenceNumber: number) => { + return fullBox('mfhd', 0, 0, [ + u32(sequenceNumber) // Sequence number + ]); +}; + +const fragmentSampleFlags = (sample: Sample) => { + let byte1 = 0; + let byte2 = 0; + let byte3 = 0; + let byte4 = 0; + + let sampleIsDifferenceSample = sample.type === 'delta'; + byte2 |= +sampleIsDifferenceSample; + + if (sampleIsDifferenceSample) { + byte1 |= 1; // There is redundant coding in this sample + } else { + byte1 |= 2; // There is no redundant coding in this sample + } + + // Note that there are a lot of other flags to potentially set here, but most are irrelevant / non-necessary + return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4; +}; + +/** Track Fragment Box */ +export const traf = (trackData: IsobmffTrackData) => { + return box('traf', undefined, [ + tfhd(trackData), + tfdt(trackData), + trun(trackData) + ]); +}; + +/** Track Fragment Header Box: Provides a reference to the extended track, and flags. */ +export const tfhd = (trackData: IsobmffTrackData) => { + assert(trackData.currentChunk); + + let tfFlags = 0; + tfFlags |= 0x00008; // Default sample duration present + tfFlags |= 0x00010; // Default sample size present + tfFlags |= 0x00020; // Default sample flags present + tfFlags |= 0x20000; // Default base is moof + + // Prefer the second sample over the first one, as the first one is a sync sample and therefore the "odd one out" + let referenceSample = trackData.currentChunk.samples[1] ?? trackData.currentChunk.samples[0]!; + let referenceSampleInfo = { + duration: referenceSample.timescaleUnitsToNextSample, + size: referenceSample.size, + flags: fragmentSampleFlags(referenceSample) + }; + + return fullBox('tfhd', 0, tfFlags, [ + u32(trackData.track.id), // Track ID + u32(referenceSampleInfo.duration), // Default sample duration + u32(referenceSampleInfo.size), // Default sample size + u32(referenceSampleInfo.flags) // Default sample flags + ]); +}; + +/** + * Track Fragment Decode Time Box: Provides the absolute decode time of the first sample of the fragment. This is + * useful for performing random access on the media file. + */ +export const tfdt = (trackData: IsobmffTrackData) => { + assert(trackData.currentChunk); + + return fullBox('tfdt', 1, 0, [ + u64(intoTimescale(trackData.currentChunk.startTimestamp, trackData.timescale)) // Base Media Decode Time + ]); +}; + +/** Track Run Box: Specifies a run of contiguous samples for a given track. */ +export const trun = (trackData: IsobmffTrackData) => { + assert(trackData.currentChunk); + + let allSampleDurations = trackData.currentChunk.samples.map(x => x.timescaleUnitsToNextSample); + let allSampleSizes = trackData.currentChunk.samples.map(x => x.size); + let allSampleFlags = trackData.currentChunk.samples.map(fragmentSampleFlags); + let allSampleCompositionTimeOffsets = trackData.currentChunk.samples. + map(x => intoTimescale(x.presentationTimestamp - x.decodeTimestamp, trackData.timescale)); + + let uniqueSampleDurations = new Set(allSampleDurations); + let uniqueSampleSizes = new Set(allSampleSizes); + let uniqueSampleFlags = new Set(allSampleFlags); + let uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets); + + let firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1]; + let sampleDurationPresent = uniqueSampleDurations.size > 1; + let sampleSizePresent = uniqueSampleSizes.size > 1; + let sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1; + let sampleCompositionTimeOffsetsPresent = + uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some(x => x !== 0); + + let flags = 0; + flags |= 0x0001; // Data offset present + flags |= 0x0004 * +firstSampleFlagsPresent; // First sample flags present + flags |= 0x0100 * +sampleDurationPresent; // Sample duration present + flags |= 0x0200 * +sampleSizePresent; // Sample size present + flags |= 0x0400 * +sampleFlagsPresent; // Sample flags present + flags |= 0x0800 * +sampleCompositionTimeOffsetsPresent; // Sample composition time offsets present + + return fullBox('trun', 1, flags, [ + u32(trackData.currentChunk.samples.length), // Sample count + u32(trackData.currentChunk.offset! - trackData.currentChunk.moofOffset! || 0), // Data offset + firstSampleFlagsPresent ? u32(allSampleFlags[0]!) : [], + trackData.currentChunk.samples.map((_, i) => [ + sampleDurationPresent ? u32(allSampleDurations[i]!) : [], // Sample duration + sampleSizePresent ? u32(allSampleSizes[i]!) : [], // Sample size + sampleFlagsPresent ? u32(allSampleFlags[i]!) : [], // Sample flags + // Sample composition time offsets + sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]!) : [] + ]) + ]); +}; + +/** + * Movie Fragment Random Access Box: For each track, provides pointers to sync samples within the file + * for random access. + */ +export const mfra = (trackDatas: IsobmffTrackData[]) => { + return box('mfra', undefined, [ + ...trackDatas.map(tfra), + mfro() + ]); +}; + +/** Track Fragment Random Access Box: Provides pointers to sync samples within the file for random access. */ +export const tfra = (trackData: IsobmffTrackData, trackIndex: number) => { + let version = 1; // Using this version allows us to use 64-bit time and offset values + + return fullBox('tfra', version, 0, [ + u32(trackData.track.id), // Track ID + u32(0b111111), // This specifies that traf number, trun number and sample number are 32-bit ints + u32(trackData.finalizedChunks.length), // Number of entries + trackData.finalizedChunks.map(chunk => [ + u64(intoTimescale(chunk.startTimestamp, trackData.timescale)), // Time + u64(chunk.moofOffset!), // moof offset + u32(trackIndex + 1), // traf number + u32(1), // trun number + u32(1) // Sample number + ]) + ]); +}; + +/** + * Movie Fragment Random Access Offset Box: Provides the size of the enclosing mfra box. This box can be used by readers + * to quickly locate the mfra box by searching from the end of the file. + */ +export const mfro = () => { + return fullBox('mfro', 0, 0, [ + // This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box + // is known + u32(0) // Size + ]); +}; + +const VIDEO_CODEC_TO_BOX_NAME: Record = { + 'avc': 'avc1', + 'hevc': 'hvc1', + 'vp8': 'vp08', + 'vp9': 'vp09', + 'av1': 'av01' +}; + +const VIDEO_CODEC_TO_CONFIGURATION_BOX: Record Box | null> = { + 'avc': avcC, + 'hevc': hvcC, + 'vp8': vpcC, + 'vp9': vpcC, + 'av1': av1C +}; + +const AUDIO_CODEC_TO_BOX_NAME: Record = { + 'aac': 'mp4a', + 'opus': 'Opus' +}; + +const AUDIO_CODEC_TO_CONFIGURATION_BOX: Record Box | null> = { + 'aac': esds, + 'opus': dOps +}; diff --git a/src/isobmff/isobmff_muxer.ts b/src/isobmff/isobmff_muxer.ts new file mode 100644 index 0000000..af738ba --- /dev/null +++ b/src/isobmff/isobmff_muxer.ts @@ -0,0 +1,760 @@ +import { Box, free, ftyp, mdat, mfra, moof, moov } from './isobmff_boxes'; +import { Muxer } from '../muxer'; +import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from '../output'; +import { Writer } from '../writer'; +import { assert, last, TransformationMatrix } from '../misc'; +import { Mp4OutputFormat } from '../output_format'; + +export const GLOBAL_TIMESCALE = 1000; +const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970 + +export type Sample = { + presentationTimestamp: number, + decodeTimestamp: number, + duration: number, + data: Uint8Array | null, + size: number, + type: 'key' | 'delta', + timescaleUnitsToNextSample: number +}; + +type Chunk = { + startTimestamp: number, + samples: Sample[], + offset: number | null, + // In the case of a fragmented file, this indicates the position of the moof box pointing to the data in this chunk + moofOffset: number | null +}; + +export type IsobmffTrackData = { + timescale: number, + samples: Sample[], + sampleQueue: Sample[], // For fragmented files + + firstDecodeTimestamp: number | null, + lastDecodeTimestamp: number, + + timeToSampleTable: { sampleCount: number, sampleDelta: number }[]; + compositionTimeOffsetTable: { sampleCount: number, sampleCompositionTimeOffset: number }[]; + lastTimescaleUnits: number | null, + lastSample: Sample | null, + + finalizedChunks: Chunk[], + currentChunk: Chunk | null, + compactlyCodedChunkTable: { + firstChunk: number, + samplesPerChunk: number + }[] +} & ({ + track: OutputVideoTrack, + type: 'video', + info: { + width: number, + height: number, + decoderConfig: VideoDecoderConfig + } +} | { + track: OutputAudioTrack, + type: 'audio', + info: { + numberOfChannels: number, + sampleRate: number, + decoderConfig: AudioDecoderConfig + } +}); + +export type IsobmffVideoTrackData = IsobmffTrackData & { type: 'video' }; +export type IsobmffAudioTrackData = IsobmffTrackData & { type: 'audio' }; + +export const intoTimescale = (timeInSeconds: number, timescale: number, round = true) => { + let value = timeInSeconds * timescale; + return round ? Math.round(value) : value; +}; + +export class IsobmffMuxer extends Muxer { + #writer: Writer; + #format: Mp4OutputFormat; + #helper = new Uint8Array(8); + #helperView = new DataView(this.#helper.buffer); + + /** + * Stores the position from the start of the file to where boxes 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(); + + #ftypSize: number | null = null; + #mdat: Box | null = null; + + #trackDatas: IsobmffTrackData[] = []; + + #creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET; + #finalizedChunks: Chunk[] = []; + + #nextFragmentNumber = 1; + + constructor(output: Output, format: Mp4OutputFormat) { + super(output); + + this.#writer = output.writer; + this.#format = format; + } + + writeU32(value: number) { + this.#helperView.setUint32(0, value, false); + this.#writer.write(this.#helper.subarray(0, 4)); + } + + writeU64(value: number) { + this.#helperView.setUint32(0, Math.floor(value / 2**32), false); + this.#helperView.setUint32(4, value, false); + this.#writer.write(this.#helper.subarray(0, 8)); + } + + writeAscii(text: string) { + for (let i = 0; i < text.length; i++) { + this.#helperView.setUint8(i % 8, text.charCodeAt(i)); + if (i % 8 === 7) this.#writer.write(this.#helper); + } + + if (text.length % 8 !== 0) { + this.#writer.write(this.#helper.subarray(0, text.length % 8)); + } + } + + writeBox(box: Box) { + this.offsets.set(box, this.#writer.getPos()); + + if (box.contents && !box.children) { + this.writeBoxHeader(box, box.size ?? box.contents.byteLength + 8); + this.#writer.write(box.contents); + } else { + let startPos = this.#writer.getPos(); + this.writeBoxHeader(box, 0); + + if (box.contents) this.#writer.write(box.contents); + if (box.children) for (let child of box.children) if (child) this.writeBox(child); + + let endPos = this.#writer.getPos(); + let size = box.size ?? endPos - startPos; + this.#writer.seek(startPos); + this.writeBoxHeader(box, size); + this.#writer.seek(endPos); + } + } + + writeBoxHeader(box: Box, size: number) { + this.writeU32(box.largeSize ? 1 : size); + this.writeAscii(box.type); + if (box.largeSize) this.writeU64(size); + } + + measureBoxHeader(box: Box) { + return 8 + (box.largeSize ? 8 : 0); + } + + patchBox(box: Box) { + const boxOffset = this.offsets.get(box); + assert(boxOffset !== undefined); + + let endPos = this.#writer.getPos(); + this.#writer.seek(boxOffset); + this.writeBox(box); + this.#writer.seek(endPos); + } + + measureBox(box: Box) { + if (box.contents && !box.children) { + let headerSize = this.measureBoxHeader(box); + return headerSize + box.contents.byteLength; + } else { + let result = this.measureBoxHeader(box); + if (box.contents) result += box.contents.byteLength; + if (box.children) for (let child of box.children) if (child) result += this.measureBox(child); + + return result; + } + } + + start() { + this.#writeHeader(); + } + + #writeHeader() { + const holdsAvc = this.output.tracks.some(x => x.type === 'video' && x.source.codec === 'avc'); + + this.writeBox(ftyp({ + holdsAvc: holdsAvc, + fragmented: this.#format.options.fastStart === 'fragmented' + })); + + this.#ftypSize = this.#writer.getPos(); + + if (this.#format.options.fastStart === 'in-memory') { + this.#mdat = mdat(false); + } else if (this.#format.options.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.writeBox(this.#mdat); + } + + 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, chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) { + const existingTrackData = this.#trackDatas.find(x => x.track === track); + if (existingTrackData) { + return existingTrackData; + } + + // TODO Make proper errors for these + assert(meta); + assert(meta.decoderConfig); + assert(meta.decoderConfig.codedWidth); + assert(meta.decoderConfig.codedHeight); + + const newTrackData: IsobmffTrackData = { + track, + type: 'video', + info: { + width: meta.decoderConfig.codedWidth, + height: meta.decoderConfig.codedHeight, + decoderConfig: meta.decoderConfig + }, + timescale: track.source.metadata.frameRate ?? 57600, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + + 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; + } + + // TODO Make proper errors for these + assert(meta); + assert(meta.decoderConfig); + + const newTrackData: IsobmffTrackData = { + track, + type: 'audio', + info: { + numberOfChannels: meta.decoderConfig.numberOfChannels, + sampleRate: meta.decoderConfig.sampleRate, + decoderConfig: meta.decoderConfig + }, + timescale: meta.decoderConfig.sampleRate, + samples: [], + sampleQueue: [], + firstDecodeTimestamp: null, + lastDecodeTimestamp: -1, + timeToSampleTable: [], + compositionTimeOffsetTable: [], + lastTimescaleUnits: null, + lastSample: null, + finalizedChunks: [], + currentChunk: null, + compactlyCodedChunkTable: [] + }; + + 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); + + 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 videoSample = this.#createSampleForTrack(trackData, chunk, compositionTimeOffset); + + if (this.#format.options.fastStart === 'fragmented') { + trackData.sampleQueue.push(videoSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, videoSample); + } + } + + addEncodedAudioChunk(track: OutputAudioTrack, chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) { + const trackData = this.#getAudioTrackData(track, chunk, 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 audioSample = this.#createSampleForTrack(trackData, chunk); + + if (this.#format.options.fastStart === 'fragmented') { + trackData.sampleQueue.push(audioSample); + this.#interleaveSamples(); + } else { + this.#addSampleToTrack(trackData, audioSample); + } + } + + #createSampleForTrack( + trackData: IsobmffTrackData, + chunk: EncodedVideoChunk | EncodedAudioChunk, + compositionTimeOffset?: number + ) { + let presentationTimestampInSeconds = chunk.timestamp / 1e6; + let decodeTimestampInSeconds = (chunk.timestamp - (compositionTimeOffset ?? 0)) / 1e6; + let durationInSeconds = (chunk.duration ?? 0) / 1e6; + + let adjusted = this.#validateTimestamp(trackData, presentationTimestampInSeconds, decodeTimestampInSeconds); + presentationTimestampInSeconds = adjusted.presentationTimestamp; + decodeTimestampInSeconds = adjusted.decodeTimestamp; + + let data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + + let sample: Sample = { + presentationTimestamp: presentationTimestampInSeconds, + decodeTimestamp: decodeTimestampInSeconds, + duration: durationInSeconds, + data: data, + size: data.byteLength, + type: chunk.type, + // Will be refined once the next sample comes in + timescaleUnitsToNextSample: intoTimescale(durationInSeconds, trackData.timescale) + }; + + return sample; + } + + #addSampleToTrack( + trackData: IsobmffTrackData, + sample: Sample + ) { + if (this.#format.options.fastStart !== 'fragmented') { + trackData.samples.push(sample); + } + + const sampleCompositionTimeOffset = + intoTimescale(sample.presentationTimestamp - sample.decodeTimestamp, trackData.timescale); + + if (trackData.lastTimescaleUnits !== null) { + assert(trackData.lastSample); + + let timescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false); + let delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits); + trackData.lastTimescaleUnits += delta; + trackData.lastSample.timescaleUnitsToNextSample = delta; + + if (this.#format.options.fastStart !== 'fragmented') { + let lastTableEntry = last(trackData.timeToSampleTable); + assert(lastTableEntry); + + if (lastTableEntry.sampleCount === 1) { + // If we hit this case, we're the second sample + lastTableEntry.sampleDelta = delta; + lastTableEntry.sampleCount++; + } else if (lastTableEntry.sampleDelta === delta) { + // Simply increment the count + lastTableEntry.sampleCount++; + } else { + // The delta has changed, subtract one from the previous run and create a new run with the new delta + lastTableEntry.sampleCount--; + trackData.timeToSampleTable.push({ + sampleCount: 2, + sampleDelta: delta + }); + } + + const lastCompositionTimeOffsetTableEntry = last(trackData.compositionTimeOffsetTable); + assert(lastCompositionTimeOffsetTableEntry); + + if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) { + // Simply increment the count + lastCompositionTimeOffsetTableEntry.sampleCount++; + } else { + // The composition time offset has changed, so create a new entry with the new composition time + // offset + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset: sampleCompositionTimeOffset + }); + } + } + } else { + trackData.lastTimescaleUnits = 0; + + if (this.#format.options.fastStart !== 'fragmented') { + trackData.timeToSampleTable.push({ + sampleCount: 1, + sampleDelta: intoTimescale(sample.duration, trackData.timescale) + }); + trackData.compositionTimeOffsetTable.push({ + sampleCount: 1, + sampleCompositionTimeOffset: sampleCompositionTimeOffset + }); + } + } + + trackData.lastSample = sample; + + let beginNewChunk = false; + if (!trackData.currentChunk) { + beginNewChunk = true; + } else { + let currentChunkDuration = sample.presentationTimestamp - trackData.currentChunk.startTimestamp; + + if (this.#format.options.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 => { + if (trackData === otherTrackData) { + return sample.type === 'key'; + } + + const firstQueuedSample = otherTrackData.sampleQueue[0]; + return firstQueuedSample && firstQueuedSample.type === 'key'; + }); + + if (currentChunkDuration >= 1.0 && keyFrameQueuedEverywhere) { + beginNewChunk = true; + this.#finalizeFragment(); + } + } else { + beginNewChunk = currentChunkDuration >= 0.5; // Chunk is long enough, we need a new one + } + } + + if (beginNewChunk) { + if (trackData.currentChunk) { + this.#finalizeCurrentChunk(trackData); + } + + trackData.currentChunk = { + startTimestamp: sample.presentationTimestamp, + samples: [], + offset: null, + moofOffset: null + }; + } + + assert(trackData.currentChunk); + trackData.currentChunk.samples.push(sample); + } + + #validateTimestamp(trackData: IsobmffTrackData, presentationTimestamp: number, decodeTimestamp: number) { + if (trackData.firstDecodeTimestamp === null) { + trackData.firstDecodeTimestamp = decodeTimestamp; + } + + decodeTimestamp -= trackData.firstDecodeTimestamp; + presentationTimestamp -= trackData.firstDecodeTimestamp; + + if (decodeTimestamp < trackData.lastDecodeTimestamp) { + throw new Error( + `Timestamps must be monotonically increasing ` + + `(timestamp went from ${trackData.lastDecodeTimestamp}s to ${decodeTimestamp}s).` + ); + } + + trackData.lastDecodeTimestamp = decodeTimestamp; + + return { presentationTimestamp, decodeTimestamp }; + } + + #finalizeCurrentChunk(trackData: IsobmffTrackData) { + assert(this.#format.options.fastStart !== 'fragmented'); + + if (!trackData.currentChunk) return; + + trackData.finalizedChunks.push(trackData.currentChunk); + this.#finalizedChunks.push(trackData.currentChunk); + + if ( + trackData.compactlyCodedChunkTable.length === 0 + || last(trackData.compactlyCodedChunkTable)!.samplesPerChunk !== trackData.currentChunk.samples.length + ) { + trackData.compactlyCodedChunkTable.push({ + firstChunk: trackData.finalizedChunks.length, // 1-indexed + samplesPerChunk: trackData.currentChunk.samples.length + }); + } + + if (this.#format.options.fastStart === 'in-memory') { + trackData.currentChunk.offset = 0; // We'll compute the proper offset when finalizing + return; + } + + // Write out the data + trackData.currentChunk.offset = this.#writer.getPos(); + for (let sample of trackData.currentChunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; // Can be GC'd + } + + this.#writer.flush(); + } + + #interleaveSamples() { + assert(this.#format.options.fastStart === 'fragmented'); + + if (this.#trackDatas.length < this.output.tracks.length) { + return; // We haven't seen a sample from each track yet + } + + outer: + while (true) { + let trackWithMinDecodeTimestamp: IsobmffTrackData | null = null; + let minDecodeTimestamp = Infinity; + + for (let trackData of this.#trackDatas) { + if (trackData.sampleQueue.length === 0) { + break outer; + } + + if (trackData.sampleQueue[0]!.decodeTimestamp < minDecodeTimestamp) { + trackWithMinDecodeTimestamp = trackData; + minDecodeTimestamp = trackData.sampleQueue[0]!.decodeTimestamp; + } + } + + if (!trackWithMinDecodeTimestamp) { + break; + } + + let sample = trackWithMinDecodeTimestamp.sampleQueue.shift()!; + this.#addSampleToTrack(trackWithMinDecodeTimestamp, sample); + } + } + + #finalizeFragment(flushWriter = true) { + assert(this.#format.options.fastStart === 'fragmented'); + + let fragmentNumber = this.#nextFragmentNumber++; + + if (fragmentNumber === 1) { + // Write the moov box now that we have all decoder configs + let movieBox = moov(this.#trackDatas, this.#creationTime, true); + this.writeBox(movieBox); + } + + // Write out an initial moof box; will be overwritten later once actual chunk offsets are known + let moofOffset = this.#writer.getPos(); + let moofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(moofBox); + + // Create the mdat box + { + let mdatBox = mdat(false); // Initially assume no fragment is larger than 4 GiB + let totalTrackSampleSize = 0; + + // Compute the size of the mdat box + for (let trackData of this.#trackDatas) { + assert(trackData.currentChunk); + for (let sample of trackData.currentChunk.samples) { + totalTrackSampleSize += sample.size; + } + } + + let mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + if (mdatSize >= 2**32) { + // Fragment is larger than 4 GiB, we need to use the large size + mdatBox.largeSize = true; + mdatSize = this.measureBox(mdatBox) + totalTrackSampleSize; + } + + mdatBox.size = mdatSize; + this.writeBox(mdatBox); + } + + // Write sample data + for (let trackData of this.#trackDatas) { + trackData.currentChunk!.offset = this.#writer.getPos(); + trackData.currentChunk!.moofOffset = moofOffset; + + for (let sample of trackData.currentChunk!.samples) { + this.#writer.write(sample.data!); + sample.data = null; // Can be GC'd + } + } + + // Now that we set the actual chunk offsets, fix the moof box + let endPos = this.#writer.getPos(); + this.#writer.seek(this.offsets.get(moofBox)!); + let newMoofBox = moof(fragmentNumber, this.#trackDatas); + this.writeBox(newMoofBox); + this.#writer.seek(endPos); + + for (let trackData of this.#trackDatas) { + trackData.finalizedChunks.push(trackData.currentChunk!); + this.#finalizedChunks.push(trackData.currentChunk!); + trackData.currentChunk = null; + } + + if (flushWriter) { + this.#writer.flush(); + } + } + + /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */ + finalize() { + if (this.#format.options.fastStart === 'fragmented') { + for (let trackData of this.#trackDatas) { + for (let sample of trackData.sampleQueue) { + this.#addSampleToTrack(trackData, sample); + } + } + + this.#finalizeFragment(false); // Don't flush the last fragment as we will flush it with the mfra box soon + } else { + for (let trackData of this.#trackDatas) { + this.#finalizeCurrentChunk(trackData); + } + } + + if (this.#format.options.fastStart === 'in-memory') { + assert(this.#mdat); + let mdatSize: number; + + // We know how many chunks there are, but computing the chunk positions requires an iterative approach: + // In order to know where the first chunk should go, we first need to know the size of the moov box. But we + // cannot write a proper moov box without first knowing all chunk positions. So, we generate a tentative + // moov box with placeholder values (0) for the chunk offsets to be able to compute its size. If it then + // turns out that appending all chunks exceeds 4 GiB, we need to repeat this process, now with the co64 box + // being used in the moov box instead, which will make it larger. After that, we definitely know the final + // size of the moov box and can compute the proper chunk positions. + + for (let i = 0; i < 2; i++) { + let movieBox = moov(this.#trackDatas, this.#creationTime); + let movieBoxSize = this.measureBox(movieBox); + mdatSize = this.measureBox(this.#mdat); + let currentChunkPos = this.#writer.getPos() + movieBoxSize + mdatSize; + + for (let chunk of this.#finalizedChunks) { + chunk.offset = currentChunkPos; + for (let { data } of chunk.samples) { + assert(data); + currentChunkPos += data.byteLength; + mdatSize += data.byteLength; + } + } + + if (currentChunkPos < 2**32) break; + if (mdatSize >= 2**32) this.#mdat.largeSize = true; + } + + let movieBox = moov(this.#trackDatas, this.#creationTime); + this.writeBox(movieBox); + + this.#mdat.size = mdatSize!; + this.writeBox(this.#mdat); + + for (let chunk of this.#finalizedChunks) { + for (let sample of chunk.samples) { + assert(sample.data); + this.#writer.write(sample.data); + sample.data = null; + } + } + } else if (this.#format.options.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); + this.writeBox(mfraBox); + + // Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size + let mfraBoxSize = this.#writer.getPos() - startPos; + this.#writer.seek(this.#writer.getPos() - 4); + this.writeU32(mfraBoxSize); + } else { + assert(this.#mdat); + assert(this.#ftypSize !== null); + + let mdatPos = this.offsets.get(this.#mdat); + assert(mdatPos !== undefined); + let mdatSize = this.#writer.getPos() - mdatPos; + this.#mdat.size = mdatSize; + this.#mdat.largeSize = mdatSize >= 2**32; // Only use the large size if we need it + this.patchBox(this.#mdat); + + let movieBox = moov(this.#trackDatas, this.#creationTime); + + if (typeof this.#format.options.fastStart === 'object') { + this.#writer.seek(this.#ftypSize); + this.writeBox(movieBox); + + let remainingBytes = mdatPos - this.#writer.getPos(); + this.writeBox(free(remainingBytes)); + } else { + this.writeBox(movieBox); + } + } + } +} diff --git a/src/misc.ts b/src/misc.ts new file mode 100644 index 0000000..10ae52e --- /dev/null +++ b/src/misc.ts @@ -0,0 +1,15 @@ +export function assert(x: unknown): asserts x { + if (!x) { + throw new Error('Assertion failed.'); + } +} + +export type TransformationMatrix = [number, number, number, number, number, number, number, number, number]; + +export const last = (arr: T[]) => { + return arr && arr[arr.length - 1]; +}; + +export const isU32 = (value: number) => { + return value >= 0 && value < 2**32; +}; diff --git a/src/muxer.ts b/src/muxer.ts new file mode 100644 index 0000000..936b839 --- /dev/null +++ b/src/muxer.ts @@ -0,0 +1,14 @@ +import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from "./output"; + +export abstract class Muxer { + output: Output; + + constructor(output: Output) { + this.output = output; + } + + abstract start(): void; + abstract addEncodedVideoChunk(track: OutputVideoTrack, chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata, compositionTimeOffset?: number): void; + abstract addEncodedAudioChunk(track: OutputAudioTrack, chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void; + abstract finalize(): void; +} \ No newline at end of file diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..2f57f3a --- /dev/null +++ b/src/output.ts @@ -0,0 +1,84 @@ +import { Muxer } from "./muxer"; +import { OutputFormat } from "./output_format"; +import { AudioSource, VideoSource } from "./source"; +import { Target } from "./target"; +import { Writer } from "./writer"; + +type OutputOptions = { + format: OutputFormat, + target: Target +}; + +export type OutputTrack = { + id: number, + output: Output +} & ({ + type: 'video', + source: VideoSource +} | { + type: 'audio', + source: AudioSource +}); + +export type OutputVideoTrack = OutputTrack & { type: 'video' }; +export type OutputAudioTrack = OutputTrack & { type: 'audio' }; + +export class Output { + muxer: Muxer; + writer: Writer; + tracks: OutputTrack[] = []; + started = false; + finalizing = false; + + constructor(options: OutputOptions) { + this.writer = options.target.createWriter(); + this.muxer = options.format.createMuxer(this); + } + + addTrack(source: VideoSource | AudioSource) { + if (this.started) { + throw new Error('Cannot add track after output has started.'); + } + if (source.connectedTrack) { + throw new Error('Source is already used for a track.'); + } + + const track = { + id: this.tracks.length + 1, + output: this, + type: source instanceof VideoSource ? 'video' : 'audio', + source + } as OutputTrack; + + this.tracks.push(track); + source.connectedTrack = track; + } + + start() { + if (this.started) { + throw new Error('Output already started.'); + } + + this.started = true; + this.muxer.start(); + + for (const track of this.tracks) { + track.source.start(); + } + } + + async finalize() { + if (this.finalizing) { + throw new Error('Cannot call finalize twice.'); + } + this.finalizing = true; + + const promises = this.tracks.map(x => x.source.flush()); + await Promise.all(promises); + + this.muxer.finalize(); + + this.writer.flush(); + this.writer.finalize(); + } +} \ No newline at end of file diff --git a/src/output_format.ts b/src/output_format.ts new file mode 100644 index 0000000..b7d45aa --- /dev/null +++ b/src/output_format.ts @@ -0,0 +1,22 @@ +import { IsobmffMuxer } from "./isobmff/isobmff_muxer"; +import { Muxer } from "./muxer"; +import { Output } from "./output"; + +export abstract class OutputFormat { + abstract createMuxer(output: Output): Muxer; +} + +export class Mp4OutputFormat extends OutputFormat { + constructor(public options: { + fastStart: false | 'in-memory' | 'fragmented' | { + expectedVideoChunks?: number, + expectedAudioChunks?: number + }, + }) { + super(); + } + + override createMuxer(output: Output) { + return new IsobmffMuxer(output, this); + } +} diff --git a/src/source.ts b/src/source.ts new file mode 100644 index 0000000..7c3d9cb --- /dev/null +++ b/src/source.ts @@ -0,0 +1,346 @@ +import { buildAudioCodecString, buildVideoCodecString } from "./codec"; +import { assert, TransformationMatrix } from "./misc"; +import { OutputAudioTrack, OutputTrack, OutputVideoTrack } from "./output"; + +export type VideoCodec = 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1'; +export type AudioCodec = 'aac' | 'opus' | 'vorbis'; // TODO add the rest + +type VideoSourceMetadata = { + rotation?: 0 | 90 | 180 | 270 | TransformationMatrix, + frameRate?: number +}; +type AudioSourceMetadata = {}; + +export abstract class VideoSource { + connectedTrack: OutputVideoTrack | null = null; + codec: VideoCodec; + metadata: VideoSourceMetadata; + + constructor(codec: VideoCodec, metadata: VideoSourceMetadata) { + this.codec = codec; + this.metadata = metadata; + } + + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error('Cannot call digest after output has started finalizing.'); + } + } + + start() {} + async flush() {} +} + +export abstract class AudioSource { + connectedTrack: OutputAudioTrack | null = null; + codec: AudioCodec; + metadata: AudioSourceMetadata; + + constructor(codec: AudioCodec, metadata: AudioSourceMetadata) { + this.codec = codec; + this.metadata = metadata; + } + + ensureNotFinalizing() { + if (this.connectedTrack?.output.finalizing) { + throw new Error('Cannot call digest after output has started finalizing.'); + } + } + + start() {} + async flush() {} +} + +export type VideoCodecConfig = { + codec: 'avc' | 'hevc' | 'vp8' | 'vp9' | 'av1', + bitrate: number +}; + +export type AudioCodecConfig = { + codec: 'aac' | 'opus' | 'vorbis', + bitrate: number +}; + +export class EncodedVideoChunkSource extends VideoSource { + constructor(codec: VideoCodec, options: VideoSourceMetadata = {}) { + super(codec, options); + } + + digest(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata, compositionTimeOffset?: number) { + this.ensureNotFinalizing(); + this.connectedTrack?.output.muxer.addEncodedVideoChunk(this.connectedTrack, chunk, meta, compositionTimeOffset); + } +} + +const KEY_FRAME_INTERVAL = 5; + +class VideoEncoderWrapper { + private encoder: VideoEncoder | null = null; + private lastMultipleOfKeyFrameInterval = -1; + + constructor(private source: VideoSource, private codecConfig: VideoCodecConfig) {} + + digest(videoFrame: VideoFrame) { + this.source.ensureNotFinalizing(); + + this.ensureEncoder(videoFrame); + assert(this.encoder); + + const multipleOfKeyFrameInterval = Math.floor((videoFrame.timestamp / 1e6) / KEY_FRAME_INTERVAL); + + // Ensure a key frame every KEY_FRAME_INTERVAL seconds. It is important that all video tracks follow the same + // "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters + // in Matroska. + this.encoder.encode(videoFrame, { keyFrame: multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval }); + + this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; + } + + private ensureEncoder(videoFrame: VideoFrame) { + if (this.encoder) { + return; + } + + this.encoder = new VideoEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedVideoChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error), // TODO + }); + + this.encoder.configure({ + codec: buildVideoCodecString(this.codecConfig.codec, videoFrame.codedWidth, videoFrame.codedHeight), + width: videoFrame.codedWidth, + height: videoFrame.codedHeight, + bitrate: this.codecConfig.bitrate, + }); + } + + async flush() { + return this.encoder?.flush(); + } +} + +export class VideoFrameSource extends VideoSource { + private encoder: VideoEncoderWrapper; + + constructor(codecConfig: VideoCodecConfig, options: VideoSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + + digest(videoFrame: VideoFrame) { + this.encoder.digest(videoFrame); + } + + override flush() { + return this.encoder.flush(); + } +} + +export class CanvasSource extends VideoSource { + private encoder: VideoEncoderWrapper; + + constructor(private canvas: HTMLCanvasElement, codecConfig: VideoCodecConfig, options: VideoSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + + digest(timestamp: number, duration = 0) { + const frame = new VideoFrame(this.canvas, { + timestamp: Math.round(1e6 * timestamp), + duration: Math.round(1e6 * duration), + }); + + this.encoder.digest(frame); + frame.close(); + } + + override flush() { + return this.encoder.flush(); + } +} + +export class MediaStreamVideoTrackSource extends VideoSource { + private encoder: VideoEncoderWrapper; + private abortController: AbortController | null = null; + + constructor(private track: MediaStreamVideoTrack, codecConfig: VideoCodecConfig, options: VideoSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new VideoEncoderWrapper(this, codecConfig); + } + + override start() { + this.abortController = new AbortController(); + + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (videoFrame) => { + this.encoder.digest(videoFrame); + videoFrame.close(); + } + }); + + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch(err => { + // Handle abort error silently + if (err instanceof DOMException && err.name === 'AbortError') return; + // Handle other errors + console.error('Pipe error:', err); + }); + } + + override async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + await this.encoder.flush(); + } +} + +export class EncodedAudioChunkSource extends AudioSource { + constructor(codec: AudioCodec) { + super(codec, {}); + } + + digest(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) { + this.ensureNotFinalizing(); + this.connectedTrack?.output.muxer.addEncodedAudioChunk(this.connectedTrack, chunk, meta); + } +} + +class AudioEncoderWrapper { + private encoder: AudioEncoder | null = null; + + constructor(private source: AudioSource, private codecConfig: AudioCodecConfig) {} + + digest(audioData: AudioData) { + this.source.ensureNotFinalizing(); + + this.ensureEncoder(audioData); + assert(this.encoder); + + this.encoder.encode(audioData); + } + + private ensureEncoder(audioData: AudioData) { + if (this.encoder) { + return; + } + + this.encoder = new AudioEncoder({ + output: (chunk, meta) => this.source.connectedTrack?.output.muxer.addEncodedAudioChunk(this.source.connectedTrack, chunk, meta), + error: (error) => console.error(error), // TODO + }); + + this.encoder.configure({ + codec: buildAudioCodecString(this.codecConfig.codec, audioData.numberOfChannels, audioData.sampleRate), + numberOfChannels: audioData.numberOfChannels, + sampleRate: audioData.sampleRate, + bitrate: this.codecConfig.bitrate, + }); + } + + async flush() { + return this.encoder?.flush(); + } +} + +export class AudioDataSource extends AudioSource { + private encoder: AudioEncoderWrapper; + + constructor(codecConfig: AudioCodecConfig, options: AudioSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + + digest(audioData: AudioData) { + this.encoder.digest(audioData); + } + + override flush() { + return this.encoder.flush(); + } +} + +export class AudioBufferSource extends AudioSource { + private encoder: AudioEncoderWrapper; + private accumulatedFrameCount = 0; + + constructor(codecConfig: AudioCodecConfig, options: AudioSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + + digest(audioBuffer: AudioBuffer) { + const numberOfChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numberOfFrames = audioBuffer.length; + + // Create a planar F32 array containing all channels + const data = new Float32Array(numberOfChannels * numberOfFrames); + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = audioBuffer.getChannelData(channel); + data.set(channelData, channel * numberOfFrames); + } + + const audioData = new AudioData({ + format: 'f32-planar', + sampleRate, + numberOfFrames, + numberOfChannels, + timestamp: Math.round(1e6 * this.accumulatedFrameCount / sampleRate), + data: data + }); + + this.encoder.digest(audioData); + audioData.close(); + + this.accumulatedFrameCount += numberOfFrames; + } + + override flush() { + return this.encoder.flush(); + } +} + +export class MediaStreamAudioTrackSource extends AudioSource { + private encoder: AudioEncoderWrapper; + private abortController: AbortController | null = null; + + constructor(private track: MediaStreamAudioTrack, codecConfig: AudioCodecConfig, options: AudioSourceMetadata = {}) { + super(codecConfig.codec, options); + this.encoder = new AudioEncoderWrapper(this, codecConfig); + } + + override start() { + this.abortController = new AbortController(); + + const processor = new MediaStreamTrackProcessor({ track: this.track }); + const consumer = new WritableStream({ + write: (audioData) => { + this.encoder.digest(audioData); + audioData.close(); + } + }); + + processor.readable.pipeTo(consumer, { + signal: this.abortController.signal + }).catch(err => { + // Handle abort error silently + if (err instanceof DOMException && err.name === 'AbortError') return; + // Handle other errors + console.error('Pipe error:', err); + }); + } + + override async flush() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + await this.encoder.flush(); + } +} \ No newline at end of file diff --git a/src/target.ts b/src/target.ts new file mode 100644 index 0000000..c1ad771 --- /dev/null +++ b/src/target.ts @@ -0,0 +1,80 @@ +import { ArrayBufferTargetWriter, ChunkedStreamTargetWriter, FileSystemWritableFileStreamTargetWriter, StreamTargetWriter, Writer } from "./writer"; + +const isTarget = Symbol('isTarget'); +export abstract class Target { + // If we didn't add this symbol, then {} would be assignable to Target + [isTarget]!: true; + + abstract createWriter(): Writer; +} + +export class ArrayBufferTarget extends Target { + buffer: ArrayBuffer | null = null; + + createWriter() { + return new ArrayBufferTargetWriter(this); + } +} + +export class StreamTarget extends Target { + constructor(public options: { + onData?: (data: Uint8Array, position: number) => void, + chunked?: boolean, + chunkSize?: number + }) { + super(); + + if (typeof options !== 'object') { + throw new TypeError('StreamTarget requires an options object to be passed to its constructor.'); + } + if (options.onData) { + if (typeof options.onData !== 'function') { + throw new TypeError('options.onData, when provided, must be a function.'); + } + if (options.onData.length < 2) { + // Checking the amount of parameters here is an important validation step as it catches a common error + // where people do not respect the position argument. + throw new TypeError( + 'options.onData, when provided, must be a function that takes in at least two arguments (data and ' + + 'position). Ignoring the position argument, which specifies the byte offset at which the data is ' + + 'to be written, can lead to broken outputs.' + ); + } + } + if (options.chunked !== undefined && typeof options.chunked !== 'boolean') { + throw new TypeError('options.chunked, when provided, must be a boolean.'); + } + if (options.chunkSize !== undefined && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError('options.chunkSize, when provided, must be a positive integer.'); + } + } + + createWriter() { + return this.options.chunked ? new ChunkedStreamTargetWriter(this) : new StreamTargetWriter(this); + } +} + +export class FileSystemWritableFileStreamTarget extends Target { + constructor( + public stream: FileSystemWritableFileStream, + public options?: { chunkSize?: number } + ) { + super(); + + if (!(stream instanceof FileSystemWritableFileStream)) { + throw new TypeError('FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance.'); + } + if (options !== undefined && typeof options !== 'object') { + throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object."); + } + if (options) { + if (options.chunkSize !== undefined && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) { + throw new TypeError('options.chunkSize, when provided, must be a positive integer'); + } + } + } + + createWriter() { + return new FileSystemWritableFileStreamTargetWriter(this); + } +} diff --git a/src/writer.ts b/src/writer.ts new file mode 100644 index 0000000..e5e8847 --- /dev/null +++ b/src/writer.ts @@ -0,0 +1,330 @@ +import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget } from './target'; + +export abstract class Writer { + /** 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. */ + abstract seek(newPos: number): void; + /** Returns the current position. */ + abstract getPos(): number; + /** Called after muxing has finished. */ + abstract finalize(): void; + /** Signals to the writer that it may be time to flush. */ + abstract flush(): void; +} + +/** + * Writes to an ArrayBufferTarget. Maintains a growable internal buffer during the muxing process, which will then be + * written to the ArrayBufferTarget once the muxing finishes. + */ +export class ArrayBufferTargetWriter extends Writer { + #pos = 0; + #target: ArrayBufferTarget; + #buffer = new ArrayBuffer(2**16); + #bytes = new Uint8Array(this.#buffer); + #maxPos = 0; + + constructor(target: ArrayBufferTarget) { + super(); + + this.#target = target; + } + + #ensureSize(size: number) { + let newLength = this.#buffer.byteLength; + while (newLength < size) newLength *= 2; + + if (newLength === this.#buffer.byteLength) return; + + let newBuffer = new ArrayBuffer(newLength); + let newBytes = new Uint8Array(newBuffer); + newBytes.set(this.#bytes, 0); + + this.#buffer = newBuffer; + this.#bytes = newBytes; + } + + write(data: Uint8Array) { + this.#ensureSize(this.#pos + data.byteLength); + + this.#bytes.set(data, this.#pos); + this.#pos += data.byteLength; + + this.#maxPos = Math.max(this.#maxPos, this.#pos); + } + + seek(newPos: number) { + this.#pos = newPos; + } + + getPos() { + return this.#pos; + } + + flush() {} + + finalize() { + this.#ensureSize(this.#pos); + this.#target.buffer = this.#buffer.slice(0, Math.max(this.#maxPos, this.#pos)); + } +} + +/** + * Writes to a StreamTarget every time it is flushed, sending out all of the new data written since the + * last flush. This is useful for streaming applications, like piping the output to disk. + */ +export class StreamTargetWriter extends Writer { + #pos = 0; + #target: StreamTarget; + #sections: { + data: Uint8Array, + start: number + }[] = []; + + constructor(target: StreamTarget) { + super(); + + this.#target = target; + } + + write(data: Uint8Array) { + this.#sections.push({ + data: data.slice(), + start: this.#pos + }); + this.#pos += data.byteLength; + } + + seek(newPos: number) { + this.#pos = newPos; + } + + getPos() { + return this.#pos; + } + + flush() { + if (this.#sections.length === 0) return; + + let chunks: { + start: number, + size: number, + data?: Uint8Array + }[] = []; + let sorted = [...this.#sections].sort((a, b) => a.start - b.start); + + chunks.push({ + start: sorted[0]!.start, + size: sorted[0]!.data.byteLength + }); + + // Figure out how many contiguous chunks we have + for (let i = 1; i < sorted.length; i++) { + let lastChunk = chunks[chunks.length - 1]!; + let section = sorted[i]!; + + if (section.start <= lastChunk.start + lastChunk.size) { + lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start); + } else { + chunks.push({ + start: section.start, + size: section.data.byteLength + }); + } + } + + for (let chunk of chunks) { + chunk.data = new Uint8Array(chunk.size); + + // Make sure to write the data in the correct order for correct overwriting + for (let section of this.#sections) { + // Check if the section is in the chunk + if (chunk.start <= section.start && section.start < chunk.start + chunk.size) { + chunk.data.set(section.data, section.start - chunk.start); + } + } + + this.#target.options.onData?.(chunk.data, chunk.start); + } + + this.#sections.length = 0; + } + + finalize() {} +} + +const DEFAULT_CHUNK_SIZE = 2**24; +const MAX_CHUNKS_AT_ONCE = 2; + +interface Chunk { + start: number, + written: ChunkSection[], + data: Uint8Array, + shouldFlush: boolean +} + +interface ChunkSection { + start: number, + end: number +} + +/** + * Writes to a StreamTarget using a chunked approach: Data is first buffered in memory until it reaches a large enough + * size, which is when it is piped to the StreamTarget. This is helpful for reducing the total amount of writes. + */ +export class ChunkedStreamTargetWriter extends Writer { + #pos = 0; + #target: StreamTarget; + #chunkSize: number; + /** + * The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out. + * A chunk is flushed if all of its contents have been written. + */ + #chunks: Chunk[] = []; + + constructor(target: StreamTarget) { + super(); + + this.#target = target; + this.#chunkSize = target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE; + + if (!Number.isInteger(this.#chunkSize) || this.#chunkSize < 2**10) { + throw new Error('Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024.'); + } + } + + write(data: Uint8Array) { + this.#writeDataIntoChunks(data, this.#pos); + this.#flushChunks(); + + this.#pos += data.byteLength; + } + + seek(newPos: number) { + this.#pos = newPos; + } + + getPos() { + return this.#pos; + } + + #writeDataIntoChunks(data: Uint8Array, position: number) { + // First, find the chunk to write the data into, or create one if none exists + let chunkIndex = this.#chunks.findIndex(x => x.start <= position && position < x.start + this.#chunkSize); + if (chunkIndex === -1) chunkIndex = this.#createChunk(position); + let chunk = this.#chunks[chunkIndex]!; + + // Figure out how much to write to the chunk, and then write to the chunk + let relativePosition = position - chunk.start; + let toWrite = data.subarray(0, Math.min(this.#chunkSize - relativePosition, data.byteLength)); + chunk.data.set(toWrite, relativePosition); + + // Create a section describing the region of data that was just written to + let section: ChunkSection = { + start: relativePosition, + end: relativePosition + toWrite.byteLength + }; + this.#insertSectionIntoChunk(chunk, section); + + // Queue chunk for flushing to target if it has been fully written to + if (chunk.written[0]!.start === 0 && chunk.written[0]!.end === this.#chunkSize) { + chunk.shouldFlush = true; + } + + // Make sure we don't hold too many chunks in memory at once to keep memory usage down + if (this.#chunks.length > MAX_CHUNKS_AT_ONCE) { + // Flush all but the last chunk + for (let i = 0; i < this.#chunks.length-1; i++) { + this.#chunks[i]!.shouldFlush = true; + } + this.#flushChunks(); + } + + // If the data didn't fit in one chunk, recurse with the remaining datas + if (toWrite.byteLength < data.byteLength) { + this.#writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength); + } + } + + #insertSectionIntoChunk(chunk: Chunk, section: ChunkSection) { + let low = 0; + let high = chunk.written.length - 1; + let index = -1; + + // Do a binary search to find the last section with a start not larger than `section`'s start + while (low <= high) { + let mid = Math.floor(low + (high - low + 1) / 2); + + if (chunk.written[mid]!.start <= section.start) { + low = mid + 1; + index = mid; + } else { + high = mid - 1; + } + } + + // Insert the new section + chunk.written.splice(index + 1, 0, section); + if (index === -1 || chunk.written[index]!.end < section.start) index++; + + // Merge overlapping sections + while (index < chunk.written.length - 1 && chunk.written[index]!.end >= chunk.written[index + 1]!.start) { + chunk.written[index]!.end = Math.max(chunk.written[index]!.end, chunk.written[index + 1]!.end); + chunk.written.splice(index + 1, 1); + } + } + + #createChunk(includesPosition: number) { + let start = Math.floor(includesPosition / this.#chunkSize) * this.#chunkSize; + let chunk: Chunk = { + start, + data: new Uint8Array(this.#chunkSize), + written: [], + shouldFlush: false + }; + this.#chunks.push(chunk); + this.#chunks.sort((a, b) => a.start - b.start); + + return this.#chunks.indexOf(chunk); + } + + #flushChunks(force = false) { + for (let i = 0; i < this.#chunks.length; i++) { + let chunk = this.#chunks[i]!; + if (!chunk.shouldFlush && !force) continue; + + for (let section of chunk.written) { + this.#target.options.onData?.( + chunk.data.subarray(section.start, section.end), + chunk.start + section.start + ); + } + this.#chunks.splice(i--, 1); + } + } + + flush() { + // Do nothing, we flush ourselves + } + + finalize() { + this.#flushChunks(true); + } +} + +/** + * Essentially a wrapper around ChunkedStreamTargetWriter, writing directly to disk using the File System Access API. + * This is useful for large files, as available RAM is no longer a bottleneck. + */ +export class FileSystemWritableFileStreamTargetWriter extends ChunkedStreamTargetWriter { + constructor(target: FileSystemWritableFileStreamTarget) { + super(new StreamTarget({ + onData: (data, position) => target.stream.write({ + type: 'write', + data, + position + }), + chunkSize: target.options?.chunkSize + })); + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index beef65d..55cea2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,7 @@ "noImplicitAny": true, "noImplicitOverride": true, "noUncheckedIndexedAccess": true, - "noPropertyAccessFromIndexSignature": true, - "types": ["@types/dom-webcodecs"] + "noPropertyAccessFromIndexSignature": true }, "include": [ "src/**/*"