fix(player): stop the server burning subtitles into the picture

A transcoded episode stalled every few seconds and seeking took five to
nine seconds to produce a frame. Neither was a seek bug: both seeks in the
capture landed correctly. The stream itself could not keep up.

The episode was HEVC video, E-AC-3 audio, and a PGSSUB subtitle track.
Only the audio needed transcoding — the device profile supports HEVC and
the server would have remuxed the video untouched. But the PlaybackInfo
request omitted SubtitleStreamIndex, and omitting it does not mean "no
subtitles": the server then honours the source's default/forced flag and
picks a track itself. It picked the PGS one. PGS is a bitmap, and the
profile advertised only srt/vtt as External, so it could not go out as a
sidecar — leaving SubtitleMethod=Encode, burn-in.

Burn-in is a video cost, not a subtitle cost. Compositing rules out
remuxing, so the whole HEVC stream was re-encoded to h264 frame by frame.
The server could not sustain that in real time: the buffer never grew past
one segment and playback ran waiting -> HLS error -> canplay -> three
seconds of picture, indefinitely, while each seek restarted the encoder
from scratch. TranscodeReasons named it — SubtitleCodecNotSupported — but
nothing in the log connected that to the stall, so the diagnostic now says
which track it is declining and why.

Ask for SubtitleStreamIndex=-1 explicitly, and advertise every text format
we can render (srt/subrip/ass/ssa/vtt) as External so a subtitle can only
ever arrive as a sidecar. Nothing is lost: the app already fetches subtitle
tracks itself and draws them over the video (UR-020), so the server's
composited copy was always redundant. Image-based tracks are consequently
not offered, which is honest rather than a regression — the renderer cannot
composite a bitmap, and the previous behaviour paid for them by making the
stream unwatchable.

The policy lives beside the other device-profile rules in Rust, where it is
testable without a device.

TRACES: UR-020, UR-004 | DR-176 | UT-168
This commit is contained in:
2026-08-16 09:23:39 +02:00
parent 1a9805f0f3
commit 041969f446
3 changed files with 134 additions and 13 deletions
+32 -11
View File
@@ -1530,23 +1530,27 @@ impl MediaRepository for OnlineRepository {
max_audio_channels: max_audio_channels.clone(),
},
],
subtitle_profiles: vec![
SubtitleProfile {
format: "srt".to_string(),
method: "External".to_string(),
},
SubtitleProfile {
format: "vtt".to_string(),
method: "External".to_string(),
},
],
subtitle_profiles: super::device_profile::subtitle_profiles()
.into_iter()
.map(|(format, method)| SubtitleProfile {
format: format.to_string(),
method: method.to_string(),
})
.collect(),
};
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: None, // Let the server pick the source default
subtitle_stream_index: None,
// Never let the server choose a subtitle track for us. Omitting this
// makes it honour the source's default/forced flag, and an image-based
// default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
// — burn-in, which forces a full video re-encode of a stream that would
// otherwise be remuxed. The app renders subtitles itself (UR-020).
//
// TRACES: UR-020, UR-004 | DR-176 | UT-168
subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
start_time_ticks: 0,
is_playback: true,
auto_open_live_stream: true,
@@ -1573,6 +1577,23 @@ impl MediaRepository for OnlineRepository {
);
}
// Name the tracks we are declining to have the server composite. Burn-in
// rules out remuxing the video, so a single image-based track can turn a
// free passthrough into a full re-encode; when that used to happen there
// was nothing in the log connecting the stall to the subtitle.
for stream in &source.media_streams {
if stream.stream_type == "Subtitle" {
if let Some(codec) = stream.codec.as_deref() {
if super::device_profile::subtitle_forces_burn_in(codec) {
info!(
" Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
stream.index, codec
);
}
}
}
}
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
// but ignores its audio codec, so it offers an E-AC-3 track for direct
// play even though DR-148 advertises only AAC — and the webview renders