Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tools/devlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ needs to support:
| HLS-fMP4 live | HTTP `.m3u8` + `.m4s` | `/http` | `h264-aac`, `hevc-aac` |
| HLS alternate audio | HTTP master + split A/V playlists | `/http` | AAC / MP2 / AC-3 (TS), AAC (fMP4) |
| HLS internal multi-audio | HTTP `.m3u8` + multi-PID `.ts` | `/http` | H.264 + AAC / MP2 / AC-3 |
| HLS timestamp continuity | HTTP VOD with crafted `.ts` PTS | `/http` | H.264 + MP2 |
| HLS live packet loss | HTTP `.m3u8` + damaged `.ts` | `/http` | H.264 + MP2 |
| HTTP internal multi-audio | continuous multi-PID MPEG-TS | `/http` | H.264 + AAC / MP2 / AC-3 |
| HLS catchup | HTTP HLS VOD (`playseek`) | `/http` | all of the above |
| mpegts (RTSP) | RTSP TS live + catchup | `/rtsp` | `h264-mp2`, `hevc-aac` |
| mpegts continuity faults | RTSP catchup + RTP packet loss | `/rtsp` | H.264 + MP2 |
| mpegts (multicast) | RTP multicast live | `/rtp` | `h264-mp2`, `hevc-ac3`, `hevc-eac3` |
| mpegts (scan) | RTP multicast live | `/rtp` | 1080i / 1080p / 2160p (see below) |
| external file | RTP multicast (looped) | `/rtp` | whatever the `.ts` file contains |
Expand All @@ -38,6 +41,24 @@ tones as separate PIDs in one MPEG-TS program. One is segmented as HLS-TS and
the other is a continuous HTTP TS response, covering both inputs accepted by
the custom demux/transmux pipeline.

The **HLS-TS continuity overlap gap restart** VOD has four six-second TS
segments with deterministic timestamp starts: 0s, 5s, 13s, then 0s. This
creates a one-second overlap, a two-second gap, and finally a greater-than-ten
second timestamp restart. An `EXT-X-DISCONTINUITY` is also placed before the
gap segment to prove that HLS-TS follows the timestamp policy instead of
resetting its output timeline from playlist metadata.

The two live packet-loss channels are deterministic fault injectors. HLS drops
40 complete TS packets from every third segment; RTSP skips six consecutive RTP
packets roughly every five seconds. The player should recover at the next valid
sample/keyframe and keep its output timeline continuous.

The **mpegts catchup gap** channel terminates every requested RTSP catchup
window and advances the source timestamp offset by an extra two seconds for
each subsequent request. Together with the normal RTSP catchup channel's
one-second request overlap, these cover both overlap and gap handling across
plain MPEG-TS URL boundaries.

**HLS catchup** is a real HLS VOD: each `playseek` window returns an
`index.m3u8` (`#EXT-X-PLAYLIST-TYPE:VOD`) listing fixed-duration `.ts` slices.
The playlist is produced instantly; each slice is encoded lazily on first
Expand Down Expand Up @@ -105,6 +126,21 @@ uv run tools/devlab/devlab.py
# http://127.0.0.1:5140/player
```

## Browser autoplay and audio prompts

Browsers may show a **Click to Play** prompt because unmuted audio playback
requires a trusted user gesture. Browser automation must handle this prompt
according to what the scenario is testing:

- If the test does not depend on audible output, mute the player and then
reload the page. The reload is required so playback starts in the muted
state and the automation can continue without the prompt.
- If the test validates audio or audible track switching, stop the automation,
show the player to the user, and ask the user to click **Click to Play**.
Resume only after the user confirms the interaction. Do not attempt to clear
the prompt with a programmatic click: it does not provide the trusted user
gesture required to enable audio playback.

Requires `ffmpeg` on PATH with `drawtext`, `libx264`, `libx265`, `aac`, and
`mp2`. On macOS with Homebrew, install a full build:

Expand Down
151 changes: 137 additions & 14 deletions tools/devlab/devlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
# Tuple: (channel_label, profile, seg_type, live_key).
HLS_CHANNELS = (
("HLS-TS (h264-mp2)", "h264-mp2", "mpegts", "h264-mp2-ts"),
("HLS-TS live packet loss", "h264-mp2", "mpegts", "h264-mp2-ts-loss"),
("HLS-TS (hevc-aac)", "hevc-aac", "mpegts", "hevc-aac-ts"),
("HLS-fMP4 (h264-aac)", "h264-aac", "fmp4", "h264-aac-fmp4"),
("HLS-fMP4 (hevc-aac)", "hevc-aac", "fmp4", "hevc-aac-fmp4"),
Expand Down Expand Up @@ -121,11 +122,19 @@ class AudioRendition(NamedTuple):
AudioRendition("audio-es", "Español", "es", "spa", 1320, "h264-ac3", "ac3", "192k"),
)

MPEG_TS_PACKET_SIZE = 188
RTP_TS_PAYLOAD_SIZE = 7 * MPEG_TS_PACKET_SIZE

# ---------------------------------------------------------------------------
# ffmpeg argument helpers
# ---------------------------------------------------------------------------


def mpegts_timestamp_args(offset: int) -> list[str]:
"""Return ffmpeg arguments for deterministic zero-delay MPEG-TS timestamps."""
return ["-output_ts_offset", str(offset), "-muxdelay", "0", "-muxpreload", "0"]


def resolve_font(path: str | None) -> str:
if path:
if os.path.isfile(path):
Expand Down Expand Up @@ -319,16 +328,11 @@ def _parse_time_token(tok: str) -> int:
return int(time.time())


def parse_playseek_begin(playseek: str) -> int:
"""Return the begin time of a playseek value as a UTC epoch (seconds)."""
return _parse_time_token(playseek.split("-")[0]) if playseek else int(time.time())


def parse_playseek_range(playseek: str) -> tuple[int, int]:
"""Return ``(begin, end)`` UTC epochs for a ``BEGIN-END`` playseek value.

rtp2httpd forwards compact ``yyyyMMddHHmmss`` times (no internal dashes) so a
plain ``-`` split is safe. A missing/!invalid end defaults to begin + 60s.
plain ``-`` split is safe. A missing or invalid end defaults to begin + 60s.
"""
toks = [t for t in playseek.split("-") if t.strip()] if playseek else []
begin = _parse_time_token(toks[0]) if toks else int(time.time())
Expand Down Expand Up @@ -588,12 +592,65 @@ def segment_cmd(self, profile: str, begin: int, idx: int) -> list[str]:
]


class ContinuityHLS:
"""Deterministic HLS-TS VOD with overlap, gap and a large timestamp restart."""

seg_dur = 6
timestamp_offsets = (0, 5, 13, 0)

def __init__(self, ffmpeg: str):
self.ffmpeg = ffmpeg

def playlist(self, base: str) -> str:
lines = [
"#EXTM3U",
"#EXT-X-VERSION:3",
f"#EXT-X-TARGETDURATION:{self.seg_dur}",
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
]
for idx in range(len(self.timestamp_offsets)):
if idx == 2:
# The TS path deliberately ignores this tag and follows the
# actual timestamp continuity policy instead.
lines.append("#EXT-X-DISCONTINUITY")
lines += [f"#EXTINF:{self.seg_dur}.000,", f"{base}/continuity-hls/{idx}.ts"]
lines.append("#EXT-X-ENDLIST")
return "\n".join(lines) + "\n"

def segment_cmd(self, idx: int) -> list[str]:
offset = self.timestamp_offsets[idx]
return [
self.ffmpeg,
"-hide_banner",
"-loglevel",
"error",
*lavfi_inputs(),
"-t",
str(self.seg_dur),
"-vf",
f"{live_filter('HLS continuity')},{_drawtext(f'SEG {idx} PTS {offset}s', 152, expansion=False)}",
*video_args("h264-mp2"),
*audio_args("h264-mp2"),
*mpegts_timestamp_args(offset),
"-f",
"mpegts",
"-",
]


# ---------------------------------------------------------------------------
# HTTP origin server
# ---------------------------------------------------------------------------


def make_http_handler(host: str, port: int, live_root: str, catchup: CatchupHLS) -> type[BaseHTTPRequestHandler]:
def make_http_handler(
host: str,
port: int,
live_root: str,
catchup: CatchupHLS,
continuity: ContinuityHLS,
) -> type[BaseHTTPRequestHandler]:
base = f"http://{host}:{port}"

class Handler(BaseHTTPRequestHandler):
Expand Down Expand Up @@ -621,7 +678,18 @@ def _serve_file(self, path: str) -> None:
else:
ctype = "video/mp2t"
with open(path, "rb") as fh:
self._send(200, ctype, fh.read())
body = fh.read()
if "h264-mp2-ts-loss" in path and path.endswith(".ts"):
try:
segment_index = int(os.path.basename(path).removeprefix("seg_").removesuffix(".ts"))
except ValueError:
segment_index = 0
if segment_index % 3 == 1:
packet_count = len(body) // MPEG_TS_PACKET_SIZE
drop_start = max(1, packet_count // 2)
drop_end = min(packet_count, drop_start + 40)
body = body[: drop_start * MPEG_TS_PACKET_SIZE] + body[drop_end * MPEG_TS_PACKET_SIZE :]
self._send(200, ctype, body)

def _live(self, parts: list[str]) -> None:
# /live/<profile>/<path...>
Expand Down Expand Up @@ -657,6 +725,26 @@ def _catchup_seg(self, profile: str, begin: int, idx: int) -> None:
if proc.poll() is None:
proc.terminate()

def _continuity_playlist(self) -> None:
text = continuity.playlist(base)
self._send(200, "application/vnd.apple.mpegurl", text.encode())

def _continuity_seg(self, idx: int) -> None:
if idx < 0 or idx >= len(continuity.timestamp_offsets):
self._send(404, "text/plain", b"not found")
return
proc = subprocess.run(
continuity.segment_cmd(idx),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30,
check=False,
)
if proc.returncode != 0:
self._send(500, "text/plain", proc.stderr[-4096:] or b"ffmpeg failed")
return
self._send(200, "video/mp2t", proc.stdout)

def _multi_ts(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "video/mp2t")
Expand Down Expand Up @@ -702,6 +790,10 @@ def do_GET(self) -> None: # noqa: N802 (http.server API)
elif len(parts) == 4 and parts[0] == "catchup-seg":
# /catchup-seg/<profile>/<begin>/<idx>.ts
self._catchup_seg(parts[1], int(parts[2]), int(parts[3].removesuffix(".ts")))
elif parts == ["continuity-hls", "index.m3u8"]:
self._continuity_playlist()
elif len(parts) == 2 and parts[0] == "continuity-hls":
self._continuity_seg(int(parts[1].removesuffix(".ts")))
elif parts == ["multi-audio.ts"]:
self._multi_ts()
else:
Expand Down Expand Up @@ -731,6 +823,8 @@ def __init__(self, ffmpeg: str, host: str, port: int):
self._sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._catchup_gap_requests = 0
self._catchup_gap_lock = threading.Lock()

def start(self) -> None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
Expand Down Expand Up @@ -765,10 +859,22 @@ def _profile_from_uri(uri: str) -> str:

def _ffmpeg_cmd(self, uri: str) -> list[str]:
profile = self._profile_from_uri(uri)
qs = parse_qs(urlparse(uri).query)
parsed_uri = urlparse(uri)
qs = parse_qs(parsed_uri.query)
playseek = (qs.get("playseek") or qs.get("tvdr") or [""])[0]
duration_args: list[str] = []
timestamp_args: list[str] = []
if playseek:
vf = catchup_filter(profile, parse_playseek_begin(playseek))
begin, end = parse_playseek_range(playseek)
vf = catchup_filter(profile, begin)
duration_args = ["-t", str(end - begin)]
timeline_offset = begin % 86400
if "/catchup-gap/" in parsed_uri.path:
with self._catchup_gap_lock:
request_index = self._catchup_gap_requests
self._catchup_gap_requests += 1
timeline_offset += request_index * 2
timestamp_args = mpegts_timestamp_args(timeline_offset)
else:
vf = live_filter(profile)
return [
Expand All @@ -778,10 +884,12 @@ def _ffmpeg_cmd(self, uri: str) -> list[str]:
"error",
"-re",
*lavfi_inputs(),
*duration_args,
"-vf",
vf,
*video_args(profile),
*audio_args(profile),
*timestamp_args,
"-f",
"mpegts",
"-",
Expand Down Expand Up @@ -850,7 +958,8 @@ def _pump(self, conn: socket.socket, uri: str) -> None:
seq = 0
ts = 0
buf = b""
payload = 1316 # 7 * 188-byte TS packets per RTP datagram
payload = RTP_TS_PAYLOAD_SIZE
inject_packet_loss = "/live-loss/" in urlparse(uri).path
try:
while not self._stop.is_set():
chunk = proc.stdout.read(payload)
Expand All @@ -861,7 +970,9 @@ def _pump(self, conn: socket.socket, uri: str) -> None:
pkt, buf = buf[:payload], buf[payload:]
header = struct.pack("!BBHII", 0x80, 33, seq & 0xFFFF, ts & 0xFFFFFFFF, 0x0DEFACED)
rtp = header + pkt
conn.sendall(b"\x24" + struct.pack("!BH", 0, len(rtp)) + rtp)
drop_for_fault = inject_packet_loss and 500 <= seq % 1000 < 506
if not drop_for_fault:
conn.sendall(b"\x24" + struct.pack("!BH", 0, len(rtp)) + rtp)
seq += 1
ts += 3600
except BrokenPipeError, ConnectionError, OSError:
Expand Down Expand Up @@ -908,7 +1019,7 @@ def url(self) -> str:

def _cmd(self) -> list[str]:
common = [self.ffmpeg, "-hide_banner", "-loglevel", "error"]
out = f"{self.url()}?ttl=1&pkt_size=1316"
out = f"{self.url()}?ttl=1&pkt_size={RTP_TS_PAYLOAD_SIZE}"
if self.ts_file:
# Stream-copy the original bitstream so the exact codecs are relayed.
return [*common, "-re", "-stream_loop", "-1", "-i", self.ts_file, "-c", "copy", "-f", "rtp_mpegts", out]
Expand Down Expand Up @@ -976,6 +1087,10 @@ def build_services_m3u(http_hostport: str, rtsp_hostport: str, mcast_channels: l
f'#EXTINF:-1 group-title="HLS" catchup="default" catchup-source="{src}",{label}',
f"http://{http_hostport}/live/{key}/index.m3u8",
]
lines += [
'#EXTINF:-1 group-title="HLS",HLS-TS continuity overlap gap restart',
f"http://{http_hostport}/continuity-hls/index.m3u8",
]
for label, _seg_type, key in ALT_AUDIO_HLS_CHANNELS:
lines += [
f'#EXTINF:-1 group-title="HLS",{label}',
Expand All @@ -1000,6 +1115,13 @@ def build_services_m3u(http_hostport: str, rtsp_hostport: str, mcast_channels: l
extinf,
f"rtsp://{rtsp_hostport}/live/{prof}",
]
gap_src = f"rtsp://{rtsp_hostport}/catchup-gap/h264-mp2?{tpl}"
lines += [
f'#EXTINF:-1 group-title="mpegts (RTSP)" catchup="default" catchup-source="{gap_src}",mpegts catchup gap',
f"rtsp://{rtsp_hostport}/live/h264-mp2",
'#EXTINF:-1 group-title="mpegts (RTSP)",mpegts live packet loss',
f"rtsp://{rtsp_hostport}/live-loss/h264-mp2",
]
for group_title, name, rtp_url in mcast_channels:
lines += [f'#EXTINF:-1 group-title="{group_title}",{name}', rtp_url]
return "\n".join(lines) + "\n"
Expand Down Expand Up @@ -1093,7 +1215,8 @@ def main() -> int:
return 1

catchup = CatchupHLS(args.ffmpeg)
handler = make_http_handler(args.host, args.http_port, live_root, catchup)
continuity = ContinuityHLS(args.ffmpeg)
handler = make_http_handler(args.host, args.http_port, live_root, catchup, continuity)
httpd = ThreadingHTTPServer((args.host, args.http_port), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()

Expand Down
1 change: 1 addition & 0 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ function VideoPlayerComponent({
const track = state.tracks.find((candidate) => candidate.id === trackId);
const activePlayer = getActivePlayer();
if (!track || !channel || !activePlayer || trackId === state.selectedTrackId) return;
setWarning(null);
pendingAudioPreferenceRef.current = { channelId: channel.id, trackId, preferenceKey: track.preferenceKey };
activePlayer.selectAudioTrack(trackId);
});
Expand Down
Loading