What version of HLS.js are you using?
v1.7.1 (latest release). Also reproduced on v1.6.19, and master carries the same code:
src/demux/video/hevc-video-parser.ts on master is byte-identical to the v1.7.1 tag.
What browser (including version) are you using?
Browser-independent. The defect is in the MPEG-TS to fMP4 transmuxer, which produces a
malformed hvcC before any media is appended, so every MSE browser is affected identically.
Reproduced headlessly against the published bundle (dist/hls.mjs) on Node v24.15.0.
What OS (including version) are you using?
macOS 15 (Darwin 25.5.0).
Configuration
Default configuration. Nothing in the config affects this path.
Summary
For an HEVC MPEG-TS stream authored without access-unit delimiters, hls.js writes an hvcC
box whose PPS array is present but empty (numOfArrays = 3, type 34 (PPS) count = 0).
Every slice then references a picture parameter set the decoder does not have.
The failure is completely silent. The decoder initialises, MEDIA_ATTACHED and FRAG_BUFFERED
fire normally, duration and the seekable range are correct, seeking works, and not a single
frame is ever produced. There is no hlsError, no bufferAppendError, no
CHUNK_DEMUXER_ERROR. The result is indistinguishable from a camera that recorded black.
This is not a v1.7.0 regression. v1.6.19 produces the same empty PPS array, so every
HEVC-in-TS stream authored this way has been silently unplayable for as long as the feature has
existed. That matters for triage: the shape of the bug — plausible player state, no error, no
frames — means affected users are unlikely to have reported it as a hls.js problem at all.
Conditions
Two properties of the stream, both common in hardware and CCTV encoders:
- No AUD (NAL type 35) anywhere in the stream.
- One PES packet per NAL unit, so the demuxer hands the parser a single NAL per
parsePES call and every NAL therefore arrives at nalIndex === 0.
Parameter-set repetition before the IDR is not required — one VPS SPS PPS group is enough.
Note that ffmpeg's mpegts muxer inserts an AUD before every access unit unconditionally, so a
stream produced with ffmpeg -f mpegts cannot exhibit this. That is very likely why it has gone
unnoticed: the most common authoring tool masks it.
Mechanism
src/demux/video/hevc-video-parser.ts, startsNewAccessUnit:
private startsNewAccessUnit(VideoSample, track, unit, nalIndex): boolean {
if (!VideoSample) {
return false;
}
// if new NAL units found and last sample still there, let's push ...
// this helps parsing streams with missing AUD (only do this if AUD never found)
if (!track.audFound && nalIndex === 0) { // (a) fires on EVERY NAL
return true;
}
// A sample can be opened by AU prefix NALs such as AUD/VPS/SPS/PPS/SEI
// before any VCL slice is appended. Keep those prefix NALs in the same
// pending sample; only split once the current sample already contains a
// picture and the next NAL indicates another access unit.
if (!VideoSample.frame) { // (b) unreachable when (a) fires
return false;
}
...
Guard (b) was added in #7854 to stop prefix NALs opening a spurious sample, and its comment
states exactly that intent. But it sits below the AUD-less shortcut (a). With no AUD and
one NAL per PES, nalIndex is always 0, so (a) returns true for every NAL and (b)
never runs. The guard is dead code precisely for the streams it was written to protect.
The consequence is not merely a spurious sample — it destroys the parameter sets:
VPS → initVPS = <VPS>, track.vps = [<VPS>].
SPS → VideoSample is still null, so no split. pushParameterSet admits it
(vps[0] === this.initVPS). A VideoSample is created.
PPS → VideoSample is now non-null, so (a) fires and pushAccessUnit() runs on a sample
that holds no picture. HevcVideoParser.pushAccessUnit nulls initVPS
("null initVPS to prevent possible track's sps/pps growth until next VPS").
pushParameterSet(track.pps, ...) then tests vps[0] === this.initVPS, which is now
<VPS> === null → false. The PPS is silently dropped.
- It never recovers:
case 32 only re-arms initVPS when !track.vps, and track.vps is
already set, so initVPS stays null for the rest of the segment.
track.pps stays [] and mp4-generator.ts faithfully writes count = 0.
Instrumented trace of startsNewAccessUnit over a real segment (VPS SPS PPS four times, then
the IDR):
nalIndex= 0 type=32 VPS sample=null initVPS=null split=false
nalIndex= 0 type=33 SPS sample=null initVPS=set split=false
nalIndex= 0 type=34 PPS sample=no-picture initVPS=set split=true <-- initVPS nulled here
nalIndex= 0 type=32 VPS sample=no-picture initVPS=null split=true
nalIndex= 0 type=33 SPS sample=no-picture initVPS=null split=true
nalIndex= 0 type=34 PPS sample=no-picture initVPS=null split=true
...
nalIndex= 0 type=19 IDR_W_RADL sample=no-picture initVPS=null split=true
=> hvcC: VPS=1 SPS=1 PPS=0
The fix
Move guard (b) above guard (a). Nothing else:
if (!VideoSample) {
return false;
}
- // if new NAL units found and last sample still there, let's push ...
- // this helps parsing streams with missing AUD (only do this if AUD never found)
- if (!track.audFound && nalIndex === 0) {
- return true;
- }
-
// A sample can be opened by AU prefix NALs such as AUD/VPS/SPS/PPS/SEI
// before any VCL slice is appended. Keep those prefix NALs in the same
// pending sample; only split once the current sample already contains a
// picture and the next NAL indicates another access unit.
if (!VideoSample.frame) {
return false;
}
+
+ // if new NAL units found and last sample still there, let's push ...
+ // this helps parsing streams with missing AUD (only do this if AUD never found)
+ if (!track.audFound && nalIndex === 0) {
+ return true;
+ }
This keeps the AUD-less split for its actual purpose — closing a sample that already holds a
picture — while letting prefix NALs accumulate into the pending sample as #7854 intended.
Results
Same segment, same code path, transmuxed through the published dist/hls.mjs before and after the
reorder:
Before (v1.7.1 as published)
codec=hvc1.1.6.L120.B0 samples=50 initSegment=789 B
hvcC type 32 (VPS) count=1 sizes=[24]
hvcC type 33 (SPS) count=1 sizes=[42]
hvcC type 34 (PPS) count=0 sizes=[]
After (guards reordered)
codec=hvc1.1.6.L120.B0 samples=50 initSegment=798 B
hvcC type 32 (VPS) count=1 sizes=[24]
hvcC type 33 (SPS) count=1 sizes=[42]
hvcC type 34 (PPS) count=1 sizes=[7]
Feeding both outputs to ffmpeg:
|
ffmpeg -v error -f null - |
ffprobe -count_frames |
| before |
50 x PPS id out of range: 0 |
nb_read_frames=N/A (nothing decoded) |
| after |
no output |
nb_read_frames=50 |
The patched output is bit-exact against the source elementary stream:
PSNR y:inf u:inf v:inf average:inf.
Reproduction — no sample stream needed
I cannot publish the stream that surfaced this, but it is fully reproducible from synthetic media.
For reference, that stream is HEVC Main, level 4.0, 1920x1080, yuv420p, 25 fps, in MPEG-TS
(stream_type 0x24), one PES per NAL, parameter sets repeated before each IDR, and no AUD.
1. Encode an AUD-less HEVC elementary stream (x265 emits no AUD of its own):
ffmpeg -f lavfi -i testsrc2=size=320x240:rate=25 -t 1 \
-c:v libx265 -preset ultrafast \
-x265-params "repeat-headers=1:keyint=25:log-level=none" \
-f hevc synth.h265
2. Mux it to TS with one PES per NAL. ffmpeg's mpegts muxer cannot be used here because it
inserts AUDs, so this uses a minimal muxer:
mux.mjs — minimal Annex-B HEVC to MPEG-TS muxer
// Minimal MPEG-TS muxer for an Annex-B HEVC elementary stream.
// Exists because ffmpeg's mpegts muxer inserts an AUD before every access
// unit, so it cannot produce the AUD-less authoring this reproduces.
// usage: node mux.mjs in.h265 out.ts
import fs from 'node:fs';
const VIDEO_PID = 0x100, PMT_PID = 0x1000, STREAM_TYPE_HEVC = 0x24, PACKET = 188;
function crc32(buf) {
let crc = 0xffffffff;
for (const byte of buf) {
crc ^= byte << 24;
for (let i = 0; i < 8; i++) crc = (crc & 0x80000000) ? ((crc << 1) ^ 0x04c11db7) : (crc << 1);
crc >>>= 0;
}
return crc >>> 0;
}
function section(tableId, pid, body) {
const len = body.length + 4 + 5; // body + CRC + section header after length
const head = Buffer.from([tableId, 0xb0 | ((len >> 8) & 0x0f), len & 0xff, 0, 1, 0xc1, 0, 0]);
const withCrc = Buffer.concat([head, body]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(withCrc));
const payload = Buffer.concat([Buffer.from([0]), withCrc, crc]); // pointer_field
const pkt = Buffer.alloc(PACKET, 0xff);
pkt[0] = 0x47; pkt[1] = 0x40 | ((pid >> 8) & 0x1f); pkt[2] = pid & 0xff; pkt[3] = 0x10;
payload.copy(pkt, 4);
return pkt;
}
const pat = section(0x00, 0, Buffer.from([0x00, 0x01, 0xe0 | (PMT_PID >> 8), PMT_PID & 0xff]));
const pmt = section(0x02, PMT_PID, Buffer.from([
0xe0 | (VIDEO_PID >> 8), VIDEO_PID & 0xff, 0xf0, 0x00, // PCR PID, program_info_length
STREAM_TYPE_HEVC, 0xe0 | (VIDEO_PID >> 8), VIDEO_PID & 0xff, 0xf0, 0x00
]));
function pesHeader(pts, dts, payloadLength) {
const stamp = (value, prefix) => Buffer.from([
(prefix << 4) | (((value / 1073741824) & 7) << 1) | 1,
(value / 4194304) & 0xff,
(((value / 16384) & 0x7f) << 1) | 1,
(value / 128) & 0xff,
((value & 0x7f) << 1) | 1
]);
const stamps = Buffer.concat([stamp(pts, 3), stamp(dts, 1)]);
const length = payloadLength + 3 + stamps.length;
return Buffer.concat([
Buffer.from([0, 0, 1, 0xe0, (length >> 8) & 0xff, length & 0xff, 0x80, 0xc0, stamps.length]),
stamps
]);
}
function packetise(payload, pcr) {
const out = [];
let offset = 0, first = true;
let counter = packetise.counter ?? 0;
while (offset < payload.length) {
const pkt = Buffer.alloc(PACKET, 0xff);
pkt[0] = 0x47;
pkt[1] = (first ? 0x40 : 0x00) | ((VIDEO_PID >> 8) & 0x1f);
pkt[2] = VIDEO_PID & 0xff;
let body = 4;
const wantPcr = first && pcr !== undefined;
const remaining = payload.length - offset;
const needStuffing = remaining < PACKET - 4 - (wantPcr ? 8 : 0);
if (wantPcr || needStuffing) {
const afLength = wantPcr && !needStuffing ? 7 : PACKET - 5 - Math.min(remaining, PACKET - 4 - (wantPcr ? 8 : 0));
pkt[3] = 0x30 | (counter & 0x0f);
pkt[4] = afLength;
pkt[5] = wantPcr ? 0x10 : 0x00;
if (wantPcr) {
const base = Math.floor(pcr / 300);
pkt[6] = (base / 33554432) & 0xff; pkt[7] = (base / 131072) & 0xff;
pkt[8] = (base / 512) & 0xff; pkt[9] = ((base & 0x1ff) >> 1) & 0xff;
pkt[10] = ((base & 1) << 7) | 0x7e; pkt[11] = 0;
}
body = 5 + afLength;
} else {
pkt[3] = 0x10 | (counter & 0x0f);
}
const take = Math.min(PACKET - body, payload.length - offset);
payload.copy(pkt, body, offset, offset + take);
offset += take; first = false; counter = (counter + 1) & 0x0f;
out.push(pkt);
}
packetise.counter = counter;
return out;
}
const es = fs.readFileSync(process.argv[2]);
// Split into NAL units on Annex-B start codes.
const starts = [];
for (let i = 0; i + 3 < es.length; i++) {
if (es[i] === 0 && es[i + 1] === 0 && es[i + 2] === 1) starts.push(i);
}
const nals = starts.map((s, i) => {
const from = s >= 1 && es[s - 1] === 0 ? s - 1 : s;
return es.subarray(from, i + 1 < starts.length ? (starts[i + 1] >= 1 && es[starts[i + 1] - 1] === 0 ? starts[i + 1] - 1 : starts[i + 1]) : es.length);
});
// A new access unit starts at the first slice of a picture, and at any
// parameter-set or prefix NAL that follows a completed picture.
function nalType(nal) {
const offset = nal[2] === 1 ? 3 : 4;
return (nal[offset] >> 1) & 0x3f;
}
function firstSlice(nal) {
const offset = nal[2] === 1 ? 3 : 4;
return (nal[offset + 2] & 0x80) !== 0;
}
const units = [];
let current = [];
let seenVcl = false;
for (const nal of nals) {
const type = nalType(nal);
const isVcl = type <= 31;
const isPrefix = (type >= 32 && type <= 35) || type === 39;
const starts = seenVcl && ((isVcl && firstSlice(nal)) || isPrefix);
if (starts) { units.push(current); current = []; seenVcl = false; }
current.push(nal);
if (isVcl) seenVcl = true;
}
if (current.length) units.push(current);
const TICKS = 90000 / 25;
const out = [];
// One PES packet per NAL unit, which is what the encoders this reproduces
// emit. It is the second condition the defect needs: the demuxer then hands
// the parser one NAL per call, so every NAL is at index 0.
units.forEach((unit, index) => {
out.push(pat, pmt);
const pts = 90000 + index * TICKS;
for (const nal of unit) {
out.push(...packetise(Buffer.concat([pesHeader(pts, pts, nal.length), nal]), pts * 300));
}
});
fs.writeFileSync(process.argv[3], Buffer.concat(out));
console.log(`wrote ${process.argv[3]}: ${units.length} access units, ${Buffer.concat(out).length} bytes`);
node mux.mjs synth.h265 synth.ts
3. Transmux with hls.js and read the hvcC. Against v1.7.1 the PPS array is empty; with the
two guards reordered it holds one 6-byte PPS, and the output decodes to all 25 frames:
hls.js 1.6.19 synthesised nb=25 hvcC: VPS=1 SPS=1 PPS=0
hls.js 1.7.1 synthesised nb=25 hvcC: VPS=1 SPS=1 PPS=0
1.7.1 + reorder nb=25 hvcC: VPS=1 SPS=1 PPS=1
Steps to reproduce
- Produce an AUD-less HEVC transport stream with one PES per NAL (steps 1 and 2 above).
- Serve it as a single-segment HLS playlist and play it with hls.js, or transmux it directly with
the library's Transmuxer.
- Read the
hvcC box of the emitted init segment, or simply watch the video.
Expected behaviour
hvcC carries the PPS referenced by the stream's slices, and the segment decodes.
What actually happened
hvcC carries an empty PPS array. The decoder initialises, playback appears to run with a correct
timeline and duration, no error is raised, and no frame is ever decoded.
Checklist
What version of HLS.js are you using?
v1.7.1 (latest release). Also reproduced on v1.6.19, and
mastercarries the same code:src/demux/video/hevc-video-parser.tsonmasteris byte-identical to thev1.7.1tag.What browser (including version) are you using?
Browser-independent. The defect is in the MPEG-TS to fMP4 transmuxer, which produces a
malformed
hvcCbefore any media is appended, so every MSE browser is affected identically.Reproduced headlessly against the published bundle (
dist/hls.mjs) on Node v24.15.0.What OS (including version) are you using?
macOS 15 (Darwin 25.5.0).
Configuration
Default configuration. Nothing in the config affects this path.
Summary
For an HEVC MPEG-TS stream authored without access-unit delimiters, hls.js writes an
hvcCbox whose PPS array is present but empty (
numOfArrays = 3,type 34 (PPS) count = 0).Every slice then references a picture parameter set the decoder does not have.
The failure is completely silent. The decoder initialises,
MEDIA_ATTACHEDandFRAG_BUFFEREDfire normally, duration and the seekable range are correct, seeking works, and not a single
frame is ever produced. There is no
hlsError, nobufferAppendError, noCHUNK_DEMUXER_ERROR. The result is indistinguishable from a camera that recorded black.This is not a v1.7.0 regression. v1.6.19 produces the same empty PPS array, so every
HEVC-in-TS stream authored this way has been silently unplayable for as long as the feature has
existed. That matters for triage: the shape of the bug — plausible player state, no error, no
frames — means affected users are unlikely to have reported it as a hls.js problem at all.
Conditions
Two properties of the stream, both common in hardware and CCTV encoders:
parsePEScall and every NAL therefore arrives atnalIndex === 0.Parameter-set repetition before the IDR is not required — one
VPS SPS PPSgroup is enough.Note that ffmpeg's mpegts muxer inserts an AUD before every access unit unconditionally, so a
stream produced with
ffmpeg -f mpegtscannot exhibit this. That is very likely why it has goneunnoticed: the most common authoring tool masks it.
Mechanism
src/demux/video/hevc-video-parser.ts,startsNewAccessUnit:Guard (b) was added in #7854 to stop prefix NALs opening a spurious sample, and its comment
states exactly that intent. But it sits below the AUD-less shortcut (a). With no AUD and
one NAL per PES,
nalIndexis always0, so (a) returnstruefor every NAL and (b)never runs. The guard is dead code precisely for the streams it was written to protect.
The consequence is not merely a spurious sample — it destroys the parameter sets:
VPS→initVPS = <VPS>,track.vps = [<VPS>].SPS→VideoSampleis stillnull, so no split.pushParameterSetadmits it(
vps[0] === this.initVPS). AVideoSampleis created.PPS→VideoSampleis now non-null, so (a) fires andpushAccessUnit()runs on a samplethat holds no picture.
HevcVideoParser.pushAccessUnitnullsinitVPS("null initVPS to prevent possible track's sps/pps growth until next VPS").
pushParameterSet(track.pps, ...)then testsvps[0] === this.initVPS, which is now<VPS> === null→ false. The PPS is silently dropped.case 32only re-armsinitVPSwhen!track.vps, andtrack.vpsisalready set, so
initVPSstaysnullfor the rest of the segment.track.ppsstays[]andmp4-generator.tsfaithfully writescount = 0.Instrumented trace of
startsNewAccessUnitover a real segment (VPS SPS PPSfour times, thenthe IDR):
The fix
Move guard (b) above guard (a). Nothing else:
if (!VideoSample) { return false; } - // if new NAL units found and last sample still there, let's push ... - // this helps parsing streams with missing AUD (only do this if AUD never found) - if (!track.audFound && nalIndex === 0) { - return true; - } - // A sample can be opened by AU prefix NALs such as AUD/VPS/SPS/PPS/SEI // before any VCL slice is appended. Keep those prefix NALs in the same // pending sample; only split once the current sample already contains a // picture and the next NAL indicates another access unit. if (!VideoSample.frame) { return false; } + + // if new NAL units found and last sample still there, let's push ... + // this helps parsing streams with missing AUD (only do this if AUD never found) + if (!track.audFound && nalIndex === 0) { + return true; + }This keeps the AUD-less split for its actual purpose — closing a sample that already holds a
picture — while letting prefix NALs accumulate into the pending sample as #7854 intended.
Results
Same segment, same code path, transmuxed through the published
dist/hls.mjsbefore and after thereorder:
Before (v1.7.1 as published)
After (guards reordered)
Feeding both outputs to ffmpeg:
ffmpeg -v error -f null -ffprobe -count_framesPPS id out of range: 0nb_read_frames=N/A(nothing decoded)nb_read_frames=50The patched output is bit-exact against the source elementary stream:
PSNR y:inf u:inf v:inf average:inf.Reproduction — no sample stream needed
I cannot publish the stream that surfaced this, but it is fully reproducible from synthetic media.
For reference, that stream is HEVC Main, level 4.0, 1920x1080,
yuv420p, 25 fps, in MPEG-TS(
stream_type 0x24), one PES per NAL, parameter sets repeated before each IDR, and no AUD.1. Encode an AUD-less HEVC elementary stream (x265 emits no AUD of its own):
ffmpeg -f lavfi -i testsrc2=size=320x240:rate=25 -t 1 \ -c:v libx265 -preset ultrafast \ -x265-params "repeat-headers=1:keyint=25:log-level=none" \ -f hevc synth.h2652. Mux it to TS with one PES per NAL. ffmpeg's mpegts muxer cannot be used here because it
inserts AUDs, so this uses a minimal muxer:
mux.mjs— minimal Annex-B HEVC to MPEG-TS muxer3. Transmux with hls.js and read the
hvcC. Against v1.7.1 the PPS array is empty; with thetwo guards reordered it holds one 6-byte PPS, and the output decodes to all 25 frames:
Steps to reproduce
the library's
Transmuxer.hvcCbox of the emitted init segment, or simply watch the video.Expected behaviour
hvcCcarries the PPS referenced by the stream's slices, and the segment decodes.What actually happened
hvcCcarries an empty PPS array. The decoder initialises, playback appears to run with a correcttimeline and duration, no error is raised, and no frame is ever decoded.
Checklist
play") is the AVC-era ancestor of the shortcut at fault, and Fix HEVC access-unit video sample boundaries in Safari (fixed #7853) #7854 introduced the guard that
is mis-ordered here, but neither covers this.
mastercarries identicalsource for the file in question.
the stream I have is not mine to publish. The synthetic reproduction above stands in; the
defect is in the transmuxer and is reached well before anything browser-specific.