diff --git a/dev/demux.html b/dev/demux.html index 6d93d39..ab1d1c6 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -17,14 +17,11 @@ source: new Mediabunny.BlobSource(file), }); - const track = await input.getPrimaryAudioTrack(); - const sink = new Mediabunny.AudioSampleSink(track); - - for await (const sample of sink.samples()) { - console.log(sample); - sample.close() - } + const track = await input.getPrimaryVideoTrack(); + const sink = new Mediabunny.EncodedPacketSink(track); + const packet = await sink.getFirstPacket(); + console.log(packet); /* for await (const packet of packetSink.packets()) { diff --git a/src/codec-data.ts b/src/codec-data.ts index 071720d..5d80fee 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -1868,6 +1868,12 @@ export const extractVp9CodecInfoFromPacket = ( }; }; +export const vp9CodecInfoHasColorInfo = (info: Vp9CodecInfo) => { + return info.colourPrimaries !== 2 + || info.transferCharacteristics !== 2 + || info.matrixCoefficients !== 2; +}; + export type Av1CodecInfo = { profile: number; level: number; @@ -1877,6 +1883,10 @@ export type Av1CodecInfo = { chromaSubsamplingX: number; chromaSubsamplingY: number; chromaSamplePosition: number; + videoFullRangeFlag: number; + colourPrimaries: number; + transferCharacteristics: number; + matrixCoefficients: number; }; /** Iterates over all OBUs in an AV1 packet bitstream. */ @@ -1891,7 +1901,8 @@ export const iterateAv1PacketObus = function* (packet: Uint8Array) { for (let i = 0; i < 8; i++) { const byte = bitstream.readAlignedByte(); - value |= ((byte & 0x7f) << (i * 7)); + // No bit shift since the value can get big + value += (byte & 0x7f) * 2 ** (i * 7); if (!(byte & 0x80)) { break; @@ -1904,7 +1915,7 @@ export const iterateAv1PacketObus = function* (packet: Uint8Array) { } // Spec requirement - if (value >= 2 ** 32 - 1) { + if (value > 2 ** 32 - 1) { return null; } @@ -1978,43 +1989,43 @@ export const extractAv1CodecInfoFromPacket = ( if (reducedStillPictureHeader) { seqLevel = bitstream.readBits(5); } else { - // Parse timing_info_present_flag const timingInfoPresentFlag = bitstream.readBits(1); + let decoderModelInfoPresentFlag = 0; + if (timingInfoPresentFlag) { - // Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval) bitstream.skipBits(32); // num_units_in_display_tick bitstream.skipBits(32); // time_scale const equalPictureInterval = bitstream.readBits(1); if (equalPictureInterval) { - // Skip num_ticks_per_picture_minus_1 (uvlc) - // Since this is variable length, we'd need to implement uvlc reading - // For now, we'll return null as this is rare - return null; + // num_ticks_per_picture_minus_1 is uvlc-coded, so we need to seek past it manually + let leadingZeros = 0; + while (leadingZeros < 32 && !bitstream.readBits(1)) { + leadingZeros++; + } + + if (leadingZeros < 32) { + bitstream.skipBits(leadingZeros); + } + } + + decoderModelInfoPresentFlag = bitstream.readBits(1); + + if (decoderModelInfoPresentFlag) { + bufferDelayLengthMinus1 = bitstream.readBits(5); + bitstream.skipBits(32); // num_units_in_decoding_tick + bitstream.skipBits(5); // buffer_removal_time_length_minus_1 + bitstream.skipBits(5); // frame_presentation_time_length_minus_1 } } - // Parse decoder_model_info_present_flag - const decoderModelInfoPresentFlag = bitstream.readBits(1); - - if (decoderModelInfoPresentFlag) { - // Store buffer_delay_length_minus_1 instead of just skipping - bufferDelayLengthMinus1 = bitstream.readBits(5); - bitstream.skipBits(32); // num_units_in_decoding_tick - bitstream.skipBits(5); // buffer_removal_time_length_minus_1 - bitstream.skipBits(5); // frame_presentation_time_length_minus_1 - } - - // Parse operating_points_cnt_minus_1 + const initialDisplayDelayPresentFlag = bitstream.readBits(1); const operatingPointsCntMinus1 = bitstream.readBits(5); - // For each operating point for (let i = 0; i <= operatingPointsCntMinus1; i++) { - // operating_point_idc[i] - bitstream.skipBits(12); + bitstream.skipBits(12); // operating_point_idc[i] - // seq_level_idx[i] const seqLevelIdx = bitstream.readBits(5); if (i === 0) { @@ -2022,7 +2033,6 @@ export const extractAv1CodecInfoFromPacket = ( } if (seqLevelIdx > 7) { - // seq_tier[i] const seqTierTemp = bitstream.readBits(1); if (i === 0) { seqTier = seqTierTemp; @@ -2030,7 +2040,6 @@ export const extractAv1CodecInfoFromPacket = ( } if (decoderModelInfoPresentFlag) { - // decoder_model_present_for_this_op[i] const decoderModelPresentForThisOp = bitstream.readBits(1); if (decoderModelPresentForThisOp) { @@ -2041,12 +2050,12 @@ export const extractAv1CodecInfoFromPacket = ( } } - // initial_display_delay_present_flag - const initialDisplayDelayPresentFlag = bitstream.readBits(1); - if (initialDisplayDelayPresentFlag) { - // initial_display_delay_minus_1[i] - bitstream.skipBits(4); + const initialDisplayDelayPresentForThisOp = bitstream.readBits(1); + + if (initialDisplayDelayPresentForThisOp) { + bitstream.skipBits(4); // initial_display_delay_minus_1[i] + } } } } @@ -2129,11 +2138,32 @@ export const extractAv1CodecInfoFromPacket = ( monochrome = bitstream.readBits(1); } + let colourPrimaries = 2; // CP_UNSPECIFIED + let transferCharacteristics = 2; // TC_UNSPECIFIED + let matrixCoefficients = 2; // MC_UNSPECIFIED + + const colorDescriptionPresentFlag = bitstream.readBits(1); + if (colorDescriptionPresentFlag) { + colourPrimaries = bitstream.readBits(8); + transferCharacteristics = bitstream.readBits(8); + matrixCoefficients = bitstream.readBits(8); + } + + let videoFullRangeFlag = 0; let chromaSubsamplingX = 1; let chromaSubsamplingY = 1; - let chromaSamplePosition = 0; + let chromaSamplePosition = 0; // CSP_UNKNOWN + + if (monochrome) { + videoFullRangeFlag = bitstream.readBits(1); + } else if (colourPrimaries === 1 && transferCharacteristics === 13 && matrixCoefficients === 0) { + // sRGB with an identity matrix, which is always full range 4:4:4 + videoFullRangeFlag = 1; + chromaSubsamplingX = 0; + chromaSubsamplingY = 0; + } else { + videoFullRangeFlag = bitstream.readBits(1); - if (!monochrome) { if (seqProfile === 0) { chromaSubsamplingX = 1; chromaSubsamplingY = 1; @@ -2143,9 +2173,10 @@ export const extractAv1CodecInfoFromPacket = ( } else { if (bitDepth === 12) { chromaSubsamplingX = bitstream.readBits(1); - if (chromaSubsamplingX) { - chromaSubsamplingY = bitstream.readBits(1); - } + chromaSubsamplingY = chromaSubsamplingX ? bitstream.readBits(1) : 0; + } else { + chromaSubsamplingX = 1; + chromaSubsamplingY = 0; } } @@ -2163,12 +2194,56 @@ export const extractAv1CodecInfoFromPacket = ( chromaSubsamplingX, chromaSubsamplingY, chromaSamplePosition, + videoFullRangeFlag, + colourPrimaries, + transferCharacteristics, + matrixCoefficients, }; } return null; }; +export const av1CodecInfoHasColorInfo = (info: Av1CodecInfo) => { + return info.colourPrimaries !== 2 + || info.transferCharacteristics !== 2 + || info.matrixCoefficients !== 2; +}; + +export type ProresCodecInfo = { + colourPrimaries: number; + transferCharacteristics: number; + matrixCoefficients: number; + fullRange: boolean; +}; + +export const extractProresCodecInfoFromPacket = (packet: Uint8Array): ProresCodecInfo | null => { + // https://wiki.multimedia.cx/index.php/Apple_ProRes + + const frameHeaderStart = 8; + if (packet.length < frameHeaderStart + 28) { + return null; + } + + const view = toDataView(packet); + + if (view.getUint32(4) !== 0x69637066) { // 'icpf' + return null; + } + + const headerSize = view.getUint16(frameHeaderStart); + if (headerSize < 28) { + return null; + } + + return { + fullRange: false, // ProRes is always limited range + colourPrimaries: view.getUint8(frameHeaderStart + 14), + transferCharacteristics: view.getUint8(frameHeaderStart + 15), + matrixCoefficients: view.getUint8(frameHeaderStart + 16), + }; +}; + export const parseOpusIdentificationHeader = (bytes: Uint8Array) => { const view = toDataView(bytes); diff --git a/src/codec.ts b/src/codec.ts index 63c41d1..23f1be0 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -10,13 +10,22 @@ import { parseAacAudioSpecificConfig } from '../shared/aac-misc'; import { Av1CodecInfo, AvcDecoderConfigurationRecord, + deserializeAvcDecoderConfigurationRecord, + deserializeHevcDecoderConfigurationRecord, HevcDecoderConfigurationRecord, + HevcNalUnitType, + parseAvcSps, + parseHevcSps, + ProresCodecInfo, Vp9CodecInfo, } from './codec-data'; import { COLOR_PRIMARIES_MAP, + COLOR_PRIMARIES_MAP_INVERSE, MATRIX_COEFFICIENTS_MAP, + MATRIX_COEFFICIENTS_MAP_INVERSE, TRANSFER_CHARACTERISTICS_MAP, + TRANSFER_CHARACTERISTICS_MAP_INVERSE, assert, assertNever, base64ToBytes, @@ -370,7 +379,7 @@ export const generateAv1CodecConfigurationFromCodecString = (codecString: string const tier = levelAndTier.slice(-1) === 'H' ? 1 : 0; const bitDepth = Number(parts[3]); const highBitDepth = bitDepth === 8 ? 0 : 1; - const twelveBit = 0; + const twelveBit = bitDepth === 12 ? 1 : 0; const monochrome = parts[4] ? Number(parts[4]) : 0; const chromaSubsamplingX = parts[5] ? Number(parts[5][0]) : 1; const chromaSubsamplingY = parts[5] ? Number(parts[5][1]) : 1; @@ -578,6 +587,132 @@ export const extractVideoCodecString = (trackInfo: { throw new TypeError(`Unhandled codec '${codec}'.`); }; +export const extractColorSpace = (info: { + codec: VideoCodec | null; + codecDescription: Uint8Array | null; + avcCodecInfo: AvcDecoderConfigurationRecord | null; + hevcCodecInfo: HevcDecoderConfigurationRecord | null; + vp9CodecInfo: Vp9CodecInfo | null; + av1CodecInfo: Av1CodecInfo | null; + proresCodecInfo: ProresCodecInfo | null; +}): VideoColorSpaceInit => { + switch (info.codec) { + case 'avc': { + let spsData = info.avcCodecInfo?.sequenceParameterSets[0]; + if (!spsData && info.codecDescription) { + spsData = deserializeAvcDecoderConfigurationRecord(info.codecDescription)?.sequenceParameterSets[0]; + } + + if (spsData) { + const spsInfo = parseAvcSps(spsData); + if (spsInfo) { + return { + primaries: + COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries] as + VideoColorPrimaries | undefined, + transfer: + TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics] as + VideoTransferCharacteristics | undefined, + matrix: + MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients] as + VideoMatrixCoefficients | undefined, + fullRange: !!spsInfo.fullRangeFlag, + }; + } + } + }; break; + + case 'hevc': { + let spsData = info.hevcCodecInfo?.arrays + .find(x => x.nalUnitType === HevcNalUnitType.SPS_NUT)?.nalUnits[0]; + if (!spsData && info.codecDescription) { + spsData = deserializeHevcDecoderConfigurationRecord(info.codecDescription)?.arrays + .find(x => x.nalUnitType === HevcNalUnitType.SPS_NUT)?.nalUnits[0]; + } + + if (spsData) { + const spsInfo = parseHevcSps(spsData); + if (spsInfo) { + return { + primaries: + COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries] as + VideoColorPrimaries | undefined, + transfer: + TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics] as + VideoTransferCharacteristics | undefined, + matrix: + MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients] as + VideoMatrixCoefficients | undefined, + fullRange: !!spsInfo.fullRangeFlag, + }; + } + } + }; break; + + case 'vp8': { + // The situation is fucky; do nothing for now. + }; break; + + case 'vp9': { + if (info.vp9CodecInfo) { + return { + primaries: + COLOR_PRIMARIES_MAP_INVERSE[info.vp9CodecInfo.colourPrimaries] as + VideoColorPrimaries | undefined, + transfer: + TRANSFER_CHARACTERISTICS_MAP_INVERSE[info.vp9CodecInfo.transferCharacteristics] as + VideoTransferCharacteristics | undefined, + matrix: + MATRIX_COEFFICIENTS_MAP_INVERSE[info.vp9CodecInfo.matrixCoefficients] as + VideoMatrixCoefficients | undefined, + fullRange: !!info.vp9CodecInfo.videoFullRangeFlag, + }; + } + }; break; + + case 'av1': { + if (info.av1CodecInfo) { + return { + primaries: + COLOR_PRIMARIES_MAP_INVERSE[info.av1CodecInfo.colourPrimaries] as + VideoColorPrimaries | undefined, + transfer: + TRANSFER_CHARACTERISTICS_MAP_INVERSE[info.av1CodecInfo.transferCharacteristics] as + VideoTransferCharacteristics | undefined, + matrix: + MATRIX_COEFFICIENTS_MAP_INVERSE[info.av1CodecInfo.matrixCoefficients] as + VideoMatrixCoefficients | undefined, + fullRange: !!info.av1CodecInfo.videoFullRangeFlag, + }; + } + }; break; + + case 'prores': { + if (info.proresCodecInfo) { + return { + primaries: + COLOR_PRIMARIES_MAP_INVERSE[info.proresCodecInfo.colourPrimaries] as + VideoColorPrimaries | undefined, + transfer: + TRANSFER_CHARACTERISTICS_MAP_INVERSE[info.proresCodecInfo.transferCharacteristics] as + VideoTransferCharacteristics | undefined, + matrix: + MATRIX_COEFFICIENTS_MAP_INVERSE[info.proresCodecInfo.matrixCoefficients] as + VideoMatrixCoefficients | undefined, + fullRange: info.proresCodecInfo.fullRange, + }; + } + }; break; + } + + return { + primaries: undefined, + transfer: undefined, + matrix: undefined, + fullRange: undefined, + }; +}; + 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 diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index ca3ba8b..ae07a7c 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -16,7 +16,7 @@ import { COLOR_PRIMARIES_MAP, TRANSFER_CHARACTERISTICS_MAP, MATRIX_COEFFICIENTS_MAP, - colorSpaceIsComplete, + colorSpaceIsEmpty, UNDETERMINED_LANGUAGE, assertNever, keyValueIterator, @@ -750,7 +750,9 @@ export const videoSampleDescription = ( ], [ VIDEO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec]?.(trackData) ?? null, pasp(trackData), - colorSpaceIsComplete(trackData.info.decoderConfig.colorSpace) ? colr(trackData) : null, + colorSpaceIsEmpty(trackData.info.decoderConfig.colorSpace) + ? null + : colr(trackData), ]); /** Pixel Aspect Ratio Box: Specifies pixel width:height spacing for non-square pixels. */ @@ -766,15 +768,28 @@ export const pasp = (trackData: IsobmffVideoTrackData) => { }; /** Colour Information Box: Specifies the color space of the video. */ -export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [ - ascii(trackData.muxer.isQuickTime ? 'nclc' : 'nclx'), // Colour type - u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace!.primaries!]), // Colour primaries - u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics - u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace!.matrix!]), // Matrix coefficients - trackData.muxer.isQuickTime - ? [] // Doesn't have it - : u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag -]); +export const colr = (trackData: IsobmffVideoTrackData) => { + const colorSpace = trackData.info.decoderConfig.colorSpace; + + return box('colr', [ + // Colour type + ascii(trackData.muxer.isQuickTime ? 'nclc' : 'nclx'), + + // Colour primaries + u16(colorSpace?.primaries != null ? COLOR_PRIMARIES_MAP[colorSpace.primaries] : 2), + + // Transfer characteristics + u16(colorSpace?.transfer != null ? TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer] : 2), + + // Matrix coefficients + u16(colorSpace?.matrix != null ? MATRIX_COEFFICIENTS_MAP[colorSpace.matrix] : 2), + + // Full range flag + trackData.muxer.isQuickTime + ? [] // Doesn't have it + : u8((colorSpace?.fullRange ? 1 : 0) << 7), + ]); +}; /** AVC Configuration Box: Provides additional information to the decoder. */ export const avcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoderConfig && box('avcC', [ @@ -811,17 +826,17 @@ export const vpcC = (trackData: IsobmffVideoTrackData) => { ? Number(parts[5]) : decoderConfig.colorSpace?.primaries ? COLOR_PRIMARIES_MAP[decoderConfig.colorSpace.primaries] - : 2; // Default to undetermined + : 1; const transferCharacteristics = parts[6] ? Number(parts[6]) : decoderConfig.colorSpace?.transfer ? TRANSFER_CHARACTERISTICS_MAP[decoderConfig.colorSpace.transfer] - : 2; + : 1; const matrixCoefficients = parts[7] ? Number(parts[7]) : decoderConfig.colorSpace?.matrix ? MATRIX_COEFFICIENTS_MAP[decoderConfig.colorSpace.matrix] - : 2; + : 1; return fullBox('vpcC', 1, 0, [ u8(profile), // Profile diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 0e0fe60..48711f6 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -14,6 +14,7 @@ import { DTS_FOURCCS, DtsFourCc, extractAudioCodecString, + extractColorSpace, extractVideoCodecString, MediaCodec, OPUS_SAMPLE_RATE, @@ -26,12 +27,16 @@ import { } from '../codec'; import { Av1CodecInfo, + av1CodecInfoHasColorInfo, AvcDecoderConfigurationRecord, extractAv1CodecInfoFromPacket, + extractProresCodecInfoFromPacket, extractVp9CodecInfoFromPacket, FlacBlockType, HevcDecoderConfigurationRecord, + ProresCodecInfo, Vp9CodecInfo, + vp9CodecInfoHasColorInfo, parseEac3Config, getEac3SampleRate, getEac3ChannelCount, @@ -72,6 +77,8 @@ import { roundIfAlmostInteger, hexStringToBytes, HEX_STRING_REGEX, + EMPTY_COLOR_SPACE, + colorSpaceIsComplete, } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { buildIsobmffMimeType, parsePsshBoxContents, psshBoxesAreEqual, PsshBox } from './isobmff-misc'; @@ -151,12 +158,13 @@ type InternalTrack = { squarePixelHeight: number; codec: VideoCodec | null; codecDescription: Uint8Array | null; - colorSpace: VideoColorSpaceInit | null; + colorSpace: VideoColorSpaceInit; avcType: 1 | 3 | null; avcCodecInfo: AvcDecoderConfigurationRecord | null; hevcCodecInfo: HevcDecoderConfigurationRecord | null; vp9CodecInfo: Vp9CodecInfo | null; av1CodecInfo: Av1CodecInfo | null; + proresCodecInfo: ProresCodecInfo | null; proresFormat: ProresFourCc | null; }; } | { @@ -1027,12 +1035,13 @@ export class IsobmffDemuxer extends Demuxer { squarePixelHeight: -1, codec: null, codecDescription: null, - colorSpace: null, + colorSpace: { ...EMPTY_COLOR_SPACE }, avcType: null, avcCodecInfo: null, hevcCodecInfo: null, vp9CodecInfo: null, av1CodecInfo: null, + proresCodecInfo: null, proresFormat: null, }; } else if (handlerType === 'soun') { @@ -1472,6 +1481,12 @@ export class IsobmffDemuxer extends Demuxer { // Logic from https://aomediacodec.github.io/av1-spec/av1-spec.pdf const bitDepth = profile === 2 && highBitDepth ? (twelveBit ? 12 : 10) : (highBitDepth ? 10 : 8); + slice.skip(1); // Reserved bits + initial presentation delay + + // Parse config OBUs if there are any + const configObus = readBytes(slice, boxInfo.contentSize - 4); + const configObuInfo = extractAv1CodecInfoFromPacket(configObus); + track.info.av1CodecInfo = { profile, level, @@ -1481,6 +1496,10 @@ export class IsobmffDemuxer extends Demuxer { chromaSubsamplingX, chromaSubsamplingY, chromaSamplePosition, + videoFullRangeFlag: configObuInfo?.videoFullRangeFlag ?? 0, + colourPrimaries: configObuInfo?.colourPrimaries ?? 2, + transferCharacteristics: configObuInfo?.transferCharacteristics ?? 2, + matrixCoefficients: configObuInfo?.matrixCoefficients ?? 2, }; }; break; @@ -3410,11 +3429,16 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo } async getColorSpace(): Promise { + const decoderConfig = await this.getDecoderConfig(); + if (!decoderConfig) { + return this.internalTrack.info.colorSpace; + } + return { - primaries: this.internalTrack.info.colorSpace?.primaries, - transfer: this.internalTrack.info.colorSpace?.transfer, - matrix: this.internalTrack.info.colorSpace?.matrix, - fullRange: this.internalTrack.info.colorSpace?.fullRange, + primaries: decoderConfig.colorSpace?.primaries, + transfer: decoderConfig.colorSpace?.transfer, + matrix: decoderConfig.colorSpace?.matrix, + fullRange: decoderConfig.colorSpace?.fullRange, }; } @@ -3439,12 +3463,56 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo const firstPacket = await this.getFirstPacket({}); this.internalTrack.info.hevcCodecInfo = firstPacket && extractHevcDecoderConfigurationRecord(firstPacket.data); - } else if (this.internalTrack.info.codec === 'vp9' && !this.internalTrack.info.vp9CodecInfo) { + } else if ( + this.internalTrack.info.codec === 'vp9' + && ( + !this.internalTrack.info.vp9CodecInfo + // The codec info extracted from vpcC may claim the color space is "undefined" + || !vp9CodecInfoHasColorInfo(this.internalTrack.info.vp9CodecInfo) + ) + ) { const firstPacket = await this.getFirstPacket({}); - this.internalTrack.info.vp9CodecInfo = firstPacket && extractVp9CodecInfoFromPacket(firstPacket.data); - } else if (this.internalTrack.info.codec === 'av1' && !this.internalTrack.info.av1CodecInfo) { + const packetInfo = firstPacket && extractVp9CodecInfoFromPacket(firstPacket.data); + + if (packetInfo) { + this.internalTrack.info.vp9CodecInfo = { + ...(this.internalTrack.info.vp9CodecInfo ?? packetInfo), + videoFullRangeFlag: packetInfo.videoFullRangeFlag, + colourPrimaries: packetInfo.colourPrimaries, + transferCharacteristics: packetInfo.transferCharacteristics, + matrixCoefficients: packetInfo.matrixCoefficients, + }; + } + } else if ( + this.internalTrack.info.codec === 'av1' + && ( + !this.internalTrack.info.av1CodecInfo + // The codec info extracted from av1C may not contain color space information + || !av1CodecInfoHasColorInfo( + this.internalTrack.info.av1CodecInfo, + ) + ) + ) { const firstPacket = await this.getFirstPacket({}); - this.internalTrack.info.av1CodecInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data); + const packetInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data); + + if (packetInfo) { + this.internalTrack.info.av1CodecInfo = packetInfo; + } + } else if (this.internalTrack.info.codec === 'prores' && !this.internalTrack.info.proresCodecInfo) { + const firstPacket = await this.getFirstPacket({}); + this.internalTrack.info.proresCodecInfo + = firstPacket && extractProresCodecInfoFromPacket(firstPacket.data); + } + + if (!colorSpaceIsComplete(this.internalTrack.info.colorSpace)) { + const colorSpace = extractColorSpace(this.internalTrack.info); + + // Fill the missing values + this.internalTrack.info.colorSpace.primaries ??= colorSpace.primaries; + this.internalTrack.info.colorSpace.transfer ??= colorSpace.transfer; + this.internalTrack.info.colorSpace.matrix ??= colorSpace.matrix; + this.internalTrack.info.colorSpace.fullRange ??= colorSpace.fullRange; } const config: VideoDecoderConfig = { @@ -3452,7 +3520,7 @@ class IsobmffVideoTrackBacking extends IsobmffTrackBacking implements InputVideo codedWidth: this.internalTrack.info.width, codedHeight: this.internalTrack.info.height, description: this.internalTrack.info.codecDescription ?? undefined, - colorSpace: this.internalTrack.info.colorSpace ?? undefined, + colorSpace: this.internalTrack.info.colorSpace, }; if ( diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 8f397c1..d9e199d 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -12,6 +12,7 @@ import { extractDtsFourCcFromPacket, extractAvcDecoderConfigurationRecord, extractHevcDecoderConfigurationRecord, + extractProresCodecInfoFromPacket, extractVp9CodecInfoFromPacket, } from '../codec-data'; import { @@ -19,6 +20,7 @@ import { AudioCodec, DtsFourCc, extractAudioCodecString, + extractColorSpace, extractVideoCodecString, MediaCodec, OPUS_SAMPLE_RATE, @@ -40,6 +42,8 @@ import { assert, binarySearchLessOrEqual, COLOR_PRIMARIES_MAP_INVERSE, + colorSpaceIsComplete, + EMPTY_COLOR_SPACE, findLastIndex, isIso639Dash2LanguageCode, isThenable, @@ -220,7 +224,7 @@ type InternalTrack = { rotation: Rotation; codec: VideoCodec | null; codecDescription: Uint8Array | null; - colorSpace: VideoColorSpaceInit | null; + colorSpace: VideoColorSpaceInit; alphaMode: boolean; proresFormat: ProresFourCc | null; } @@ -1199,7 +1203,7 @@ export class MatroskaDemuxer extends Demuxer { rotation: 0, codec: null, codecDescription: null, - colorSpace: null, + colorSpace: { ...EMPTY_COLOR_SPACE }, alphaMode: false, proresFormat: null, }; @@ -1363,37 +1367,39 @@ export class MatroskaDemuxer extends Demuxer { case EBMLId.Colour: { if (this.currentTrack?.info?.type !== 'video') break; - this.currentTrack.info.colorSpace = {}; this.readContiguousElements(slice.slice(dataStartPos, size)); }; break; case EBMLId.MatrixCoefficients: { - if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break; + if (this.currentTrack?.info?.type !== 'video') break; const matrixCoefficients = readUnsignedInt(slice, size); - const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients] ?? null; + const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients]; this.currentTrack.info.colorSpace.matrix = mapped as VideoColorSpaceInit['matrix']; }; break; case EBMLId.Range: { - if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break; + if (this.currentTrack?.info?.type !== 'video') break; - this.currentTrack.info.colorSpace.fullRange = readUnsignedInt(slice, size) === 2; + const range = readUnsignedInt(slice, size); + this.currentTrack.info.colorSpace.fullRange = range === 1 || range === 2 + ? range === 2 + : undefined; }; break; case EBMLId.TransferCharacteristics: { - if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break; + if (this.currentTrack?.info?.type !== 'video') break; const transferCharacteristics = readUnsignedInt(slice, size); - const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics] ?? null; + const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics]; this.currentTrack.info.colorSpace.transfer = mapped as VideoColorSpaceInit['transfer']; }; break; case EBMLId.Primaries: { - if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace) break; + if (this.currentTrack?.info?.type !== 'video') break; const primaries = readUnsignedInt(slice, size); - const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries] ?? null; + const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries]; this.currentTrack.info.colorSpace.primaries = mapped as VideoColorSpaceInit['primaries']; }; break; @@ -2490,11 +2496,16 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid } async getColorSpace(): Promise { + const decoderConfig = await this.getDecoderConfig(); + if (!decoderConfig) { + return this.internalTrack.info.colorSpace; + } + return { - primaries: this.internalTrack.info.colorSpace?.primaries, - transfer: this.internalTrack.info.colorSpace?.transfer, - matrix: this.internalTrack.info.colorSpace?.matrix, - fullRange: this.internalTrack.info.colorSpace?.fullRange, + primaries: decoderConfig.colorSpace?.primaries, + transfer: decoderConfig.colorSpace?.transfer, + matrix: decoderConfig.colorSpace?.matrix, + fullRange: decoderConfig.colorSpace?.fullRange, }; } @@ -2517,6 +2528,7 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid const needsPacketForAdditionalInfo = this.internalTrack.info.codec === 'vp9' || this.internalTrack.info.codec === 'av1' + || this.internalTrack.info.codec === 'prores' // Packets are in Annex B format: || (this.internalTrack.info.codec === 'avc' && !this.internalTrack.info.codecDescription) // Packets are in Annex B format: @@ -2526,32 +2538,47 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid firstPacket = await this.getFirstPacket({}); } + const codecInfo = { + width: this.internalTrack.info.width, + height: this.internalTrack.info.height, + codec: this.internalTrack.info.codec, + codecDescription: this.internalTrack.info.codecDescription, + colorSpace: this.internalTrack.info.colorSpace, + avcType: 1 as const, // We don't know better (or do we?) so just assume 'avc1' + avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket + ? extractAvcDecoderConfigurationRecord(firstPacket.data) + : null, + hevcCodecInfo: this.internalTrack.info.codec === 'hevc' && firstPacket + ? extractHevcDecoderConfigurationRecord(firstPacket.data) + : null, + vp9CodecInfo: this.internalTrack.info.codec === 'vp9' && firstPacket + ? extractVp9CodecInfoFromPacket(firstPacket.data) + : null, + av1CodecInfo: this.internalTrack.info.codec === 'av1' && firstPacket + ? extractAv1CodecInfoFromPacket(firstPacket.data) + : null, + proresCodecInfo: this.internalTrack.info.codec === 'prores' && firstPacket + ? extractProresCodecInfoFromPacket(firstPacket.data) + : null, + proresFormat: this.internalTrack.info.proresFormat, + }; + + if (!colorSpaceIsComplete(this.internalTrack.info.colorSpace)) { + const colorSpace = extractColorSpace(codecInfo); + + // Fill the missing values + this.internalTrack.info.colorSpace.primaries ??= colorSpace.primaries; + this.internalTrack.info.colorSpace.transfer ??= colorSpace.transfer; + this.internalTrack.info.colorSpace.matrix ??= colorSpace.matrix; + this.internalTrack.info.colorSpace.fullRange ??= colorSpace.fullRange; + } + const config: VideoDecoderConfig = { - codec: extractVideoCodecString({ - width: this.internalTrack.info.width, - height: this.internalTrack.info.height, - codec: this.internalTrack.info.codec, - codecDescription: this.internalTrack.info.codecDescription, - colorSpace: this.internalTrack.info.colorSpace, - avcType: 1, // We don't know better (or do we?) so just assume 'avc1' - avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket - ? extractAvcDecoderConfigurationRecord(firstPacket.data) - : null, - hevcCodecInfo: this.internalTrack.info.codec === 'hevc' && firstPacket - ? extractHevcDecoderConfigurationRecord(firstPacket.data) - : null, - vp9CodecInfo: this.internalTrack.info.codec === 'vp9' && firstPacket - ? extractVp9CodecInfoFromPacket(firstPacket.data) - : null, - av1CodecInfo: this.internalTrack.info.codec === 'av1' && firstPacket - ? extractAv1CodecInfoFromPacket(firstPacket.data) - : null, - proresFormat: this.internalTrack.info.proresFormat, - }), + codec: extractVideoCodecString(codecInfo), codedWidth: this.internalTrack.info.width, codedHeight: this.internalTrack.info.height, description: this.internalTrack.info.codecDescription ?? undefined, - colorSpace: this.internalTrack.info.colorSpace ?? undefined, + colorSpace: this.internalTrack.info.colorSpace, }; if ( diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index dc213a8..01b2dde 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -14,7 +14,7 @@ import { UNDETERMINED_LANGUAGE, assert, assertNever, - colorSpaceIsComplete, + colorSpaceIsEmpty, imageMimeTypeToExtension, keyValueIterator, normalizeRotation, @@ -405,29 +405,31 @@ export class MatroskaMuxer extends Muxer { (hasNonSquarePixelAspectRatio ? { id: EBMLId.DisplayHeight, data: trackData.info.aspectRatio!.den } : null), (hasNonSquarePixelAspectRatio ? { id: EBMLId.DisplayUnit, data: 3 } : null), // 3 = display aspect ratio trackData.info.alphaMode ? { id: EBMLId.AlphaMode, data: 1 } : null, - (colorSpaceIsComplete(colorSpace) - ? { + (colorSpaceIsEmpty(colorSpace) + ? null + : { id: EBMLId.Colour, data: [ { id: EBMLId.MatrixCoefficients, - data: MATRIX_COEFFICIENTS_MAP[colorSpace.matrix], + data: colorSpace?.matrix != null ? MATRIX_COEFFICIENTS_MAP[colorSpace.matrix] : 2, }, { id: EBMLId.TransferCharacteristics, - data: TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer], + data: colorSpace?.transfer != null + ? TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer] + : 2, }, { id: EBMLId.Primaries, - data: COLOR_PRIMARIES_MAP[colorSpace.primaries], + data: colorSpace?.primaries != null ? COLOR_PRIMARIES_MAP[colorSpace.primaries] : 2, }, { id: EBMLId.Range, - data: colorSpace.fullRange ? 2 : 1, + data: colorSpace?.fullRange != null ? (colorSpace.fullRange ? 2 : 1) : 0, }, ], - } - : null), + }), (flippedRotation ? { id: EBMLId.Projection, diff --git a/src/media-sink.ts b/src/media-sink.ts index 0edc4dd..f34b761 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -29,6 +29,7 @@ import { assertNever, CallSerializer, clamp, + colorSpaceIsComplete, getInt24, getUint24, insertSorted, @@ -944,20 +945,42 @@ class VideoDecoderWrapper extends DecoderWrapper { } }; - if (codec === 'avc' && this.decoderConfig.description && isChromium()) { - // Chromium has/had a bug with playing interlaced AVC (https://issues.chromium.org/issues/456919096) - // which can be worked around by requesting that software decoding be used. So, here we peek into the - // AVC description, if present, and switch to software decoding if we find interlaced content. - const record = deserializeAvcDecoderConfigurationRecord(toUint8Array(this.decoderConfig.description)); - if (record && record.sequenceParameterSets.length > 0) { - const sps = parseAvcSps(record.sequenceParameterSets[0]!); - if (sps && sps.frameMbsOnlyFlag === 0) { - this.decoderConfig = { - ...this.decoderConfig, - hardwareAcceleration: 'prefer-software', - }; + if (isChromium()) { + if (codec === 'avc' && this.decoderConfig.description) { + // Chromium has/had a bug with playing interlaced AVC (https://issues.chromium.org/issues/456919096) + // which can be worked around by requesting that software decoding be used. So, here we peek into + // the AVC description, if present, and switch to software decoding if we find interlaced content. + const record = deserializeAvcDecoderConfigurationRecord( + toUint8Array(this.decoderConfig.description), + ); + if (record && record.sequenceParameterSets.length > 0) { + const sps = parseAvcSps(record.sequenceParameterSets[0]!); + if (sps && sps.frameMbsOnlyFlag === 0) { + this.decoderConfig = { + ...this.decoderConfig, + hardwareAcceleration: 'prefer-software', + }; + } } } + + if (!colorSpaceIsComplete(this.decoderConfig.colorSpace)) { + // Found via https://github.com/remotion-dev/remotion/issues/10841. + // If the color space is incomplete (which is often that it's just partially filled), Chromium has + // some nasty logic where it doesn't pass that information along to the GPU at all. The result is + // that information is genuinely lost, like the color matrix for example. Chromium has other code + // paths where it just fills the missing values with a hardcoded default, so we do the exact same + // thing here, with the same hardcoded defaults: + this.decoderConfig = { + ...this.decoderConfig, + colorSpace: { + primaries: this.decoderConfig.colorSpace?.primaries ?? 'bt709', + matrix: this.decoderConfig.colorSpace?.matrix ?? 'bt709', + transfer: this.decoderConfig.colorSpace?.transfer ?? 'bt709', + fullRange: this.decoderConfig.colorSpace?.fullRange ?? false, + }, + }; + } } const stack = new Error('Decoding error').stack; diff --git a/src/misc.ts b/src/misc.ts index 3a247d3..ac89a48 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -162,6 +162,25 @@ export const colorSpaceIsComplete = ( ); }; +export const colorSpaceIsEmpty = (colorSpace: VideoColorSpaceInit | undefined) => { + return ( + !colorSpace + || ( + colorSpace.primaries == null + && colorSpace.transfer == null + && colorSpace.matrix == null + && colorSpace.fullRange == null + ) + ); +}; + +export const EMPTY_COLOR_SPACE: VideoColorSpaceInit = { + primaries: undefined, + transfer: undefined, + matrix: undefined, + fullRange: undefined, +}; + export const isAllowSharedBufferSource = (x: unknown) => { return ( x instanceof ArrayBuffer diff --git a/test/browser/color-space.test.ts b/test/browser/color-space.test.ts new file mode 100644 index 0000000..880d4fd --- /dev/null +++ b/test/browser/color-space.test.ts @@ -0,0 +1,222 @@ +import { expect, test } from 'vitest'; +import { VideoCodec } from '../../src/codec.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { Input } from '../../src/input.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { EncodedVideoPacketSource } from '../../src/media-source.js'; +import { assert, colorSpaceIsComplete } from '../../src/misc.js'; +import { Output } from '../../src/output.js'; +import { MkvOutputFormat, Mp4OutputFormat } from '../../src/output-format.js'; +import { EncodedPacket } from '../../src/packet.js'; +import { BufferSource, Source, UrlSource } from '../../src/source.js'; +import { BufferTarget } from '../../src/target.js'; + +test('Color space extraction, AVC in MP4', async () => { + const source = await readPackets('/video.mp4'); + const buffer = await remuxWithoutColorSpace(new Mp4OutputFormat(), source); + + await expectColorSpace(new BufferSource(buffer), { + primaries: undefined, + transfer: undefined, + matrix: 'bt470bg', + fullRange: true, + }); +}); + +test('Color space extraction, HEVC in MP4', async () => { + const source = await readPackets('/video-h265.mp4'); + const buffer = await remuxWithoutColorSpace(new Mp4OutputFormat(), source); + + await expectColorSpace(new BufferSource(buffer), { + primaries: undefined, + transfer: undefined, + matrix: 'bt470bg', + fullRange: true, + }); +}); + +test('Color space extraction, VP9 in MP4', async () => { + const source = await encodePackets('vp9', 'vp09.00.10.08'); + const buffer = await remuxWithoutColorSpace(new Mp4OutputFormat(), source); + + await expectCompleteColorSpace(new BufferSource(buffer)); +}); + +test('Color space extraction, AV1 in MP4', async () => { + const source = await encodePackets('av1', 'av01.0.04M.08'); + const buffer = await remuxWithoutColorSpace(new Mp4OutputFormat(), source); + + await expectCompleteColorSpace(new BufferSource(buffer)); +}); + +test('Color space extraction, AVC in Matroska', async () => { + const source = await readPackets('/video.mp4'); + const buffer = await remuxWithoutColorSpace(new MkvOutputFormat(), source); + + await expectColorSpace(new BufferSource(buffer), { + primaries: undefined, + transfer: undefined, + matrix: 'bt470bg', + fullRange: true, + }); +}); + +test('Color space extraction, HEVC in Matroska', async () => { + const source = await readPackets('/video-h265.mp4'); + const buffer = await remuxWithoutColorSpace(new MkvOutputFormat(), source); + + await expectColorSpace(new BufferSource(buffer), { + // The SPS only signals the matrix and the range, leaving the rest unspecified + primaries: undefined, + transfer: undefined, + matrix: 'bt470bg', + fullRange: true, + }); +}); + +test('Color space extraction, VP9 in Matroska', async () => { + const source = await encodePackets('vp9', 'vp09.00.10.08'); + const buffer = await remuxWithoutColorSpace(new MkvOutputFormat(), source); + + await expectCompleteColorSpace(new BufferSource(buffer)); +}); + +test('Color space extraction, AV1 in Matroska', async () => { + const source = await encodePackets('av1', 'av01.0.04M.08'); + const buffer = await remuxWithoutColorSpace(new MkvOutputFormat(), source); + + await expectCompleteColorSpace(new BufferSource(buffer)); +}); + +const readPackets = async (path: string) => { + using input = new Input({ + source: new UrlSource(path), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryVideoTrack(); + assert(track); + + const codec = await track.getCodec(); + assert(codec); + + const decoderConfig = await track.getDecoderConfig(); + assert(decoderConfig); + + const sink = new EncodedPacketSink(track); + const packets: EncodedPacket[] = []; + + for await (const packet of sink.packets()) { + packets.push(packet); + + if (packets.length === 10) { + break; + } + } + + return { codec, decoderConfig, packets }; +}; + +const encodePackets = async (codec: VideoCodec, codecString: string) => { + const width = 320; + const height = 240; + + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext('2d')!; + + let decoderConfig: VideoDecoderConfig | null = null; + const packets: EncodedPacket[] = []; + + // We manually go through WebCodecs here so we can intercept the decoder config + const encoder = new VideoEncoder({ + output: (chunk, metadata) => { + decoderConfig ??= metadata?.decoderConfig ?? null; + packets.push(EncodedPacket.fromEncodedChunk(chunk)); + }, + error: (error) => { + throw error; + }, + }); + encoder.configure({ codec: codecString, width, height }); + + for (let i = 0; i < 10; i++) { + context.fillStyle = `hsl(${36 * i}, 100%, 50%)`; + context.fillRect(0, 0, width, height); + context.fillStyle = 'black'; + context.fillRect(20 * i, 10 * i, 80, 80); + + const frame = new VideoFrame(canvas, { timestamp: i * 1e6 / 30, duration: 1e6 / 30 }); + encoder.encode(frame, { keyFrame: i === 0 }); + frame.close(); + } + + await encoder.flush(); + encoder.close(); + + assert(decoderConfig); + + return { codec, decoderConfig, packets }; +}; + +const remuxWithoutColorSpace = async ( + format: Mp4OutputFormat | MkvOutputFormat, + source: { codec: VideoCodec; decoderConfig: VideoDecoderConfig; packets: EncodedPacket[] }, +) => { + const output = new Output({ + format, + target: new BufferTarget(), + }); + + const videoSource = new EncodedVideoPacketSource(source.codec); + output.addVideoTrack(videoSource); + + await output.start(); + + const decoderConfig = { ...source.decoderConfig }; + delete decoderConfig.colorSpace; + + let isFirstPacket = true; + for (const packet of source.packets) { + await videoSource.add(packet, isFirstPacket ? { decoderConfig } : undefined); + isFirstPacket = false; + } + + await output.finalize(); + + const buffer = output.target.buffer; + assert(buffer); + + return buffer; +}; + +const expectColorSpace = async (source: Source, expected: VideoColorSpaceInit) => { + using input = new Input({ + source, + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryVideoTrack(); + assert(track); + + const decoderConfig = await track.getDecoderConfig(); + assert(decoderConfig); + + expect(await track.getColorSpace()).toEqual(expected); + expect({ ...decoderConfig.colorSpace }).toEqual(expected); +}; + +const expectCompleteColorSpace = async (source: Source) => { + using input = new Input({ + source, + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryVideoTrack(); + assert(track); + + const decoderConfig = await track.getDecoderConfig(); + assert(decoderConfig); + + expect(colorSpaceIsComplete(await track.getColorSpace())).toBe(true); + expect(colorSpaceIsComplete(decoderConfig.colorSpace)).toBe(true); +};