/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { buildAacAudioSpecificConfig, parseAacAudioSpecificConfig } from '../shared/aac-misc'; import { AUDIO_CODECS, AudioCodec, MediaCodec, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, SUBTITLE_CODECS, SubtitleCodec, VIDEO_CODECS, VideoCodec, } from './codec'; import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output'; import { assert, assertNever, binarySearchLessOrEqual, CallSerializer, clamp, clearIntervalUnthrottled, floorToDivisor, last, promiseWithResolvers, roundToDivisor, setInt24, setIntervalUnthrottled, setUint24, toUint8Array, UnthrottledTimerHandle, wait, } from './misc'; import { Muxer } from './muxer'; import { SubtitleParser } from './subtitles'; import { toAlaw, toUlaw } from './pcm'; import { CustomVideoEncoder, CustomAudioEncoder, customVideoEncoders, customAudioEncoders, } from './custom-coder'; import { EncodedPacket, EncodedPacketSideData, PacketType } from './packet'; import { AudioSample, audioSampleToInterleavedFormat, toInterleavedAudioFormat, VideoSample, VideoSamplePixelFormat, } from './sample'; import { AudioEncodingConfig, buildAudioEncoderConfig, buildVideoEncoderConfig, validateAudioEncodingConfig, validateVideoEncodingConfig, VideoEncodingConfig, } from './encode'; import { AudioResampler } from './resample'; import { determineVideoPacketType } from './codec-data'; import { Logging } from './logging'; /** * Base class for media sources. Media sources are used to add media samples to an output file. * @group Media sources * @public */ export abstract class MediaSource { /** @internal */ abstract readonly _codec: MediaCodec; /** @internal */ _connectedTrack: OutputTrack | null = null; /** @internal */ _closingPromise: Promise | null = null; /** @internal */ _closed = false; /** @internal */ _ensureValidAdd() { if (!this._connectedTrack) { throw new Error('Source is not connected to an output track.'); } if (this._connectedTrack.output.state === 'canceled') { throw new Error('Output has been canceled.'); } if (this._connectedTrack.output.state === 'finalizing' || this._connectedTrack.output.state === 'finalized') { throw new Error('Output has been finalized.'); } if (this._connectedTrack.output.state === 'pending') { throw new Error('Output has not started.'); } if (this._closed) { throw new Error('Source is closed.'); } } /** @internal */ async _start() {} /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async _flushAndClose(forceClose: boolean) {} /** * Closes this source. This prevents future samples from being added and signals to the output file that no further * samples will come in for this track. Calling `.close()` is optional but recommended after adding the * last sample - for improved performance and reduced memory usage. */ close() { if (this._closingPromise) { return; } const connectedTrack = this._connectedTrack; if (!connectedTrack) { throw new Error('Cannot call close without connecting the source to an output track.'); } if (connectedTrack.output.state === 'pending') { throw new Error('Cannot call close before output has been started.'); } this._closingPromise = (async () => { await this._flushAndClose(false); this._closed = true; if (connectedTrack.output.state === 'finalizing' || connectedTrack.output.state === 'finalized') { return; } connectedTrack.output._muxer.onTrackClose(connectedTrack); })(); } /** @internal */ async _flushOrWaitForOngoingClose(forceClose: boolean) { return this._closingPromise ??= (async () => { await this._flushAndClose(forceClose); this._closed = true; })(); } } /** * Base class for video sources - sources for video tracks. * @group Media sources * @public */ export abstract class VideoSource extends MediaSource { /** @internal */ override _connectedTrack: OutputVideoTrack | null = null; /** @internal */ override readonly _codec: VideoCodec; /** Internal constructor. */ constructor(codec: VideoCodec) { super(); if (!VIDEO_CODECS.includes(codec)) { throw new TypeError(`Invalid video codec '${codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`); } this._codec = codec; } } const maybeEnsureIsKeyPacket = (track: OutputVideoTrack, packet: EncodedPacket) => { if (track.metadata.hasOnlyKeyPackets && packet.type !== 'key') { throw new Error('Cannot add non-key packets to a hasOnlyKeyPackets video track.'); } }; /** * The most basic video source; can be used to directly pipe encoded packets into the output file. * @group Media sources * @public */ export class EncodedVideoPacketSource extends VideoSource { /** Creates a new {@link EncodedVideoPacketSource} whose packets are encoded using `codec`. */ constructor(codec: VideoCodec) { super(codec); } /** * Adds an encoded packet to the output video track. Packets must be added in *decode order*, while a packet's * timestamp must be its *presentation timestamp*. B-frames are handled automatically. * * @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid * decoder config. * * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise * to respect writer and encoder backpressure. */ add(packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); } if (packet.isMetadataOnly) { throw new TypeError('Metadata-only packets cannot be added.'); } if (meta !== undefined && (!meta || typeof meta !== 'object')) { throw new TypeError('meta, when provided, must be an object.'); } this._ensureValidAdd(); maybeEnsureIsKeyPacket(this._connectedTrack!, packet); return this._connectedTrack!.output._muxer.addEncodedVideoPacket(this._connectedTrack!, packet, meta); } } class VideoEncoderWrapper { private ensureEncoderPromise: Promise | null = null; private encoderInitialized = false; private encoder: VideoEncoder | null = null; private muxer: Muxer | null = null; private lastMultipleOfKeyFrameInterval = -1; private emittedEncoderPackets = 0; // Tracks the input dimensions of the first frame private codedWidth: number | null = null; private codedHeight: number | null = null; // Tracks the output dimensions of the first frame (used to lock dimensions for fill/contain/cover) private outputWidth: number | null = null; private outputHeight: number | null = null; // Frame rate normalization state private frameRateLastSample: VideoSample | null = null; private frameRateLastTimestamp: number | null = null; private frameRateLastEndTimestamp: number | null = null; // VideoEncoder converts everything to microseconds, so we need to do some bookkeeping to restore the original // timing information private preciseTimings: { microsecondTimestamp: number; timestamp: number; duration: number; timestampIsValid: boolean; durationIsValid: boolean; }[] = []; private customEncoder: CustomVideoEncoder | null = null; private customEncoderCallSerializer = new CallSerializer(); private customEncoderQueueSize = 0; // Alpha stuff private alphaEncoder: VideoEncoder | null = null; private splitter: ColorAlphaSplitter | null = null; private splitterCreationFailed = false; private alphaFrameQueue: (VideoFrame | null)[] = []; /** * Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context. * However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught. * So, we keep track of the encoder error and throw it as soon as we get the chance. */ private error: unknown = null; private errorSet = false; private setError(error: unknown) { if (!this.errorSet) { this.error = error; this.errorSet = true; } } private lastMuxerPromise: Promise = Promise.resolve(); private closed = false; constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {} async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) { const originalSample = videoSample; try { this.checkForEncoderError(); this.source._ensureValidAdd(); const config = this.encodingConfig; const sizeChangeBehavior = config.sizeChangeBehavior ?? 'deny'; let isSizeChange = false; // Ensure video sample size remains constant or handle the change if (this.codedWidth !== null && this.codedHeight !== null) { if (videoSample.codedWidth !== this.codedWidth || videoSample.codedHeight !== this.codedHeight) { isSizeChange = true; if (sizeChangeBehavior === 'deny') { throw new Error( `Video sample size must remain constant. Expected ${this.codedWidth}x${this.codedHeight},` + ` got ${videoSample.codedWidth}x${videoSample.codedHeight}. To allow the sample size to` + ` change over time, set \`sizeChangeBehavior\` to a value other than 'deny' in the` + ` encoding options.`, ); } } } else { this.codedWidth = videoSample.codedWidth; this.codedHeight = videoSample.codedHeight; } // Determine if we need to apply transformations via canvas const hasTransformConfig = config.transform?.width !== undefined || config.transform?.height !== undefined || config.transform?.rotate !== undefined || config.transform?.crop !== undefined || config.transform?.force === true; const needsTransform = hasTransformConfig || (isSizeChange && sizeChangeBehavior !== 'passThrough'); if (needsTransform) { let targetWidth = config.transform?.width; let targetHeight = config.transform?.height; let appliedFit: 'fill' | 'contain' | 'cover' = config.transform?.fit ?? 'fill'; // If the size changed and behavior is fill/contain/cover, lock to the original output dimensions if (isSizeChange && sizeChangeBehavior !== 'passThrough') { assert(this.outputWidth); assert(this.outputHeight); assert(sizeChangeBehavior !== 'deny'); targetWidth = this.outputWidth!; targetHeight = this.outputHeight!; appliedFit = sizeChangeBehavior; } const transformed = await videoSample.transform({ width: targetWidth, height: targetHeight, roundDimensionsTo: 2, crop: config.transform?.crop, rotate: config.transform?.rotate, fit: appliedFit, alpha: config.alpha, }); // Save the output dimensions of the first frame if (this.outputWidth === null || this.outputHeight === null) { this.outputWidth = transformed.displayWidth; this.outputHeight = transformed.displayHeight; } if (shouldClose) { videoSample.close(); } videoSample = transformed; shouldClose = true; } else { // If no canvas is needed, we still need to record the output dimensions for the first frame if (this.outputWidth === null || this.outputHeight === null) { this.outputWidth = videoSample.codedWidth; this.outputHeight = videoSample.codedHeight; } } const frameRate = config.transform?.frameRate; if (frameRate !== undefined) { // Apply frame rate normalization const originalEndTimestamp = videoSample.timestamp + videoSample.duration; const alignedTimestamp = floorToDivisor(videoSample.timestamp, frameRate); if (this.frameRateLastSample !== null) { if (alignedTimestamp <= this.frameRateLastTimestamp!) { // Same frame rate slot, replace stored sample with the newer one this.frameRateLastSample.close(); this.frameRateLastSample = videoSample.clone(); this.frameRateLastEndTimestamp = originalEndTimestamp; return; } else { // Pad the gap by repeating the previous frame await this.padFrameRate(alignedTimestamp, encodeOptions); } } // Clone if the sample is still the user's, to avoid mutating externally-owned data if (videoSample === originalSample) { videoSample = videoSample.clone(); shouldClose = true; } videoSample.setTimestamp(alignedTimestamp); videoSample.setDuration(1 / frameRate); this.frameRateLastSample?.close(); this.frameRateLastSample = videoSample.clone(); this.frameRateLastTimestamp = alignedTimestamp; this.frameRateLastEndTimestamp = originalEndTimestamp; } await this.processAndEncode(videoSample, encodeOptions); } finally { if (shouldClose) { videoSample.close(); } } } /** * Runs the process function (if any) and encodes the resulting samples. */ private async processAndEncode( videoSample: VideoSample, encodeOptions?: VideoEncoderEncodeOptions, ) { const config = this.encodingConfig; let samplesToEncode: VideoSample[]; // Apply the user-defined process function, if any if (config.transform?.process) { let processed = config.transform.process(videoSample); if (processed instanceof Promise) { processed = await processed; } if (processed === null) { return; } if (!Array.isArray(processed)) { processed = [processed]; } samplesToEncode = processed.map((x) => { if (x instanceof VideoSample) { return x; } if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) { return new VideoSample(x); } // Calling the VideoSample constructor here will automatically handle input validation for us // (it throws for any non-legal argument). return new VideoSample(x as CanvasImageSource, { timestamp: videoSample.timestamp, duration: videoSample.duration, }); }); } else { samplesToEncode = [videoSample]; } try { for (const sampleToEncode of samplesToEncode) { if (!this.encoderInitialized) { if (!this.ensureEncoderPromise) { this.ensureEncoder(sampleToEncode); } // No, this "if" statement is not useless. Sometimes, the above call to // `ensureEncoder` might have synchronously completed and the encoder is // already initialized. In this case, we don't need to await the promise // anymore. This also fixes nasty async race condition bugs when multiple // code paths are calling this method: It's important that the call that // initialized the encoder go through this code first. if (!this.encoderInitialized) { await this.ensureEncoderPromise; } } assert(this.encoderInitialized); if (this.closed) { break; } const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2; const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval); const mergedEncodeOptions = { ...sampleToEncode.encodeOptions, ...encodeOptions }; const finalEncodeOptions = { ...mergedEncodeOptions, keyFrame: mergedEncodeOptions.keyFrame !== undefined ? mergedEncodeOptions.keyFrame // Ensure a key frame every keyFrameInterval 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 (or at least desirable). : keyFrameInterval === 0 || multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval, }; this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; this.encodingConfig.onEncodedSample?.(sampleToEncode); if (this.customEncoder) { this.customEncoderQueueSize++; // We clone the sample so it cannot be closed on us from the outside before it reaches the encoder const clonedSample = sampleToEncode.clone(); const promise = this.customEncoderCallSerializer .call(() => this.customEncoder!.encode(clonedSample, finalEncodeOptions)) .catch((error: unknown) => this.setError(error)) .finally(() => { this.customEncoderQueueSize--; clonedSample.close(); }); if (this.customEncoderQueueSize >= 4) { await promise; } } else { assert(this.encoder); const videoFrame = sampleToEncode.toVideoFrame(); const preciseTimingIndex = binarySearchLessOrEqual( this.preciseTimings, videoFrame.timestamp, x => x.microsecondTimestamp, ); const existingEntry = preciseTimingIndex !== -1 ? this.preciseTimings[preciseTimingIndex] : null; if (existingEntry && existingEntry.microsecondTimestamp === videoFrame.timestamp) { if (existingEntry.timestamp !== sampleToEncode.timestamp) { // Mapping isn't unique, can't use the timestamp existingEntry.timestampIsValid = false; } if (existingEntry.duration !== sampleToEncode.duration) { // Mapping isn't unique, can't use the duration existingEntry.durationIsValid = false; } } else { this.preciseTimings.splice(preciseTimingIndex + 1, 0, { microsecondTimestamp: videoFrame.timestamp, timestamp: sampleToEncode.timestamp, duration: sampleToEncode.duration, timestampIsValid: true, durationIsValid: true, }); // Make sure it doesn't grow indefinitely if (this.preciseTimings.length > 128) { this.preciseTimings.shift(); } } if (!this.alphaEncoder) { // No alpha encoder, simple case this.encoder.encode(videoFrame, finalEncodeOptions); videoFrame.close(); } else { // We're expected to encode alpha as well const frameDefinitelyHasNoAlpha = !!videoFrame.format && !videoFrame.format.includes('A'); if (frameDefinitelyHasNoAlpha || this.splitterCreationFailed) { this.alphaFrameQueue.push(null); this.encoder.encode(videoFrame, finalEncodeOptions); videoFrame.close(); } else { if (!this.splitter) { this.splitter = new ColorAlphaSplitter(); } // The splitter takes ownership, so no need to close the frames ourselves const { colorFrame, alphaFrame } = await this.splitter.split(videoFrame); this.alphaFrameQueue.push(alphaFrame); this.encoder.encode(colorFrame, finalEncodeOptions); colorFrame.close(); } } // We need to do this after sending the frame to the encoder as the frame otherwise might be closed if (this.encoder.encodeQueueSize >= 4) { await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }), ); } } await this.lastMuxerPromise; // Allow the writer to apply backpressure } } finally { for (const sample of samplesToEncode) { if (sample !== videoSample) { sample.close(); } } } } /** Repeats the last frame rate sample to fill the gap up to the given timestamp. */ private async padFrameRate(until: number, encodeOptions?: VideoEncoderEncodeOptions) { const frameRate = this.encodingConfig.transform!.frameRate!; assert(this.frameRateLastSample); const frameDifference = Math.round((until - this.frameRateLastTimestamp!) * frameRate); for (let i = 1; i < frameDifference; i++) { const sample = this.frameRateLastSample.clone(); sample.setTimestamp(this.frameRateLastTimestamp! + i / frameRate); sample.setDuration(1 / frameRate); await this.processAndEncode(sample, encodeOptions); sample.close(); } } private ensureEncoder(videoSample: VideoSample) { this.ensureEncoderPromise = (async () => { const encoderConfig = buildVideoEncoderConfig({ ...this.encodingConfig, width: videoSample.codedWidth, height: videoSample.codedHeight, squarePixelWidth: videoSample.squarePixelWidth, squarePixelHeight: videoSample.squarePixelHeight, framerate: this.source._connectedTrack?.metadata.frameRate, }); this.encodingConfig.onEncoderConfig?.(encoderConfig); const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports( this.encodingConfig.codec, encoderConfig, )); if (MatchingCustomEncoder) { // @ts-expect-error "Can't create instance of abstract class 🤓" this.customEncoder = new MatchingCustomEncoder() as CustomVideoEncoder; // @ts-expect-error It's technically readonly this.customEncoder.codec = this.encodingConfig.codec; // @ts-expect-error It's technically readonly this.customEncoder.config = encoderConfig; // @ts-expect-error It's technically readonly this.customEncoder.onPacket = (packet, meta) => { if (!(packet instanceof EncodedPacket)) { throw new TypeError('The first argument passed to onPacket must be an EncodedPacket.'); } if (meta !== undefined && (!meta || typeof meta !== 'object')) { throw new TypeError('The second argument passed to onPacket must be an object or undefined.'); } maybeEnsureIsKeyPacket(this.source._connectedTrack!, packet); this.encodingConfig.onEncodedPacket?.(packet, meta); this.lastMuxerPromise = this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.setError(error); }); }; // @ts-expect-error It's technically readonly this.customEncoder.onError = (error) => { this.setError(error); }; await this.customEncoder.init(); } else { if (typeof VideoEncoder === 'undefined') { throw new Error('VideoEncoder is not supported by this browser.'); } encoderConfig.alpha = 'discard'; // Since we handle alpha ourselves if (this.encodingConfig.alpha === 'keep') { // Encoding alpha requires using two parallel encoders, so we need to make sure they stay in sync // and that neither of them drops frames. Setting latencyMode to 'quality' achieves this, because // "User Agents MUST not drop frames to achieve the target bitrate and/or framerate." encoderConfig.latencyMode = 'quality'; } const hasOddDimension = encoderConfig.width % 2 === 1 || encoderConfig.height % 2 === 1; if ( hasOddDimension && (this.encodingConfig.codec === 'avc' || this.encodingConfig.codec === 'hevc') ) { // Throw a special error for this case as it gets hit often throw new Error( `The dimensions ${encoderConfig.width}x${encoderConfig.height} are not supported for codec` + ` '${this.encodingConfig.codec}'; both width and height must be even numbers. Make sure to` + ` round your dimensions to the nearest even number.`, ); } const support = await VideoEncoder.isConfigSupported(encoderConfig); if (!support.supported) { throw new Error( `This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,` + ` ${encoderConfig.width}x${encoderConfig.height}, hardware acceleration:` + ` ${encoderConfig.hardwareAcceleration ?? 'no-preference'}) is not supported by this browser.` + ` Consider using another codec or changing your video parameters.`, ); } /** Queue of color chunks waiting for their alpha counterpart. */ const colorChunkQueue: { chunk: EncodedVideoChunk; meta: EncodedVideoChunkMetadata | undefined; }[] = []; /** Each value is the number of encoded alpha chunks at which a null alpha chunk should be added. */ const nullAlphaChunkQueue: number[] = []; let encodedAlphaChunkCount = 0; let alphaEncoderQueue = 0; const addPacket = ( colorChunk: EncodedVideoChunk, alphaChunk: EncodedVideoChunk | null, meta: EncodedVideoChunkMetadata | undefined, ) => { const sideData: EncodedPacketSideData = {}; if (alphaChunk) { const alphaData = new Uint8Array(alphaChunk.byteLength); alphaChunk.copyTo(alphaData); sideData.alpha = alphaData; } let packet = EncodedPacket.fromEncodedChunk(colorChunk, sideData); // See if there's a relevant timing entry to refine the packet's timing data const preciseTimingIndex = binarySearchLessOrEqual( this.preciseTimings, colorChunk.timestamp, x => x.microsecondTimestamp, ); const entry = preciseTimingIndex !== -1 ? this.preciseTimings[preciseTimingIndex] : null; let actualType: PacketType | null = null; if (this.emittedEncoderPackets === 0 && packet.type === 'delta' && meta?.decoderConfig) { // https://github.com/Vanilagy/mediabunny/issues/365 // We expect the first packet to be a key packet. If it's not, let's actually verify that it's // not by getting the actual type. actualType = determineVideoPacketType( this.encodingConfig.codec, meta.decoderConfig, packet.data, ); } // Define the packet if ((entry && entry.microsecondTimestamp === colorChunk.timestamp) || actualType !== null) { packet = packet.clone({ timestamp: entry?.timestampIsValid ? entry.timestamp : undefined, duration: entry?.durationIsValid ? entry.duration : undefined, type: actualType ?? undefined, }); } maybeEnsureIsKeyPacket(this.source._connectedTrack!, packet); this.encodingConfig.onEncodedPacket?.(packet, meta); this.lastMuxerPromise = this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.setError(error); }); this.emittedEncoderPackets++; }; const stack = new Error('Encoding error').stack; this.encoder = new VideoEncoder({ output: (chunk, meta) => { if (!this.alphaEncoder) { // We're done addPacket(chunk, null, meta); return; } const alphaFrame = this.alphaFrameQueue.shift(); assert(alphaFrame !== undefined); if (alphaFrame) { this.alphaEncoder.encode(alphaFrame, { // Crucial: The alpha frame is forced to be a key frame whenever the color frame // also is. Without this, playback can glitch and even crash in some browsers. // This is the reason why the two encoders are wired in series and not in parallel. keyFrame: chunk.type === 'key', }); alphaEncoderQueue++; alphaFrame.close(); colorChunkQueue.push({ chunk, meta }); } else { // There was no alpha component for this frame if (alphaEncoderQueue === 0) { // No pending alpha encodes either, so we're done addPacket(chunk, null, meta); } else { // There are still alpha encodes pending, so we can't add the packet immediately since // we'd end up with out-of-order packets. Instead, let's queue a null alpha chunk to be // added in the future, after the current encoder workload has completed: nullAlphaChunkQueue.push(encodedAlphaChunkCount + alphaEncoderQueue); colorChunkQueue.push({ chunk, meta }); } } }, error: (error) => { error.stack = stack; // Provide a more useful stack trace, the default one sucks this.setError(error); }, }); this.encoder.configure(encoderConfig); if (this.encodingConfig.alpha === 'keep') { const stack = new Error('Encoding error').stack; // We need to encode alpha as well, which we do with a separate encoder this.alphaEncoder = new VideoEncoder({ // We ignore the alpha chunk's metadata // eslint-disable-next-line @typescript-eslint/no-unused-vars output: (chunk, meta) => { alphaEncoderQueue--; // There has to be a color chunk because the encoders are wired in series const colorChunk = colorChunkQueue.shift(); assert(colorChunk !== undefined); addPacket(colorChunk.chunk, chunk, colorChunk.meta); // See if there are any null alpha chunks queued up encodedAlphaChunkCount++; while ( nullAlphaChunkQueue.length > 0 && nullAlphaChunkQueue[0] === encodedAlphaChunkCount ) { nullAlphaChunkQueue.shift(); const colorChunk = colorChunkQueue.shift(); assert(colorChunk !== undefined); addPacket(colorChunk.chunk, null, colorChunk.meta); } }, error: (error) => { error.stack = stack; // Provide a more useful stack trace this.setError(error); }, }); this.alphaEncoder.configure(encoderConfig); } } assert(this.source._connectedTrack); this.muxer = this.source._connectedTrack.output._muxer; this.encoderInitialized = true; })(); } async flushAndClose(forceClose: boolean) { if (!forceClose) { this.checkForEncoderError(); } // Final frame rate padding: fill remaining frames up to the last sample's original end timestamp if (!forceClose && this.frameRateLastSample) { const frameRate = this.encodingConfig.transform!.frameRate!; const alignedEnd = floorToDivisor(this.frameRateLastEndTimestamp!, frameRate); await this.padFrameRate(alignedEnd); } this.closed = true; this.frameRateLastSample?.close(); this.frameRateLastSample = null; if (this.customEncoder) { if (!forceClose) { void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush()); } await this.customEncoderCallSerializer.call(() => this.customEncoder!.close()); } else if (this.encoder) { if (!forceClose) { // These are wired in series, therefore they must also be flushed in series await this.encoder.flush(); await this.alphaEncoder?.flush(); // Workaround for https://issues.chromium.org/issues/529852980 to give it time for errors to surface await wait(25); } if (this.encoder.state !== 'closed') { this.encoder.close(); } if (this.alphaEncoder && this.alphaEncoder.state !== 'closed') { this.alphaEncoder.close(); } this.alphaFrameQueue.forEach(x => x?.close()); this.splitter?.close(); } if (!forceClose) { this.checkForEncoderError(); } } getQueueSize() { if (this.customEncoder) { return this.customEncoderQueueSize; } else { // Because the color and alpha encoders are wired in series, there's no need to also include the alpha // encoder's queue size here return this.encoder?.encodeQueueSize ?? 0; } } checkForEncoderError() { if (this.errorSet) { throw this.error; } } } let splitterWorkerUrl: string | null = null; /** Utility class for splitting a composite frame into separate color and alpha parts on the CPU in a worker. */ export class ColorAlphaSplitter { private worker: Worker | null = null; private pendingRequests = new Map< number, ReturnType> >(); private nextRequestId = 0; split(sourceFrame: VideoFrame): Promise<{ colorFrame: VideoFrame; alphaFrame: VideoFrame }> { if (!this.worker) { if (!splitterWorkerUrl) { const blob = new Blob( [`(${colorAlphaSplitterWorkerCode.toString()})()`], { type: 'application/javascript' }, ); splitterWorkerUrl = URL.createObjectURL(blob); } this.worker = new Worker(splitterWorkerUrl); this.worker.addEventListener('message', (event: MessageEvent) => { const data = event.data; const pending = this.pendingRequests.get(data.id); if (!pending) { return; } this.pendingRequests.delete(data.id); if ('error' in data) { pending.reject(new Error(data.error)); } else { pending.resolve({ colorFrame: data.colorFrame, alphaFrame: data.alphaFrame }); } }); this.worker.addEventListener('error', (event) => { const error = new Error(event.message || 'Color/alpha splitter worker error.'); for (const pending of this.pendingRequests.values()) { pending.reject(error); } this.pendingRequests.clear(); }); } const id = this.nextRequestId++; const pending = promiseWithResolvers<{ colorFrame: VideoFrame; alphaFrame: VideoFrame }>(); this.pendingRequests.set(id, pending); this.worker.postMessage({ id, sourceFrame }, { transfer: [sourceFrame] }); return pending.promise; } close() { this.worker?.terminate(); this.worker = null; const error = new Error('Color/alpha splitter closed.'); for (const pending of this.pendingRequests.values()) { pending.reject(error); } this.pendingRequests.clear(); } } type ColorAlphaSplitterWorkerRequest = { id: number; sourceFrame: VideoFrame; }; type ColorAlphaSplitterWorkerResponse = | { id: number; colorFrame: VideoFrame; alphaFrame: VideoFrame } | { id: number; error: string }; const colorAlphaSplitterWorkerCode = () => { // Reused across frames as long as the size matches, since consecutive frames usually share dimensions. let cpuSourceBuffer: Uint8Array | null = null; // Serialize execution internally so concurrent requests don't race on the shared cpuSourceBuffer. let chain: Promise = Promise.resolve(); self.addEventListener('message', (event: MessageEvent) => { const { id, sourceFrame } = event.data; chain = chain.then(async () => { try { const { colorFrame, alphaFrame } = await split(sourceFrame); self.postMessage({ id, colorFrame, alphaFrame }, { transfer: [colorFrame, alphaFrame] }); } catch (error) { self.postMessage({ id, error: (error as Error).message }); } finally { sourceFrame.close(); } }); }); const split = async (sourceFrame: VideoFrame) => { const format = sourceFrame.format as VideoSamplePixelFormat | null; if (!format) { throw new Error('CPU color/alpha splitting requires a known VideoFrame format.'); } const sourceSize = sourceFrame.allocationSize(); if (!cpuSourceBuffer || cpuSourceBuffer.byteLength !== sourceSize) { cpuSourceBuffer = new Uint8Array(sourceSize); } await sourceFrame.copyTo(cpuSourceBuffer); if (format === 'RGBA' || format === 'BGRA') { return splitInterleavedRgba(cpuSourceBuffer, format, sourceFrame); } else if ( format === 'I420A' || format === 'I420AP10' || format === 'I420AP12' || format === 'I422A' || format === 'I422AP10' || format === 'I422AP12' || format === 'I444A' || format === 'I444AP10' || format === 'I444AP12' ) { return splitPlanarYuvA(cpuSourceBuffer, format, sourceFrame); } throw new Error(`CPU color/alpha splitting does not support format '${format}'.`); }; const splitInterleavedRgba = ( source: Uint8Array, format: 'RGBA' | 'BGRA', sourceFrame: VideoFrame, ) => { const width = sourceFrame.visibleRect?.width ?? sourceFrame.codedWidth; const height = sourceFrame.visibleRect?.height ?? sourceFrame.codedHeight; const pixelCount = width * height; const chromaW = Math.ceil(width / 2); const chromaH = Math.ceil(height / 2); const alphaSize = pixelCount + chromaW * chromaH * 2; // Encode alpha as I420: Y = source A bytes, UV = 128 const alphaBuffer = new Uint8Array(alphaSize); for (let i = 0, j = 3; i < pixelCount; i++, j += 4) { alphaBuffer[i] = source[j]!; } alphaBuffer.fill(128, pixelCount); // Hand the source buffer straight to VideoFrame as RGBX/BGRX so the A bytes are ignored const colorFrame = new VideoFrame(source, { format: format === 'RGBA' ? 'RGBX' : 'BGRX', codedWidth: width, codedHeight: height, timestamp: sourceFrame.timestamp, duration: sourceFrame.duration ?? undefined, // No transfer! }); const alphaInit = { format: 'I420' as const, codedWidth: width, codedHeight: height, timestamp: sourceFrame.timestamp, duration: sourceFrame.duration ?? undefined, transfer: [alphaBuffer.buffer], }; const alphaFrame = new VideoFrame(alphaBuffer, alphaInit); return { colorFrame, alphaFrame }; }; const splitPlanarYuvA = ( source: Uint8Array, format: | 'I420A' | 'I420AP10' | 'I420AP12' | 'I422A' | 'I422AP10' | 'I422AP12' | 'I444A' | 'I444AP10' | 'I444AP12', sourceFrame: VideoFrame, ) => { const width = sourceFrame.visibleRect?.width ?? sourceFrame.codedWidth; const height = sourceFrame.visibleRect?.height ?? sourceFrame.codedHeight; const is10 = format.includes('P10'); const is12 = format.includes('P12'); const bytesPerSample = (is10 || is12) ? 2 : 1; let chromaW: number; let chromaH: number; if (format.startsWith('I420')) { chromaW = Math.ceil(width / 2); chromaH = Math.ceil(height / 2); } else if (format.startsWith('I422')) { chromaW = Math.ceil(width / 2); chromaH = height; } else { chromaW = width; chromaH = height; } const ySamples = width * height; const uvSamples = chromaW * chromaH; const yBytes = ySamples * bytesPerSample; const uvBytes = uvSamples * bytesPerSample; const aBytes = ySamples * bytesPerSample; const colorBytes = yBytes + uvBytes * 2; const colorFormat = format.replace('A', '') as VideoPixelFormat; const alphaChromaW = Math.ceil(width / 2); const alphaChromaH = Math.ceil(height / 2); const alphaUvSamples = alphaChromaW * alphaChromaH; const alphaUvBytes = alphaUvSamples * bytesPerSample; const alphaSize = aBytes + 2 * alphaUvBytes; const alphaBuffer = new Uint8Array(alphaSize); const aPlaneStart = colorBytes; alphaBuffer.set(source.subarray(aPlaneStart, aPlaneStart + aBytes), 0); // Fill UV planes with the neutral chroma value const uvOffset = aBytes; const neutralChroma = is10 ? 512 : (is12 ? 2048 : 128); if (bytesPerSample === 1) { alphaBuffer.fill(neutralChroma, uvOffset); } else { const uvView = new Uint16Array(alphaBuffer.buffer, uvOffset, 2 * alphaUvSamples); uvView.fill(neutralChroma); } const alphaFormat = (is10 ? 'I420P10' : (is12 ? 'I420P12' : 'I420')) as VideoPixelFormat; // Color frame is simply a prefix of the combined bytes const colorFrame = new VideoFrame(source.subarray(0, colorBytes), { format: colorFormat, codedWidth: width, codedHeight: height, timestamp: sourceFrame.timestamp, duration: sourceFrame.duration ?? undefined, }); const alphaInit = { format: alphaFormat, codedWidth: width, codedHeight: height, timestamp: sourceFrame.timestamp, duration: sourceFrame.duration ?? undefined, transfer: [alphaBuffer.buffer], }; const alphaFrame = new VideoFrame(alphaBuffer, alphaInit); return { colorFrame, alphaFrame }; }; }; /** * This source can be used to add raw, unencoded video samples (frames) to an output video track. These frames will * automatically be encoded and then piped into the output. * @group Media sources * @public */ export class VideoSampleSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; /** * Creates a new {@link VideoSampleSource} whose samples are encoded according to the specified * {@link VideoEncodingConfig}. */ constructor(encodingConfig: VideoEncodingConfig) { validateVideoEncodingConfig(encodingConfig); super(encodingConfig.codec); this._encoder = new VideoEncoderWrapper(this, encodingConfig); } /** * Encodes a video sample (frame) and then adds it to the output. * * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise * to respect writer and encoder backpressure. */ add(videoSample: VideoSample, encodeOptions?: VideoEncoderEncodeOptions) { if (!(videoSample instanceof VideoSample)) { throw new TypeError('videoSample must be a VideoSample.'); } return this._encoder.add(videoSample, false, encodeOptions); } /** @internal */ override _flushAndClose(forceClose: boolean) { return this._encoder.flushAndClose(forceClose); } } /** * This source can be used to add video frames to the output track from a fixed canvas element. Since canvases are often * used for rendering, this source provides a convenient wrapper around {@link VideoSampleSource}. * @group Media sources * @public */ export class CanvasSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; /** @internal */ private _canvas: HTMLCanvasElement | OffscreenCanvas; /** * Creates a new {@link CanvasSource} from a canvas element or `OffscreenCanvas` whose samples are encoded * according to the specified {@link VideoEncodingConfig}. */ constructor(canvas: HTMLCanvasElement | OffscreenCanvas, encodingConfig: VideoEncodingConfig) { if ( !(typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement) && !(typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) ) { throw new TypeError('canvas must be an HTMLCanvasElement or OffscreenCanvas.'); } validateVideoEncodingConfig(encodingConfig); super(encodingConfig.codec); this._encoder = new VideoEncoderWrapper(this, encodingConfig); this._canvas = canvas; } /** * Captures the current canvas state as a video sample (frame), encodes it and adds it to the output. * * @param timestamp - The timestamp of the sample, in seconds. * @param duration - The duration of the sample, in seconds. * * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise * to respect writer and encoder backpressure. */ add(timestamp: number, duration = 0, encodeOptions?: VideoEncoderEncodeOptions) { if (!Number.isFinite(timestamp) || timestamp < 0) { throw new TypeError('timestamp must be a non-negative number.'); } if (!Number.isFinite(duration) || duration < 0) { throw new TypeError('duration must be a non-negative number.'); } const sample = new VideoSample(this._canvas, { timestamp, duration }); return this._encoder.add(sample, true, encodeOptions); } /** @internal */ override _flushAndClose(forceClose: boolean) { return this._encoder.flushAndClose(forceClose); } } /** * Options for {@link MediaStreamVideoTrackSource}. * @group Media sources * @public */ export type MediaStreamVideoTrackSourceOptions = { /** * The frame rate at which the underlying video track is sampled. Defaults to the frame rate specified in the * track's [`MediaTrackSettings`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings). Set to * `null` to only add a frame whenever the underlying track pushes one - this minimizes frame count but can * lead to wildly irregular FPS. */ frameRate?: number | null; /** * Controls the basis (zero point) for video frame timestamps. * * When set to `'synced-zero'`, timestamps will be relative to the first chunk of media from a `MediaStreamTrack` * added to the {@link Output}. * * When set to `'zero'`, timestamps will be relative to the first video frame emitted by this source. * * When set to `'unix'`, timestamps will be relative to the Unix epoch, so clearly associated with a distinct point * in time. Here, pausing via {@link MediaStreamVideoTrackSource.pause} will also create gaps in timestamps. Be sure * to pair this mode with {@link BaseTrackMetadata.isRelativeToUnixEpoch}. * * Defaults to `'synced-zero'`. */ timestampBase?: 'synced-zero' | 'zero' | 'unix'; }; /** * Video source that encodes the frames of a * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) and pipes them into the * output. This is useful for capturing live or real-time data such as webcams or screen captures. Frames will * automatically start being captured once the connected {@link Output} is started, and will keep being captured until * the {@link Output} is finalized or this source is closed. * @group Media sources * @public */ export class MediaStreamVideoTrackSource extends VideoSource { /** @internal */ private _options: MediaStreamVideoTrackSourceOptions; /** @internal */ private _encoder: VideoEncoderWrapper; /** @internal */ private _abortController: AbortController | null = null; /** @internal */ private _track: MediaStreamVideoTrack; /** @internal */ private _workerTrackId: number | null = null; /** @internal */ private _workerListener: ((event: MessageEvent) => void) | null = null; /** @internal */ private _promiseWithResolvers = promiseWithResolvers(); /** @internal */ private _errorPromiseAccessed = false; /** @internal */ private _paused = false; /** @internal */ private _lastVideoFrame: VideoFrame | null = null; /** @internal */ private _timerHandle: UnthrottledTimerHandle | null = null; /** @internal */ private _videoElement: HTMLVideoElement | null = null; /** A promise that rejects upon any error within this source. This promise never resolves. */ get errorPromise() { this._errorPromiseAccessed = true; return this._promiseWithResolvers.promise; } /** Whether this source is currently paused as a result of calling `.pause()`. */ get paused() { return this._paused; } /** * Creates a new {@link MediaStreamVideoTrackSource} from a * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull * video samples from the stream in real time and encode them according to {@link VideoEncodingConfig}. */ constructor( track: MediaStreamVideoTrack, encodingConfig: VideoEncodingConfig, options: MediaStreamVideoTrackSourceOptions = {}, ) { if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') { throw new TypeError('track must be a video MediaStreamTrack.'); } validateVideoEncodingConfig(encodingConfig); if (typeof options !== 'object' || !options) { throw new TypeError('options must be an object.'); } if (options.frameRate != null && (typeof options.frameRate !== 'number' || options.frameRate <= 0)) { throw new TypeError('options.frameRate, when provided, must be either a positive number or null.'); } if ( options.timestampBase !== undefined && options.timestampBase !== 'synced-zero' && options.timestampBase !== 'zero' && options.timestampBase !== 'unix' ) { throw new TypeError( 'options.timestampBase, when provided, must be one of \'synced-zero\', \'zero\', or \'unix\'.', ); } encodingConfig = { ...encodingConfig, latencyMode: 'realtime', }; super(encodingConfig.codec); this._options = options; this._encoder = new VideoEncoderWrapper(this, encodingConfig); this._track = track; } /** @internal */ override async _start() { if (!this._errorPromiseAccessed) { Logging._warn( 'Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal' + ' errors get bubbled up properly.', ); } const frameRate = this._options.frameRate !== undefined ? this._options.frameRate : (this._track.getSettings().frameRate ?? null); this._abortController = new AbortController(); let firstVideoFrameTimestamp: number | null = null; let lastFrameTime: number | null = null; let frameCount = 0; let errored = false; let lastSampleTimestamp: number | null = null; let timestampOffset = 0; const tick = () => { assert(frameRate !== null); if (!this._lastVideoFrame) { return; } assert(lastFrameTime !== null); assert(firstVideoFrameTimestamp !== null); const now = performance.now(); // Add as many frames as warranted by the elapsed time. // > instead of >= intentionally because tick() is called before the _lastVideoFrame is changed while (now - lastFrameTime > 1000 / frameRate) { lastFrameTime += 1000 / frameRate; const timestamp = firstVideoFrameTimestamp + frameCount / frameRate; const frame = new VideoFrame(this._videoElement ?? this._lastVideoFrame, { timestamp: 1e6 * timestamp, duration: 1e6 / frameRate, }); addVideoFrame(frame, now); } }; if (frameRate !== null) { this._timerHandle = setIntervalUnthrottled(tick, 4); // Run it at 250 Hz } const onVideoFrame = (videoFrame: VideoFrame) => { if (frameRate === null) { addVideoFrame(videoFrame); } else { const now = performance.now(); if (!this._lastVideoFrame) { addVideoFrame(videoFrame.clone(), now); lastFrameTime = now; this._lastVideoFrame = videoFrame; } else { tick(); this._lastVideoFrame?.close(); this._lastVideoFrame = videoFrame; } } }; const addVideoFrame = (videoFrame: VideoFrame, now = performance.now()) => { if (errored) { videoFrame.close(); return; } frameCount++; const currentTimestamp = videoFrame.timestamp / 1e6; if (this._paused) { const frameSeen = firstVideoFrameTimestamp !== null; if (frameSeen) { if (lastSampleTimestamp !== null && this._options.timestampBase !== 'unix') { // In addition to dropping this frame, let's also keep track of the time we have lost due to the // pause. Doing it like this instead of simply keeping track of the paused time is better since // it retains the frame rate of the underlying source. const timeDelta = currentTimestamp - lastSampleTimestamp; timestampOffset -= timeDelta; } lastSampleTimestamp = currentTimestamp; } videoFrame.close(); return; } if (firstVideoFrameTimestamp === null) { firstVideoFrameTimestamp = currentTimestamp; let target: number; const timestampBase = this._options.timestampBase ?? 'synced-zero'; if (timestampBase === 'unix') { target = Date.now() / 1000; } else if (timestampBase === 'zero') { target = 0; } else { const output = this._connectedTrack!.output; if (output._firstMediaStreamTimestamp === null) { output._firstMediaStreamTimestamp = now / 1000; target = 0; } else { target = now / 1000 - output._firstMediaStreamTimestamp; } } timestampOffset = target - firstVideoFrameTimestamp; } lastSampleTimestamp = currentTimestamp; if (this._encoder.getQueueSize() >= 8) { // Drop frames if the encoder is overloaded videoFrame.close(); return; } const sample = new VideoSample(videoFrame, { timestamp: currentTimestamp + timestampOffset, }); void this._encoder.add(sample, true) .catch((error) => { errored = true; this._abortController?.abort(); this._promiseWithResolvers.reject(error); if (this._workerTrackId !== null) { // Tell the worker to stop the track sendMessageToMediaStreamTrackProcessorWorker({ type: 'stopTrack', trackId: this._workerTrackId, }); } }); }; if (typeof MediaStreamTrackProcessor !== 'undefined') { // We can do it here directly, perfect const processor = new MediaStreamTrackProcessor({ track: this._track }); const consumer = new WritableStream({ write: onVideoFrame }); processor.readable.pipeTo(consumer, { signal: this._abortController.signal, }).catch((error) => { // Handle AbortError silently if (error instanceof DOMException && error.name === 'AbortError') return; this._promiseWithResolvers.reject(error); }); } else { // It might still be supported in a worker, so let's check that const supportedInWorker = await mediaStreamTrackProcessorIsSupportedInWorker(); if (supportedInWorker) { this._workerTrackId = nextMediaStreamTrackProcessorWorkerId++; sendMessageToMediaStreamTrackProcessorWorker({ type: 'videoTrack', trackId: this._workerTrackId, track: this._track, }); this._workerListener = (event: MessageEvent) => { const message = event.data as MediaStreamTrackProcessorWorkerMessage; if (message.type === 'videoFrame' && message.trackId === this._workerTrackId) { onVideoFrame(message.videoFrame); } else if (message.type === 'error' && message.trackId === this._workerTrackId) { this._promiseWithResolvers.reject(message.error); } }; mediaStreamTrackProcessorWorker!.addEventListener('message', this._workerListener); } else if (frameRate !== null) { // No MediaStreamTrackProcessor support at all (e.g. Firefox), but we have a frame rate, so we can // manually sample from a hidden