Flag image-based subtitle tracks (PGS/VobSub/DVB) as not burn-in-able
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:
parent
5ee99a7525
commit
2f670ec9a4
6 changed files with 91 additions and 3 deletions
|
|
@ -36,6 +36,11 @@ export interface SeasonDto {
|
||||||
export interface PlaybackTrackOption {
|
export interface PlaybackTrackOption {
|
||||||
index: number;
|
index: number;
|
||||||
label: string;
|
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 {
|
export interface PlaybackOptionsDto {
|
||||||
|
|
|
||||||
|
|
@ -230,8 +230,17 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
audioSelect.innerHTML = options.audioTracks.length
|
audioSelect.innerHTML = options.audioTracks.length
|
||||||
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
||||||
: '<option value="">Piste par défaut</option>';
|
: '<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>' +
|
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) {
|
} catch (err) {
|
||||||
console.error('[watchparty player] playback-options failed', err);
|
console.error('[watchparty player] playback-options failed', err);
|
||||||
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
|
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
|
||||||
|
|
|
||||||
|
|
@ -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
|
Nothing to change in this app for this one; it needs fonts/fontconfig
|
||||||
fixed wherever Emby's own container/host runs.
|
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
|
## Assumptions to verify
|
||||||
|
|
||||||
**Confirmed against the real server** (not a guess — actually exercised,
|
**Confirmed against the real server** (not a guess — actually exercised,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,18 @@ const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'A
|
||||||
// the same check.
|
// the same check.
|
||||||
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
|
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 {
|
interface EmbyItemsResponse {
|
||||||
Items?: EmbyItem[];
|
Items?: EmbyItem[];
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +43,7 @@ interface PlaybackInfoResponse {
|
||||||
MediaSources?: Array<{
|
MediaSources?: Array<{
|
||||||
Id?: string;
|
Id?: string;
|
||||||
TranscodingUrl?: 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;
|
PlaySessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +190,11 @@ class CatalogServiceClass {
|
||||||
const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`;
|
const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`;
|
||||||
return {
|
return {
|
||||||
audioTracks: streams.filter((s) => s.Type === 'Audio').map((s) => ({ index: s.Index, label: label(s) })),
|
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())
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,11 @@ export interface SeasonDto {
|
||||||
export interface PlaybackTrackOption {
|
export interface PlaybackTrackOption {
|
||||||
index: number;
|
index: number;
|
||||||
label: string;
|
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 {
|
export interface PlaybackOptionsDto {
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,24 @@ describe('CatalogService', () => {
|
||||||
(err: Error & { status?: number }) => err.status === 404
|
(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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue