Skip to content

Pin the H.264 profile so transcoded video actually renders - #305

Merged
tripsittr merged 1 commit into
mainfrom
hls-baseline-profile
Oct 6, 2026
Merged

tripsittr merged 1 commit into
mainfrom
hls-baseline-profile

Conversation

@tripsittr

Copy link
Copy Markdown
Owner

Reported twice as "audio plays, no video" — most recently Midnight Gospel episode 3, after episode 2 was fixed. Both times it looked like a broken stream. It is an unsupported H.264 profile.

Why

libx264 inherits the source's pixel format unless told otherwise. A 10-bit rip — common for anime and for anything encoded from a good master — came out of the HLS transcode as High 10, which Apple's hardware decoder refuses outright. The audio track decodes fine, so the player runs the clock over a black rectangle.

Measured, not reasoned

Running the server's exact command over a yuv420p10le source:

before:  profile=High 10   pix_fmt=yuv420p10le
after:   profile=High      pix_fmt=yuv420p

-profile:v high -level:v 4.1 -pix_fmt yuv420p. Level 4.1 is the ceiling every iOS device since the 4S decodes and covers 1080p comfortably.

The lesson already existed in this codebase

MediaTranscoder, which writes the permanent converted copies, has set -pix_fmt yuv420p all along — with a comment reading "10-bit sources otherwise produce a file nothing will play". The HLS path never learned it.

That is the part worth noting: this was a known hazard, documented in a neighbouring file, and the newer code path reintroduced it. The profile is now pinned in both, and asserted by a test rather than left to a comment.

Testing

Two tests. One checks the argument list. The other runs ffmpeg for real — encodes a genuinely 10-bit source through the server's own command() and probes the resulting segment for High 10.

The second is the one that matters: an argument-list assertion alone would have passed while the output was still unplayable, which is exactly how this shipped. Both confirmed to fail against the shipped code; the real-encode test fails with "High 10 is refused by Apple hardware decoders".

325 pass across the streaming and media suites. It skips cleanly where ffmpeg cannot encode 10-bit.

For review

Existing HLS sessions are cached on disk and will not be re-encoded. HlsSegmenter::start() reuses a session directory when index.m3u8 already exists, and the session id is a hash of (item, updated_at, height, start) — which this change does not alter. So any episode already transcoded still has High 10 segments and will still play black until that cache is cleared. Worth confirming where those live and whether this needs a one-off purge on deploy; I have not done that here because it deletes files.

Level 4.1 is a judgement call. It is the safe universal ceiling, but it caps 4K — a 2160p source will be constrained. Given the transcode already scales to max_height (1080 by default) that should not bite, but if you ever raise that ceiling the level needs raising with it.

Reported twice as "audio plays, no video", which reads as a broken stream and
is an unsupported profile. libx264 inherits the source's pixel format unless
told otherwise, so a 10-bit rip came out of the HLS transcode as High 10 --
which Apple's hardware decoder refuses outright. The audio decodes, so the
clock runs over a black rectangle.

Measured by running the server's exact command over a yuv420p10le source:
before, profile=High 10 and pix_fmt=yuv420p10le; after, profile=High and
pix_fmt=yuv420p.

MediaTranscoder already set -pix_fmt yuv420p, with a comment saying 10-bit
sources otherwise produce a file nothing will play. The HLS path never learned
it. The profile is pinned in both now.

One test checks the arguments; the other runs ffmpeg for real, encodes a 10-bit
source through the server's own command and probes the segment for High 10.
The second is the one that matters -- an argument assertion alone would pass
while the output stayed unplayable.
@tripsittr

Copy link
Copy Markdown
Owner Author

Correction to my own "for review" note — the stale-cache concern is already handled, no purge needed.

I wrote that existing sessions keep their High 10 segments and "worth confirming whether this needs a one-off purge on deploy". It does not. HlsSegmenter::sweep() removes any session whose newest segment is older than 120 minutes, and routes/console.php runs it hourly under sweep-hls-sessions.

So after deploying, stale segments clear themselves within an hour or two, and the next request for that episode re-encodes with the pinned profile. Nothing to delete by hand.

The only residue is an episode somebody re-opens inside that window, which would replay the old High 10 segments once. If that matters, deleting storage/app/private/hls/ on deploy forces an immediate re-encode — but it is not required, and I have not done it since it removes files.

@tripsittr

Copy link
Copy Markdown
Owner Author

Reviewed on a5 — clean. 2 tests pass. Based on current main, no migration, no destructive ops.

Correct fix for the transcode side of the 10-bit problem (Midnight Gospel E03 audio-only). Pinning -profile:v high -level:v 4.1 -pix_fmt yuv420p in both the HLS segmenter and MediaTranscoder forces 8-bit High rather than the High 10 that libx264 inherits from a 10-bit source — which Apple's hardware decoder refuses (audio decodes, video doesn't → the exact symptom). Level 4.1 covers 1080p and is decodable since the 4S. Good that you verified it against a real yuv420p10le source rather than assuming.

This connects to my original Midnight Gospel probe: those episodes are 10-bit HEVC Main 10, so every transcode of them hit this. Nothing blocking.

@tripsittr
tripsittr merged commit 51460ac into main Oct 6, 2026
3 checks passed
@tripsittr
tripsittr deleted the hls-baseline-profile branch October 6, 2026 23:00
tripsittr added a commit that referenced this pull request Oct 6, 2026
#305 fixed the transcode side: libx264 inherited the source pixel format and
emitted High 10, which Apple hardware refuses. a5's review noted the Midnight
Gospel episodes are 10-bit HEVC Main 10, which prompted checking the other
route.

A 10-bit H.264 .mp4 reaches the same failure with no transcode at all. The
stream is genuinely h264 in a genuinely .mp4, so every check passed and the
file was handed over untouched as High 10, playing black. The probe had
recorded bit_depth all along and nothing consulted it.

Verified against a real encode: codec_name=h264, profile=High 10,
bits_per_raw_sample=10. Before, isPlayableVideo returned true; now it is
refused and routes to the transcode that produces 8-bit High.

An unrecorded depth is treated as 8-bit, because that is overwhelmingly what
it is -- assuming otherwise would transcode every file an older probe measured.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant