fix(playout): clean CFR HLS preview so hls.js can sync

Root cause of the black preview: CasparCG's real-time channel feeds the
HLS consumer frames with irregular timestamps (the "packet with pts X has
duration 0" warnings). With frame-count GOPs (-g 60) the muxer split
points drift, producing erratic segment durations (0.4s-4.2s) that exceed
the declared TARGETDURATION. hls.js parses the resulting live playlist but
can never establish a fragment timeline — it reloads forever
("sliding 0.00 / prev-sn na / MISSED") and never appends a fragment, so
the video element stays at readyState 0 (black). Verified live via the
browser: manifest + segments serve 200, segment is valid h264/aac with a
keyframe start, yet hls.js logs zero FRAG_LOADED.

Fix: force a constant output frame rate (-r 30000/1001, regenerates
uniform PTS) and time-based keyframes every 2s (-force_key_frames
expr:gte(t,n_forced*2)), so every segment is a clean keyframe-aligned 2.0s
chunk. Yields a spec-compliant playlist (TARGETDURATION 2, stable
8-segment/16s window) identical in shape to the capture/VOD HLS the rest
of the app already plays successfully through the same hls.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 13:35:45 -04:00
parent d778aa4cdb
commit f28799317d

View file

@ -159,24 +159,33 @@ export class PlayoutManager {
}
// Low-bitrate HLS for the web UI preview. Segments land in the shared media
// volume; the mam-api serves /live/<channel_id>/* from there.
// volume; the mam-api serves /media/live/<channel_id>/* from there.
async _addHlsConsumer() {
// mkdir is done by the entrypoint; CasparCG's ffmpeg consumer creates the
// playlist on first segment. 2s segments / 6-window list keeps lag low
// without thrashing disk.
// FILE keyword (alias of the FFMPEG consumer) writing a segmented HLS
// playlist. Same arg rules as the STREAM consumer: -param:stream form and a
// format=yuv420p filter ahead of libx264 (channel output is RGBA).
// The CasparCG channel feeds this consumer in real time, and its frame
// timestamps are irregular ("packet with pts X has duration 0" warnings).
// With frame-count GOPs (-g 60) the HLS muxer split points drift, producing
// erratic segment durations (0.4s4.2s) and TARGETDURATION violations. The
// result is a live playlist hls.js parses but can never sync to — it
// reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends
// a fragment, so the preview stays black.
//
// Fix: force a constant output frame rate (-r, regenerates uniform PTS) and
// TIME-based keyframes every 2s (-force_key_frames) so every segment is a
// clean, keyframe-aligned 2.0s chunk. This yields a spec-compliant playlist
// (TARGETDURATION 2, stable 8-segment / 16s window) identical in shape to
// the capture/VOD HLS the rest of the app already plays.
const out = `${HLS_DIR}/index.m3u8`;
const args = [
`FILE "${out}"`,
'-format hls',
'-hls_time 2',
'-hls_list_size 6',
'-hls_flags delete_segments+append_list',
'-hls_list_size 8',
'-hls_flags delete_segments+append_list+independent_segments',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M',
'-g 60 -keyint_min 60 -sc_threshold 0',
'-codec:a aac -b:a 96k',
// 29.97 CFR confidence feed: -r forces constant frame rate (fixes the
// duration-0 PTS), -force_key_frames pins keyframes to 2s media boundaries.
'-r 30000/1001 -force_key_frames expr:gte(t,n_forced*2) -sc_threshold 0',
'-codec:a aac -b:a 96k -ar 48000',
'-filter:v format=yuv420p',
].join(' ');
await this.amcp.send(`ADD ${CHANNEL} ${args}`);