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);
+};