* feat(playback): add IsPGS helper and sup streaming extract path PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup elementary stream for client-side rendering, so they no longer have to be burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t windowing now skips PGS like ASS: both formats are fetched once and consumed whole by their client-side renderers. This also fixes a pre-existing truncation bug: the -t duration cap was applied unconditionally, cutting embedded ASS extracts off at the default 600s window even though the ASS client fetches the full track. Extract the ffmpeg argument construction into streamExtractArgs for testability, following the buildFFmpegArgs pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): expose PGS subtitle tracks as .sup stream URLs PGS tracks were filtered out of /playback/start subtitle_urls entirely, so the web player showed no subtitles for PGS-only files (#34). Include them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since they still have no non-burn-in delivery path. HandleSubtitle streams the full PGS track as application/octet-stream. The seek/duration window is forced to zero for sup: subtitleSeekPosition falls back to the session's last reported position even without a ?position= query, which would otherwise start the extract mid-file. The proxy-node subtitle handler gets the same sup branch, streaming ffmpeg output directly instead of buffering like its text paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared helper so codec lists live in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(player): native PGS subtitle rendering via libpgs Render PGS subtitle tracks client-side instead of leaving them unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS track is active it lazy-loads libpgs, which fetches the .sup stream in a worker, decodes display sets progressively as bytes arrive, and draws them onto a canvas positioned over the video. The renderer looks up the display set at currentTime + timeOffset, so the HLS stream origin adds and the user-facing delay subtracts — a positive delay shows subtitles later, matching VTT semantics. Offset changes apply through the timeOffset setter without recreating the renderer; track switches, PiP detach, and unmount dispose it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(player): prefer text over bitmap tracks in subtitle auto-select With PGS tracks now listed, an earlier PGS track would win auto-select over a later same-language SRT/ASS track. Deprioritize bitmap codecs within the same source tier — text is lighter to render and styleable — while a PGS track still wins when it is the only language match, and forced-PGS auto-select now works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
258 lines
7.7 KiB
TypeScript
258 lines
7.7 KiB
TypeScript
import type { PlayerSubtitleInfo, PlayerSubtitleTrackSignature, SubtitleMode } from "../types";
|
|
import { isBitmapCodec } from "./subtitleCodecs";
|
|
|
|
const ORIGINAL_LANGUAGE_SENTINEL = "original";
|
|
|
|
const SOURCE_PRIORITY: Record<string, number> = {
|
|
external: 0,
|
|
downloaded: 1,
|
|
embedded: 2,
|
|
};
|
|
|
|
/**
|
|
* Auto-select priority for a track: lower is better. Within the same source
|
|
* tier, text tracks beat bitmap (PGS) tracks — bitmap is heavier to render
|
|
* and can't be styled — while a bitmap track still wins when it's the only
|
|
* match for the language.
|
|
*/
|
|
function trackPriority(track: PlayerSubtitleInfo): number {
|
|
const source = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2;
|
|
return source * 2 + (isBitmapCodec(track.codec) ? 1 : 0);
|
|
}
|
|
|
|
const ISO639_TO_3: Record<string, string> = {
|
|
en: "eng",
|
|
es: "spa",
|
|
fr: "fre",
|
|
de: "ger",
|
|
it: "ita",
|
|
pt: "por",
|
|
nl: "dut",
|
|
pl: "pol",
|
|
sv: "swe",
|
|
no: "nor",
|
|
da: "dan",
|
|
fi: "fin",
|
|
ru: "rus",
|
|
uk: "ukr",
|
|
cs: "cze",
|
|
sk: "slo",
|
|
hu: "hun",
|
|
ro: "rum",
|
|
bg: "bul",
|
|
hr: "hrv",
|
|
sl: "slv",
|
|
sr: "srp",
|
|
tr: "tur",
|
|
el: "gre",
|
|
he: "heb",
|
|
ar: "ara",
|
|
zh: "chi",
|
|
ja: "jpn",
|
|
ko: "kor",
|
|
vi: "vie",
|
|
th: "tha",
|
|
id: "ind",
|
|
ms: "may",
|
|
hi: "hin",
|
|
};
|
|
|
|
function normalize(value: string | undefined | null): string {
|
|
return (value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeConcreteLanguage(value: string | undefined | null): string | null {
|
|
const normalized = normalize(value);
|
|
if (!normalized || normalized === ORIGINAL_LANGUAGE_SENTINEL) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function sameLanguageCode(a: string | undefined | null, b: string | undefined | null): boolean {
|
|
const left = normalizeConcreteLanguage(a);
|
|
const right = normalizeConcreteLanguage(b);
|
|
if (!left || !right) return false;
|
|
if (left === right) return true;
|
|
return (ISO639_TO_3[left] ?? left) === (ISO639_TO_3[right] ?? right);
|
|
}
|
|
|
|
function sameLanguage(track: PlayerSubtitleInfo, language: string): boolean {
|
|
return sameLanguageCode(track.language, language);
|
|
}
|
|
|
|
function subtitleTrackMatchesSignature(
|
|
track: PlayerSubtitleInfo,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): boolean {
|
|
if (!signature) return false;
|
|
return (
|
|
normalize(track.source) === normalize(signature.source) &&
|
|
normalize(track.language) === normalize(signature.language) &&
|
|
normalize(track.codec) === normalize(signature.codec) &&
|
|
(normalize(signature.label) === "" || normalize(track.label) === normalize(signature.label)) &&
|
|
Boolean(track.forced) === Boolean(signature.forced) &&
|
|
Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)
|
|
);
|
|
}
|
|
|
|
function findExactSubtitleSignatureMatch(
|
|
tracks: PlayerSubtitleInfo[],
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number | null {
|
|
if (!signature) return null;
|
|
const match = tracks.find((track) => subtitleTrackMatchesSignature(track, signature));
|
|
return match?.index ?? null;
|
|
}
|
|
|
|
function scoreSignatureFallback(
|
|
track: PlayerSubtitleInfo,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number {
|
|
if (!signature) return 0;
|
|
let score = 0;
|
|
if (normalize(track.source) === normalize(signature.source)) score += 4;
|
|
if (Boolean(track.forced) === Boolean(signature.forced)) score += 2;
|
|
if (Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)) score += 2;
|
|
if (normalize(track.codec) === normalize(signature.codec)) score += 1;
|
|
if (normalize(track.label) === normalize(signature.label)) score += 1;
|
|
return score;
|
|
}
|
|
|
|
/** Sort subtitle tracks: external first, then downloaded, then embedded. */
|
|
export function sortSubtitlesBySource(tracks: PlayerSubtitleInfo[]): PlayerSubtitleInfo[] {
|
|
return [...tracks].sort((a, b) => {
|
|
const pa = SOURCE_PRIORITY[a.source ?? "embedded"] ?? 2;
|
|
const pb = SOURCE_PRIORITY[b.source ?? "embedded"] ?? 2;
|
|
return pa - pb;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find the best subtitle track index for a given language,
|
|
* preferring external > downloaded > embedded.
|
|
* Returns the track's backend index (track.index) or -1 if no match.
|
|
*/
|
|
export function findPreferredSubtitleIndex(tracks: PlayerSubtitleInfo[], language: string): number {
|
|
let bestIdx = -1;
|
|
let bestPriority = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
if (!track || !sameLanguage(track, language)) continue;
|
|
const priority = trackPriority(track);
|
|
if (priority < bestPriority) {
|
|
bestPriority = priority;
|
|
bestIdx = track.index;
|
|
}
|
|
}
|
|
|
|
return bestIdx;
|
|
}
|
|
|
|
function findPreferredSubtitleIndexWithSignature(
|
|
tracks: PlayerSubtitleInfo[],
|
|
language: string,
|
|
signature: PlayerSubtitleTrackSignature | null,
|
|
): number {
|
|
let bestTrack: PlayerSubtitleInfo | null = null;
|
|
let bestScore = -1;
|
|
let bestPriority = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
if (!track || !sameLanguage(track, language)) continue;
|
|
const priority = trackPriority(track);
|
|
const score = scoreSignatureFallback(track, signature);
|
|
if (
|
|
bestTrack === null ||
|
|
score > bestScore ||
|
|
(score === bestScore && priority < bestPriority)
|
|
) {
|
|
bestTrack = track;
|
|
bestScore = score;
|
|
bestPriority = priority;
|
|
}
|
|
}
|
|
|
|
return bestTrack?.index ?? -1;
|
|
}
|
|
|
|
export interface SubtitleAutoSelectOptions {
|
|
mode: SubtitleMode;
|
|
tracks: PlayerSubtitleInfo[];
|
|
preferredLanguage: string | null;
|
|
preferredTrackSignature?: PlayerSubtitleTrackSignature | null;
|
|
audioLanguage: string | null;
|
|
profileLanguage: string | null;
|
|
showForcedSubtitles: boolean;
|
|
}
|
|
|
|
function findForcedSubtitleIndex(
|
|
tracks: PlayerSubtitleInfo[],
|
|
language: string | null | undefined,
|
|
): number | null {
|
|
if (!language) return null;
|
|
const match = findPreferredSubtitleIndex(
|
|
tracks.filter((track) => track.forced),
|
|
language,
|
|
);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
/**
|
|
* Determines which subtitle track to auto-select on playback start.
|
|
* Returns the track's backend index, or null if no track should be selected.
|
|
*/
|
|
export function resolveSubtitleAutoSelect(options: SubtitleAutoSelectOptions): number | null {
|
|
const {
|
|
mode,
|
|
tracks,
|
|
preferredLanguage,
|
|
preferredTrackSignature,
|
|
audioLanguage,
|
|
profileLanguage,
|
|
showForcedSubtitles,
|
|
} = options;
|
|
const signature = preferredTrackSignature ?? null;
|
|
|
|
if (tracks.length === 0) return null;
|
|
const preferredSubtitleLang = normalizeConcreteLanguage(preferredLanguage);
|
|
const normalizedProfileLanguage = normalize(profileLanguage);
|
|
const effectiveProfileLang =
|
|
normalizeConcreteLanguage(profileLanguage) ??
|
|
(normalizedProfileLanguage === ORIGINAL_LANGUAGE_SENTINEL
|
|
? preferredSubtitleLang
|
|
: normalizedProfileLanguage === ""
|
|
? "en"
|
|
: null);
|
|
const effectiveAudioLang = normalizeConcreteLanguage(audioLanguage) ?? effectiveProfileLang;
|
|
|
|
switch (mode) {
|
|
case "off":
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
|
|
case "always": {
|
|
const exactMatch = findExactSubtitleSignatureMatch(tracks, signature);
|
|
if (exactMatch !== null) return exactMatch;
|
|
if (!preferredLanguage) return null;
|
|
const match = findPreferredSubtitleIndexWithSignature(tracks, preferredLanguage, signature);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
case "auto": {
|
|
if (preferredLanguage === "") return null;
|
|
if (effectiveProfileLang && sameLanguageCode(effectiveAudioLang, effectiveProfileLang)) {
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
}
|
|
const lang = preferredSubtitleLang ?? effectiveProfileLang;
|
|
if (!lang) {
|
|
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
|
|
}
|
|
const match = findPreferredSubtitleIndexWithSignature(tracks, lang, signature);
|
|
return match >= 0 ? match : null;
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|