DEV Community

Mason K
Mason K

Posted on

Audit your HLS segments: a Python script that catches the 6-second-that-isn't

TL;DR

-hls_time 6 is a request, not a guarantee. We'll reproduce the bug on a test clip, fix it with three encoder flags, and write a ~75 line Python audit that checks a playlist against RFC 8216 and Apple's HLS authoring rules.

Most HLS pipelines set the segment length once and never look again. The playlist can disagree with your config and everything still plays, so nothing alerts. #video #ffmpeg

We'll need ffmpeg 6.x or newer, ffprobe, and python 3.10+. Everything runs locally.

1. 🎥 Make a test clip

# a 60 s, 720p, 24 fps synthetic clip with a sine tone
ffmpeg -y -f lavfi -i "testsrc2=size=1280x720:rate=24:duration=60" \
  -f lavfi -i "sine=frequency=440:duration=60" \
  -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest src.mp4
Enter fullscreen mode Exit fullscreen mode

2. 🐛 Reproduce the drift

Ask for six-second segments the way most tutorials do:

# bad.sh
mkdir -p bad
ffmpeg -y -i src.mp4 -c:v libx264 -preset veryfast -c:a aac \
  -f hls -hls_time 6 -hls_playlist_type vod \
  -hls_segment_filename 'bad/seg_%03d.ts' bad/index.m3u8
grep -E "TARGET|EXTINF" bad/index.m3u8
Enter fullscreen mode Exit fullscreen mode
#EXT-X-TARGETDURATION:10
#EXTINF:10.416667,
#EXTINF:10.416667,
#EXTINF:10.416667,
#EXTINF:10.416667,
#EXTINF:10.416667,
#EXTINF:7.916667,
Enter fullscreen mode Exit fullscreen mode

Ten-and-a-bit seconds, six segments, no error. The cause: segments can only start on a keyframe, and libx264's default keyframe interval is 250 frames. At 24 fps, 250 / 24 = 10.4167 s. The muxer waits for the next legal cut point.

💡 Tip: this is not an FFmpeg bug. It is what the muxer is supposed to do. The fix belongs in the encoder.

3. 🔧 Fix it with a GOP that divides the segment

Make the keyframe interval equal to the segment length in frames (6 s x 24 fps = 144), and stop scene-cut detection from inserting extra keyframes:

# good.sh
mkdir -p good
ffmpeg -y -i src.mp4 -c:v libx264 -preset veryfast \
  -g 144 -keyint_min 144 -sc_threshold 0 -c:a aac \
  -f hls -hls_time 6 -hls_playlist_type vod \
  -hls_segment_filename 'good/seg_%03d.ts' good/index.m3u8
Enter fullscreen mode Exit fullscreen mode

Ten segments, #EXT-X-TARGETDURATION:6. If your frame rate is 29.97, use 180 frames for 6.006 s segments, which is exactly what Apple's spec means by "nominally 6 seconds (for example, NTSC 29.97 may be 6.006 seconds)".

4. 📏 The rules we'll check

All from the specs, not from folklore:

Rule Source
EXTINF, rounded to the nearest integer, must be <= target duration RFC 8216 4.3.3.1
Segments must not exceed the target duration by more than 0.5 s Apple authoring spec 7.7
Video segments must start with an IDR frame Apple authoring spec 7.4
EXTINF sums must match real content duration within one frame Apple authoring spec 8.1
Target duration should be 6 s Apple authoring spec 7.5

Our script will also take an expected target, because the check that finds the bug above is "did the manifest match what I asked for".

5. 🛠️ The audit script

#!/usr/bin/env python3
# hls_audit.py - check a media playlist against the segment rules in RFC 8216 and Apple's authoring spec
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

APPLE_SLACK = 0.5  # Apple authoring spec 7.7: segments must not exceed target by more than 0.5 s


def parse_media_playlist(path: Path):
    target = None
    segs = []  # (duration, uri)
    pending = None
    for line in path.read_text().splitlines():
        line = line.strip()
        if line.startswith("#EXT-X-TARGETDURATION:"):
            target = int(line.split(":", 1)[1])
        elif line.startswith("#EXTINF:"):
            pending = float(line.split(":", 1)[1].split(",", 1)[0])
        elif line and not line.startswith("#") and pending is not None:
            segs.append((pending, line))
            pending = None
    return target, segs


def first_packet_is_keyframe(seg: Path) -> bool:
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", "%+#1",
         "-show_entries", "packet=flags", "-of", "csv=p=0", str(seg)],
        capture_output=True, text=True).stdout.strip()
    return out.startswith("K")


