From 434390916f0664bc860e58d3c4551b8ecbef6738 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 25 Jun 2026 14:54:40 -0700 Subject: [PATCH] fix(spf): retain the muxed audio codec on video tracks without an AUDIO group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an EXT-X-STREAM-INF lists an audio codec (e.g. CODECS="avc1,mp4a") but declares no AUDIO group, the audio is muxed into that rendition's segments. The parser was discarding the audio codec and keeping only the video codec, so the SourceBuffer mimetype omitted audio and the muxed segment failed to append (CHUNK_DEMUXER_ERROR_APPEND_FAILED: audio object type does not match the mimetype). Retain both codecs in that case so the mimetype matches the muxed media. The guard keys on an audio codec actually being present in CODECS, not on the mere absence of an AUDIO group — audioless video (CODECS="avc1") stays video-only, and demuxed sources (with an AUDIO group) are unchanged. Verified live: a muxed CMAF source (mediastreamsegmenter) now appends and plays in SPF where it previously errored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spf/src/media/hls/parse-multivariant.ts | 8 +++++- .../hls/tests/parse-multivariant.test.ts | 27 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) 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,