HEVC-in-TS without AUDs: hvcC is written with an empty PPS array, so nothing decodes (silent, predates 1.7.0)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 16.9k
- Forks
- 2.8k
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 27
Description
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
parsePEScall and every NAL therefore arrives atnalIndex === 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→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 sample
that 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.- It never recovers:
case 32only re-armsinitVPSwhen!track.vps, andtrack.vpsis
already set, soinitVPSstaysnullfor 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'sTransmuxer. - Read the
hvcCbox 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
- Searched existing issues — nothing matches. #1239 ("stream without AUD NAL unit does not
play") is the AVC-era ancestor of the shortcut at fault, and #7854 introduced the guard that
is mis-ordered here, but neither covers this. - Reproduced against the latest release (v1.7.1), and confirmed
mastercarries identical
source for the file in question. - Not verified on the hosted demo pages. That requires publicly hosting a sample stream and
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. - No CORS or network involvement — reproduced from local files with no HTTP at all.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/demux/video/hevc-video-parser.ts at startsNewAccessUnit and trace the no-AUD, one-NAL-per-PES path. Use the synthetic HEVC-to-MPEG-TS reproduction described in the issue, then inspect the generated hvcC and decode it with ffprobe. Done means the PPS array contains the parameter set and frames decode without errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100