def actual_duration(seg: Path) -> float:
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(seg)],
        capture_output=True, text=True).stdout.strip()
    return float(out)


def main(playlist: str, expect: int | None) -> int:
    pl = Path(playlist)
    target, segs = parse_media_playlist(pl)
    problems = []
    if target is None:
        print("FAIL no EXT-X-TARGETDURATION")
        return 1
    print(f"target duration: {target}s, segments: {len(segs)}")
    if expect is not None and target != expect:
        problems.append(f"target duration is {target}s but you asked for {expect}s (the encoder GOP is probably longer than the segment you requested)")
    for i, (dur, uri) in enumerate(segs):
        seg = pl.parent / uri
        if round(dur) > target:
            problems.append(f"{uri}: EXTINF {dur:.3f} rounds to {round(dur)}, above target {target} (RFC 8216)")
        if dur > target + APPLE_SLACK:
            problems.append(f"{uri}: EXTINF {dur:.3f} exceeds target by more than {APPLE_SLACK}s (Apple 7.7)")
        if seg.exists():
            if not first_packet_is_keyframe(seg):
                problems.append(f"{uri}: first video packet is not a keyframe (Apple 7.4)")
            real = actual_duration(seg)
            if abs(real - dur) > 0.1:
                problems.append(f"{uri}: playlist says {dur:.3f}s, file is {real:.3f}s")
    durs = [d for d, _ in segs[:-1]]  # the last segment is allowed to be short
    if durs:
        print(f"min/max/mean (excluding last): {min(durs):.3f} / {max(durs):.3f} / {sum(durs)/len(durs):.3f}")
    for p in problems:
        print("FAIL", p)
    if not problems:
        print("OK")
    return 1 if problems else 0


if __name__ == "__main__":
    expect = int(sys.argv[2]) if len(sys.argv) > 2 else None
    sys.exit(main(sys.argv[1], expect))
Enter fullscreen mode Exit fullscreen mode

Two helpers shell out to ffprobe: one reads the first packet of each segment and checks the keyframe flag (K), the other reads the real container duration so we can catch an EXTINF that lies.

6. ▶️ Run it

python3 hls_audit.py bad/index.m3u8 6; echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode
target duration: 10s, segments: 6
min/max/mean (excluding last): 10.417 / 10.417 / 10.417
FAIL target duration is 10s but you asked for 6s (the encoder GOP is probably longer than the segment you requested)
exit=1
Enter fullscreen mode Exit fullscreen mode
python3 hls_audit.py good/index.m3u8 6; echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode
target duration: 6s, segments: 10
min/max/mean (excluding last): 6.000 / 6.000 / 6.000
OK
exit=0
Enter fullscreen mode Exit fullscreen mode

The same clip with -g 48 -keyint_min 48 -sc_threshold 0 -hls_time 2 gives 30 segments and passes with hls_audit.py two/index.m3u8 2. Same duration of video, three times the requests. That is the trade in one line.

⚠️ Note: we only checked the first packet of each segment and the container duration. A fuller audit would also confirm that every rendition cuts at the same timestamps (Apple 8.22). Run the script on each rung and diff the EXTINF lists.

7. 🚦 Put it in CI

The exit code is the whole integration:

# .github/workflows/hls-audit.yml (excerpt)
- name: Package fixture
  run: ./scripts/package_fixture.sh
- name: Audit playlists
  run: |
    for p in out/*/index.m3u8; do python3 scripts/hls_audit.py "$p" 6; done
Enter fullscreen mode Exit fullscreen mode

Also run it against a handful of live playlists in production once a week. Segment drift usually arrives with an encoder upgrade or a new preset, not with a code change you reviewed.

What segment length costs

Choice You gain You pay
Shorter (2 s) Faster first frame and quality switches, lower live latency floor More requests, more playlist reloads, more keyframes
Six seconds Fewer requests, easier on encoder efficiency, matches Apple's recommendation Slower to react, higher live latency floor

We did not measure the keyframe overhead here. It depends on content and encoder, so measure it on your own catalogue before choosing.

Wrapping up

  • Your segment length is set by your keyframe interval, not by -hls_time.
  • Check target duration against what you intended; TARGETDURATION rising silently is the symptom.
  • Next: run the audit per rendition and check boundary alignment, then look at low-latency HLS if you need short latency without a full request penalty. The RFC 8216 sections linked in the table are short and worth reading once.

Do you have a pipeline where the manifest and the config disagree? Tell me in the comments.

Top comments (0)