Flag image-based subtitle tracks (PGS/VobSub/DVB) as not burn-in-able
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 14s

Two different items both failed the same way when picking a subtitle -
without knowing whether the second one was also a PGS (image/bitmap)
track like the first confirmed one, that result was ambiguous: still the
same known issue, or something new? Rather than keep digging through
server logs per item, this app can just know the answer itself - Emby's
PlaybackInfo response already includes each subtitle stream's Codec.

- server/src/services/CatalogService.ts: getPlaybackOptions reads it and
  reports supportsBurnIn: false for image-based subtitle codecs
  (hdmv_pgs_subtitle/dvd_subtitle/dvb_subtitle - IMAGE_SUBTITLE_CODECS).
  This app forces SubtitleMethod=Encode whenever a subtitle is picked
  (applyPlaybackOverrides), asking ffmpeg's `subtitles` filter (libass)
  to burn it in - libass renders text via a font, it has no path for
  compositing a pre-rendered bitmap at all, confirmed against a real
  server (see docs/architecture.md's Round 4/5).
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption gains
  supportsBurnIn (subtitle tracks only, undefined for audio).
- client/src/views/player/Player.ts: the subtitle <select> now disables
  these options with a "(image, non disponible)" label instead of
  letting anyone pick a track that's guaranteed to break playback -
  listed so it's clear why, not silently missing.
- server/test/catalogService.test.ts: new case covering subrip/ass
  (supportsBurnIn: true) vs hdmv_pgs_subtitle (false) from the same
  MediaStreams response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 14:45:38 +02:00
parent 5ee99a7525
commit 2f670ec9a4
6 changed files with 91 additions and 3 deletions

View file

@ -36,6 +36,11 @@ export interface SeasonDto {
export interface PlaybackTrackOption {
index: number;
label: string;
// Subtitle tracks only (undefined for audio) - false for an image/
// bitmap subtitle (PGS/VobSub/DVB - a Blu-ray/DVD rip's own subtitles,
// typically), which this player's forced burn-in (SubtitleMethod=Encode)
// cannot actually render - see Player.ts's subtitle <select> population.
supportsBurnIn?: boolean;
}
export interface PlaybackOptionsDto {

View file

@ -230,8 +230,17 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>';
// supportsBurnIn: false means this is an image/bitmap subtitle (PGS/
// VobSub/DVB, typically a Blu-ray/DVD rip) - this player forces a
// real burn-in (SubtitleMethod=Encode, see CatalogService) whenever
// a subtitle is picked, which only ever works for text subtitles.
// Disabled rather than hidden, so it's clear the track exists and
// *why* it can't be picked, instead of silently missing from the
// list - confirmed against a real server that picking one of these
// doesn't just fail to show, it breaks playback outright (see
// docs/architecture.md's player-track-selection section).
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('');
options.subtitleTracks.map((t) => `<option value="${t.index}"${t.supportsBurnIn === false ? ' disabled' : ''}>${escapeHtml(t.label)}${t.supportsBurnIn === false ? ' (image, non disponible)' : ''}</option>`).join('');
} catch (err) {
console.error('[watchparty player] playback-options failed', err);
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.

View file

@ -533,6 +533,39 @@ this bug (a subtitle picked, never shown) instead of fixing anything.
Nothing to change in this app for this one; it needs fonts/fontconfig
fixed wherever Emby's own container/host runs.
**Round 5: fontconfig fixed, and a *different* item still failed the same
way - added a real check instead of guessing further.** After Round 4's
fix, the font loaded fine (`Loading font file '/config/fonts/
GoNotoKurrent.ttf'`, no more "Failed to load fontconfig fonts") - but a
retest on the same item still failed, this time with `Could not find
codec parameters for stream N (Subtitle: hdmv_pgs_subtitle (pgssub))`.
`hdmv_pgs_subtitle` (PGS) is a Blu-ray **image/bitmap** subtitle format -
a pre-rendered picture per frame, not text. This app's forced
`SubtitleMethod=Encode` asks ffmpeg's `subtitles` filter to burn it in,
and that filter is `libass` - it renders *text* via a font (exactly what
Round 4 just fixed the ability to do), and has no code path for
compositing a bitmap at all. Picking a PGS track was never going to work
here, fontconfig or not. A second item, tested specifically to rule PGS
in or out as the whole explanation, hit the exact same wall - without
knowing that item's own subtitle codec, that result was genuinely
ambiguous (still PGS on a second file? or something else entirely?),
which is exactly the kind of guess this saga had already spent four
rounds on.
Rather than keep asking for one more log line per item, `getPlaybackOptions`
(`CatalogService.ts`) now reads each subtitle stream's own `Codec` from
the same `PlaybackInfo` response it already fetches, and reports
`supportsBurnIn: false` for the three image-based subtitle codecs
(`hdmv_pgs_subtitle`, `dvd_subtitle`, `dvb_subtitle` - `IMAGE_SUBTITLE_CODECS`).
The player's subtitle `<select>` (`views/player/Player.ts`) shows these
as disabled options labeled "(image, non disponible)" rather than hiding
them outright - the track still exists and is still listed, just not
pickable, so it's clear *why* rather than silently missing. This answers
the "is item 2 also PGS" question directly from the app itself instead of
needing another round of server-log archaeology, and prevents anyone
from picking a subtitle that's guaranteed to break playback the same way
again, for any item, without needing to know any of this history.
## Assumptions to verify
**Confirmed against the real server** (not a guess — actually exercised,

View file

@ -23,6 +23,18 @@ const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'A
// the same check.
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
// Image/bitmap subtitle codecs - pre-rendered pictures (from a Blu-ray/DVD
// rip), not text. This app forces SubtitleMethod=Encode whenever a
// subtitle is picked (see applyPlaybackOverrides), which asks ffmpeg's
// `subtitles` filter (libass) to burn it in - libass renders TEXT via a
// font, it has no code path for compositing a bitmap at all. Confirmed
// against a real server (see docs/architecture.md's player-track-
// selection section, "Round 4/5"): picking one of these doesn't produce a
// slow-but-working transcode, it makes Emby's own segment generation
// throw outright. Used by getPlaybackOptions to flag these in the picker
// instead of letting someone pick a track that's guaranteed to fail.
const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle']);
interface EmbyItemsResponse {
Items?: EmbyItem[];
}
@ -31,7 +43,7 @@ interface PlaybackInfoResponse {
MediaSources?: Array<{
Id?: string;
TranscodingUrl?: string;
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string }>;
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string; Codec?: string }>;
}>;
PlaySessionId?: string;
}
@ -178,7 +190,11 @@ class CatalogServiceClass {
const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`;
return {
audioTracks: streams.filter((s) => s.Type === 'Audio').map((s) => ({ index: s.Index, label: label(s) })),
subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({ index: s.Index, label: label(s) }))
subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({
index: s.Index,
label: label(s),
supportsBurnIn: !IMAGE_SUBTITLE_CODECS.has((s.Codec || '').toLowerCase())
}))
};
}

View file

@ -138,6 +138,11 @@ export interface SeasonDto {
export interface PlaybackTrackOption {
index: number;
label: string;
// Subtitle tracks only (undefined for audio - the concept doesn't apply).
// false means this app's forced SubtitleMethod=Encode (see
// CatalogService.applyPlaybackOverrides) cannot actually work for this
// track - see IMAGE_SUBTITLE_CODECS's own comment for why.
supportsBurnIn?: boolean;
}
export interface PlaybackOptionsDto {

View file

@ -86,4 +86,24 @@ describe('CatalogService', () => {
(err: Error & { status?: number }) => err.status === 404
);
});
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not burn-in-able, text subtitles as fine', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{
Id: 'src1',
MediaStreams: [
{ Type: 'Audio', Index: 1, Language: 'fre' },
{ Type: 'Subtitle', Index: 2, Language: 'eng', Codec: 'subrip' },
{ Type: 'Subtitle', Index: 3, Language: 'eng', Codec: 'hdmv_pgs_subtitle' },
{ Type: 'Subtitle', Index: 4, Language: 'fre', Codec: 'ass' }
]
}]
}));
const options = await CatalogService.getPlaybackOptions(session, 'item1');
assert.equal(options.subtitleTracks.find((t) => t.index === 2)?.supportsBurnIn, true, 'subrip is text - burns in fine');
assert.equal(options.subtitleTracks.find((t) => t.index === 3)?.supportsBurnIn, false, 'PGS is a bitmap - this player\'s forced Encode can\'t render it');
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.supportsBurnIn, true, 'ass is text too');
});
});