diff --git a/packages/spf/src/media/hls/parse-multivariant.ts b/packages/spf/src/media/hls/parse-multivariant.ts index da7d97de..1ee3811c 100644 --- a/packages/spf/src/media/hls/parse-multivariant.ts +++ b/packages/spf/src/media/hls/parse-multivariant.ts @@ -222,7 +222,13 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO track.height = stream.resolution.height; } if (codecs?.video) { - track.codecs = [codecs.video]; + // When the STREAM-INF lists an audio codec but declares no AUDIO group, + // the audio is muxed into this rendition's segments — keep both codecs so + // the SourceBuffer mimetype matches the muxed media (otherwise the muxed + // audio fails to append: "audio object type does not match the mimetype"). + // With an AUDIO group the audio is a separate rendition, so only the video + // codec belongs here. + track.codecs = codecs.audio && !stream.audioGroupId ? [codecs.video, codecs.audio] : [codecs.video]; } if (stream.frameRate) { track.frameRate = stream.frameRate; diff --git a/packages/spf/src/media/hls/tests/parse-multivariant.test.ts b/packages/spf/src/media/hls/tests/parse-multivariant.test.ts index 1fb8529d..2bdab84a 100644 --- a/packages/spf/src/media/hls/tests/parse-multivariant.test.ts +++ b/packages/spf/src/media/hls/tests/parse-multivariant.test.ts @@ -31,7 +31,9 @@ video-1080p.m3u8`; const result = parseMultivariantPlaylist(text, { url: baseUrl }); - // Should have video selection set + // These STREAM-INFs list an audio codec (mp4a) with no AUDIO group, so the + // audio is muxed into each rendition — both codecs are retained on the track + // (the SourceBuffer mimetype must cover the muxed media). const videoSet = result.selectionSets.find((s) => s.type === 'video'); expect(videoSet).toBeDefined(); expect(videoSet?.switchingSets).toHaveLength(1); @@ -45,7 +47,7 @@ video-1080p.m3u8`; bandwidth: 800000, width: 640, height: 360, - codecs: ['avc1.4d401e'], + codecs: ['avc1.4d401e', 'mp4a.40.2'], }); expect(typeof videoTracks?.[0]?.id).toBe('string'); @@ -55,7 +57,7 @@ video-1080p.m3u8`; bandwidth: 1400000, width: 1280, height: 720, - codecs: ['avc1.4d401f'], + codecs: ['avc1.4d401f', 'mp4a.40.2'], mimeType: 'video/mp4', }); expect(typeof videoTracks?.[1]?.id).toBe('string'); @@ -68,7 +70,7 @@ video-1080p.m3u8`; bandwidth: 2800000, width: 1920, height: 1080, - codecs: ['avc1.640028'], + codecs: ['avc1.640028', 'mp4a.40.2'], mimeType: 'video/mp4', }); expect(typeof track1080p.id).toBe('string'); @@ -77,6 +79,23 @@ video-1080p.m3u8`; expect(track1080p.audioGroupIds).toBeUndefined(); }); + it('keeps an audioless video rendition video-only (no audio codec in CODECS, no AUDIO group)', () => { + // The inverse of the muxed case: no audio codec listed and no AUDIO group + // means the rendition is genuinely video-only — the muxed-codec retention + // must key on an audio codec actually being present, not fabricate one from + // the mere absence of an AUDIO group. + const text = `#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720,CODECS="avc1.640020" +video-720p.m3u8`; + + const result = parseMultivariantPlaylist(text, { url: baseUrl }); + const videoTracks = result.selectionSets.find((s) => s.type === 'video')?.switchingSets[0]?.tracks; + + expect(videoTracks).toHaveLength(1); + expect(videoTracks?.[0]?.codecs).toEqual(['avc1.640020']); + }); + it('de-duplicates the EXT-X-STREAM-INF cross-product: one track per video URI, accumulating audio groups', () => { // The same two video renditions are each listed twice — once paired with a // 5.1 group (higher BANDWIDTH, ac-3) and once with a stereo group (lower,