Skip to content

Commit 0e25af2

Browse files
feat(engine): rank subtitle-language pick by disposition, surface forced/SDH/commentary on TrackInfo (#73)
LoadOptions.preferredSubtitleLanguages (4.4.0) picked the first track in a matching language. It now picks the best track within the first matching preference: full subtitles rank over SDH / forced / commentary (from container dispositions) and text over bitmap. New TrackInfo.isForced / isHearingImpaired / isCommentary surface those dispositions (read alongside the existing isDefault) so hosts can rank or filter subtitleTracks the same way; pure selectSubtitleIndex + subtitlePickRank + isBitmapSubtitleCodec are exposed and unit-tested. Also fixes a pre-existing bitmap-classification bug in the native-subtitle rendition (#55): two sites matched TrackInfo.codec (the libavcodec DECODER name, e.g. pgssub) against an exact-match Set of DESCRIPTOR names (hdmv_pgs_subtitle, ...), so PGS / DVB / DVD bitmap tracks leaked into the mov_text trak table and the store-attach set (only xsub matched by luck), producing phantom native-menu entries and a store/reader index mismatch when prepareNativeSubtitles was set. Both sites now use the shared decoder-name isBitmapSubtitleCodec classifier, matching the reader's enum classifier. 193 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4hBXf5yHPUNxQsDavBKGk
1 parent 56ffc7c commit 0e25af2

8 files changed

Lines changed: 140 additions & 23 deletions

File tree

Sources/AetherEngine/AetherEngine+Loading.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,10 @@ extension AetherEngine {
249249
}
250250
}
251251
// prepareNativeSubtitles + non-bitmap text tracks: flag gates allocateMuxer SubtitleConfig; must be set before start() (#55).
252-
// TrackInfo.codec is the lower-case libavcodec name (e.g. "subrip", "ass"). Bitmap codecs excluded:
253-
let bitmapCodecNames: Set<String> = [
254-
"hdmv_pgs_subtitle", "dvb_subtitle", "dvd_subtitle", "xsub"
255-
]
256252
// Each text track becomes one mov_text track in the init moov (#55, all-tracks). Sidecar entries append at runtime; this table is embedded-only.
257-
let textTracks = subtitleTracks.filter { !bitmapCodecNames.contains($0.codec) }
253+
// Bitmap codecs excluded via the shared decoder-name classifier (a prior exact-match Set used descriptor
254+
// names that never matched TrackInfo.codec's decoder names, so PGS/DVB/DVD leaked in as mov_text).
255+
let textTracks = subtitleTracks.filter { !Self.isBitmapSubtitleCodec($0.codec) }
258256
nativeSubtitleTrackTable = textTracks.map { track in
259257
NativeSubtitleTrackEntry(sourceStreamIndex: track.id, language: track.language)
260258
}

Sources/AetherEngine/AetherEngine+Probe.swift

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,23 +258,55 @@ extension AetherEngine {
258258
return nil
259259
}
260260

261-
/// Resolve the subtitle track to auto-activate from `LoadOptions.preferredSubtitleLanguages`: the first
262-
/// track whose language matches a preference (preferences scanned in order), else nil. Unlike audio there
263-
/// is no explicit index override and no default fallback: nil means "keep subtitles off" (the default).
264-
/// Pure and nonisolated; the engine calls this at the end of a successful load and, on a hit, activates
265-
/// the track via the host-overlay path so the host need not language-match `subtitleTracks` itself (#73).
261+
/// Resolve the subtitle track to auto-activate from `LoadOptions.preferredSubtitleLanguages`: within the
262+
/// first preference (scanned in order) that has any language match, the best-ranked track by
263+
/// `subtitlePickRank`; else nil. Preference order dominates rank, so an earlier preference always beats a
264+
/// later one. Unlike audio there is no explicit index override and no default fallback: nil means "keep
265+
/// subtitles off" (the default). Pure and nonisolated; the engine calls this at the end of a successful
266+
/// load and, on a hit, activates the track via the host-overlay path so a host without container metadata
267+
/// of its own need not language-match and rank `subtitleTracks` itself (#73).
266268
nonisolated static func selectSubtitleIndex(
267269
tracks: [TrackInfo],
268270
preferredLanguages: [String]
269271
) -> Int32? {
270272
for preferred in preferredLanguages {
271-
if let match = tracks.first(where: { languageMatches($0.language, preferred) }) {
272-
return Int32(match.id)
273+
let matches = tracks.filter { languageMatches($0.language, preferred) }
274+
// min(by:) is stable, so equal-rank ties keep container order.
275+
if let best = matches.min(by: { subtitlePickRank($0) < subtitlePickRank($1) }) {
276+
return Int32(best.id)
273277
}
274278
}
275279
return nil
276280
}
277281

282+
/// Lower rank wins. The descriptor axis (full > SDH > forced > commentary, from container dispositions)
283+
/// dominates; text-vs-bitmap is a tiebreaker (text preferred, since host styling only applies to text
284+
/// cues). Sourced from `TrackInfo` disposition flags rather than title strings, so it is locale-robust.
285+
/// Used by `selectSubtitleIndex` and exposed so a host can rank `subtitleTracks` the same way (#73).
286+
nonisolated static func subtitlePickRank(_ track: TrackInfo) -> Int {
287+
let descriptorRank: Int
288+
if track.isCommentary {
289+
descriptorRank = 3
290+
} else if track.isForced {
291+
descriptorRank = 2
292+
} else if track.isHearingImpaired {
293+
descriptorRank = 1
294+
} else {
295+
descriptorRank = 0
296+
}
297+
return descriptorRank * 2 + (isBitmapSubtitleCodec(track.codec) ? 1 : 0)
298+
}
299+
300+
/// True when the codec is a bitmap (image) subtitle. `TrackInfo.codec` is the libavcodec DECODER name
301+
/// (pgssub / dvdsub / dvbsub / xsub), not the descriptor name (hdmv_pgs_subtitle / dvb_subtitle / ...);
302+
/// matched case-insensitively by substring so either form is tolerated. Bitmap subs cannot mux into
303+
/// mov_text, so the native-subtitle rendition (#55) excludes them and only the host overlay renders them.
304+
nonisolated static func isBitmapSubtitleCodec(_ codec: String) -> Bool {
305+
let c = codec.lowercased()
306+
return ["pgs", "hdmv", "dvb_sub", "dvbsub", "dvd_sub", "dvdsub", "vobsub", "xsub"]
307+
.contains(where: { c.contains($0) })
308+
}
309+
278310
/// Case-insensitive language match across ISO 639-1 / 639-2 (B and T) / English name, e.g.
279311
/// `"en" == "eng" == "english"`, `"de" == "deu" == "ger"`. Empty / nil track language never matches.
280312
/// Shared by audio (#72) and subtitle (#73) language selection. Pure and unit-tested.

Sources/AetherEngine/AetherEngine+Subtitles.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ extension AetherEngine {
7474
startEmbeddedSubtitleTask(url: url, reader: customClone, formatHint: customFormatHint, streamIndex: Int32(index), startAt: startAt)
7575
}
7676

77-
/// Apply `LoadOptions.preferredSubtitleLanguages` at the end of a successful load: activate the first
78-
/// subtitle track whose language matches a preference (scanned in order), else leave subtitles off (the
79-
/// default). Uses the host-overlay path (equivalent to a `selectSubtitleTrack` call); `startAnchor` is the
77+
/// Apply `LoadOptions.preferredSubtitleLanguages` at the end of a successful load: activate the best-ranked
78+
/// subtitle track whose language matches a preference (scanned in order; see `selectSubtitleIndex`), else
79+
/// leave subtitles off (the default). Uses the host-overlay path (equivalent to a `selectSubtitleTrack`
80+
/// call); `startAnchor` is the
8081
/// load's resume position so a mid-file resume seeks the side demuxer to the playhead instead of byte 0.
8182
/// A no-op when the list is empty, no track matches, or the host already activated a subtitle. The resolved
8283
/// index is published via `activeSubtitleTrackIndex`. Independent of `prepareNativeSubtitles`. (#73)

Sources/AetherEngine/Demuxer/Demuxer.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,11 @@ public final class Demuxer: @unchecked Sendable {
416416
name = "Track \(index) (\(codecName))"
417417
}
418418

419-
let isDefault = (stream.pointee.disposition & AV_DISPOSITION_DEFAULT) != 0
419+
let disposition = stream.pointee.disposition
420+
let isDefault = (disposition & AV_DISPOSITION_DEFAULT) != 0
421+
let isForced = (disposition & AV_DISPOSITION_FORCED) != 0
422+
let isHearingImpaired = (disposition & AV_DISPOSITION_HEARING_IMPAIRED) != 0
423+
let isCommentary = (disposition & AV_DISPOSITION_COMMENT) != 0
420424
let channels = Int(codecpar.pointee.ch_layout.nb_channels)
421425

422426
// EAC3 profile 30 = JOC (Dolby Atmos on streaming). Lets UI label "Atmos".
@@ -444,6 +448,9 @@ public final class Demuxer: @unchecked Sendable {
444448
language: language,
445449
channels: channels,
446450
isDefault: isDefault,
451+
isForced: isForced,
452+
isHearingImpaired: isHearingImpaired,
453+
isCommentary: isCommentary,
447454
isAtmos: isAtmos,
448455
assHeader: assHeader
449456
)

Sources/AetherEngine/PlayerState.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ public struct LoadOptions: Sendable, Equatable {
9898
public var preferredAudioLanguages: [String]
9999

100100
/// Ordered subtitle-language preference (ISO 639-1 / 639-2 codes or English names, e.g. `["en", "de"]`).
101-
/// When non-empty, at the end of a successful load the engine activates the first subtitle track whose
101+
/// When non-empty, at the end of a successful load the engine activates the best subtitle track whose
102102
/// language matches a preference (preferences scanned in order, case-insensitive, ISO 639-1/2 B+T and
103-
/// English-name synonyms); no match leaves subtitles OFF (the default). This drives the host-overlay
103+
/// English-name synonyms; within the matched preference, full subtitles rank over SDH / forced /
104+
/// commentary and text over bitmap, from container dispositions); no match leaves subtitles OFF (the
105+
/// default). This drives the host-overlay
104106
/// path (`subtitleCues`, equivalent to a `selectSubtitleTrack` call) and publishes the resolved track
105107
/// via `activeSubtitleTrackIndex`. Where `preferredAudioLanguages` saves a real cost (its track is muxed
106108
/// into the loopback HLS at the first frame, so a late pick forces a pre-probe or reload), this is pure
@@ -276,19 +278,29 @@ public struct TrackInfo: Identifiable, Sendable, Equatable {
276278
/// 2=stereo, 6=5.1, 8=7.1. 0 for non-audio.
277279
public let channels: Int
278280
public let isDefault: Bool
281+
/// Container disposition `FORCED` (subtitles meant to show without the user enabling subtitles, e.g.
282+
/// foreign-dialogue or signs tracks). Drives the subtitle-language ranking in `selectSubtitleIndex`.
283+
public let isForced: Bool
284+
/// Container disposition `HEARING_IMPAIRED` (SDH / closed-caption tracks with sound descriptions).
285+
public let isHearingImpaired: Bool
286+
/// Container disposition `COMMENT` (director / cast commentary tracks). Applies to audio and subtitle.
287+
public let isCommentary: Bool
279288
/// EAC3 with JOC profile (Dolby Atmos). Lets the UI surface "Atmos" instead of the bed channel count (typically 5.1).
280289
public let isAtmos: Bool
281290

282291
/// ASS / SSA tracks only: `[Script Info]` + `[V4+ Styles]` + `[Events]` format line from codec extradata. Hosts rendering ASS styling themselves (see `LoadOptions.preserveASSMarkup`) need it to resolve style references. nil for all other track kinds.
283292
public let assHeader: String?
284293

285-
public init(id: Int, name: String, codec: String, language: String?, channels: Int = 0, isDefault: Bool, isAtmos: Bool = false, assHeader: String? = nil) {
294+
public init(id: Int, name: String, codec: String, language: String?, channels: Int = 0, isDefault: Bool, isForced: Bool = false, isHearingImpaired: Bool = false, isCommentary: Bool = false, isAtmos: Bool = false, assHeader: String? = nil) {
286295
self.id = id
287296
self.name = name
288297
self.codec = codec
289298
self.language = language
290299
self.channels = channels
291300
self.isDefault = isDefault
301+
self.isForced = isForced
302+
self.isHearingImpaired = isHearingImpaired
303+
self.isCommentary = isCommentary
292304
self.isAtmos = isAtmos
293305
self.assHeader = assHeader
294306
}

Sources/AetherEngine/Video/HLSVideoEngine.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,9 @@ public final class HLSVideoEngine: @unchecked Sendable {
132132
/// Call after `start()`. Returns per-track languages for logging.
133133
@discardableResult
134134
public func attachAllNativeSubtitleStores() -> [String?] {
135-
let bitmap: Set<String> = ["hdmv_pgs_subtitle", "dvb_subtitle", "dvd_subtitle", "xsub"]
136-
let text = (demuxer?.subtitleTrackInfos() ?? []).filter { !bitmap.contains($0.codec) }
135+
// Decoder-name classifier: an exact-match Set of descriptor names here never matched TrackInfo.codec
136+
// (the libavcodec decoder name), so bitmap tracks leaked into the native mov_text store set.
137+
let text = (demuxer?.subtitleTrackInfos() ?? []).filter { !AetherEngine.isBitmapSubtitleCodec($0.codec) }
137138
let languages = text.map { $0.language }
138139
attachNativeSubtitleStores(count: text.count, languages: languages)
139140
return languages

Tests/AetherEngineTests/SubtitleLanguageSelectionTests.swift

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import Foundation
99
/// resolution in isolation.
1010
struct SubtitleLanguageSelectionTests {
1111

12-
private func track(_ id: Int, _ lang: String?) -> TrackInfo {
13-
TrackInfo(id: id, name: "s\(id)", codec: "subrip", language: lang, channels: 0, isDefault: false)
12+
private func track(_ id: Int, _ lang: String?, codec: String = "subrip",
13+
forced: Bool = false, sdh: Bool = false, commentary: Bool = false) -> TrackInfo {
14+
TrackInfo(id: id, name: "s\(id)", codec: codec, language: lang, channels: 0,
15+
isDefault: false, isForced: forced, isHearingImpaired: sdh, isCommentary: commentary)
1416
}
1517

1618
@Test("first matching preference selects its track")
@@ -67,4 +69,59 @@ struct SubtitleLanguageSelectionTests {
6769
#expect(AetherEngine.selectSubtitleIndex(
6870
tracks: tracks, preferredLanguages: ["en"]) == nil)
6971
}
72+
73+
@Test("within a language, a full track beats forced / SDH / commentary")
74+
func fullBeatsDescriptors() {
75+
// Full track is last, so this also proves rank beats container order within the matched language.
76+
let forced = [track(0, "en", forced: true), track(1, "en")]
77+
#expect(AetherEngine.selectSubtitleIndex(tracks: forced, preferredLanguages: ["en"]) == 1)
78+
let sdh = [track(0, "en", sdh: true), track(1, "en")]
79+
#expect(AetherEngine.selectSubtitleIndex(tracks: sdh, preferredLanguages: ["en"]) == 1)
80+
let commentary = [track(0, "en", commentary: true), track(1, "en")]
81+
#expect(AetherEngine.selectSubtitleIndex(tracks: commentary, preferredLanguages: ["en"]) == 1)
82+
}
83+
84+
@Test("descriptor ranking is full > SDH > forced > commentary")
85+
func descriptorOrdering() {
86+
#expect(AetherEngine.subtitlePickRank(track(0, "en")) <
87+
AetherEngine.subtitlePickRank(track(1, "en", sdh: true)))
88+
#expect(AetherEngine.subtitlePickRank(track(0, "en", sdh: true)) <
89+
AetherEngine.subtitlePickRank(track(1, "en", forced: true)))
90+
#expect(AetherEngine.subtitlePickRank(track(0, "en", forced: true)) <
91+
AetherEngine.subtitlePickRank(track(1, "en", commentary: true)))
92+
}
93+
94+
@Test("at equal descriptor rank, text beats bitmap")
95+
func textBeatsBitmap() {
96+
let tracks = [track(0, "en", codec: "hdmv_pgs_subtitle"), track(1, "en", codec: "subrip")]
97+
#expect(AetherEngine.selectSubtitleIndex(tracks: tracks, preferredLanguages: ["en"]) == 1)
98+
// But a full bitmap still beats a forced text track (descriptor dominates the codec tiebreaker).
99+
let mixed = [track(0, "en", codec: "subrip", forced: true), track(1, "en", codec: "hdmv_pgs_subtitle")]
100+
#expect(AetherEngine.selectSubtitleIndex(tracks: mixed, preferredLanguages: ["en"]) == 1)
101+
}
102+
103+
@Test("preference order dominates rank")
104+
func preferenceOrderDominatesRank() {
105+
// en is only available forced; de is a full track. en is the earlier preference, so en wins
106+
// despite ranking lower, because preference order is the outer loop.
107+
let tracks = [track(0, "en", forced: true), track(1, "de")]
108+
#expect(AetherEngine.selectSubtitleIndex(
109+
tracks: tracks, preferredLanguages: ["en", "de"]) == 0)
110+
}
111+
112+
@Test("bitmap classification matches libavcodec DECODER names, not descriptor names")
113+
func bitmapCodecClassification() {
114+
// TrackInfo.codec carries the decoder name; these are what the demuxer actually emits.
115+
for c in ["pgssub", "dvdsub", "dvbsub", "xsub", "PGSSUB"] {
116+
#expect(AetherEngine.isBitmapSubtitleCodec(c), "\(c) should be bitmap")
117+
}
118+
// Descriptor-style names are tolerated defensively.
119+
for c in ["hdmv_pgs_subtitle", "dvb_subtitle", "dvd_subtitle"] {
120+
#expect(AetherEngine.isBitmapSubtitleCodec(c), "\(c) should be bitmap")
121+
}
122+
// Text codecs are never bitmap.
123+
for c in ["subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text"] {
124+
#expect(!AetherEngine.isBitmapSubtitleCodec(c), "\(c) should not be bitmap")
125+
}
126+
}
70127
}

docs/formats.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ Subtitle packets are routed through the same demux loop as audio and video. No s
7070
- **Bitmap codecs** (PGS / HDMV PGS / DVB / DVD) → `.image(SubtitleImage)`. The indexed pixel plane is walked through its palette, premultiplied against alpha, and wrapped as a `CGImage`. Position is normalised in `[0..1]` against the source video frame so the host scales to any on-screen rect.
7171
- **Sidecar files** (a separate `.srt` / `.ass` / `.vtt` URL) → `selectSidecarSubtitle(url:httpHeaders:)` opens its own short-lived `AVFormatContext`, decodes the whole file once, atomically swaps the result into `subtitleCues`. The fetch forwards the session's `LoadOptions.httpHeaders` by default (WebDAV auth and friends); pass the call's own `httpHeaders` to override per fetch.
7272

73+
### Track selection by language preference
74+
75+
`LoadOptions` can seed the initial audio and subtitle tracks from an ordered language preference, resolved from the engine's single probe so a host honors a saved preference without a separate pre-probe or a post-load reload:
76+
77+
- **`preferredAudioLanguages`** (ordered ISO 639-1 / 639-2 codes or English names, e.g. `["en", "de"]`) picks the first-frame audio track: an explicit `audioSourceStreamIndex` wins, else the first track matching a preference in order, else the container default. The pick is muxed into the loopback HLS, so it is correct on the first frame with no `selectAudioTrack` reload.
78+
- **`preferredSubtitleLanguages`** activates a subtitle at the end of load. Within the first preference that has a match, it picks the *best* track by container disposition: full subtitles rank over SDH (`HEARING_IMPAIRED`), forced, and commentary (`COMMENT`), and text over bitmap. No match leaves subtitles off. It drives the host-overlay path, so unlike audio it needs no reload regardless; it only spares a host from language-matching `subtitleTracks` itself. The native menu (below) keeps its own host-driven default selection via `setNativeSubtitleSelected(track:)`.
79+
80+
Matching is case-insensitive across ISO 639-1, 639-2/B, 639-2/T, and English names (`en` == `eng` == `english`); preference order dominates, so an earlier preference on a later track still wins. The resolved tracks are published on `player.activeAudioTrackIndex` / `player.activeSubtitleTrackIndex` (both match `TrackInfo.id`), and every `TrackInfo` carries `isDefault` / `isForced` / `isHearingImpaired` / `isCommentary` (from container dispositions) so a host can rank or filter the track lists the same way.
81+
7382
### Second simultaneous subtitle track (bilingual)
7483

7584
A second subtitle channel can run alongside the primary for bilingual playback / language learning: `selectSecondarySubtitleTrack(index:)` for an embedded track and `selectSecondarySidecarSubtitle(url:httpHeaders:)` for a sidecar file, mirroring the primary API. Its cues land in a separate `@Published secondarySubtitleCues` list (so the host can render the two channels independently, e.g. top vs bottom), with `isSecondarySubtitleActive` and `isLoadingSecondarySubtitles` for UI state; `clearSecondarySubtitle()` tears it down. The secondary channel decodes through the same demux loop and PTS rules as the primary.

0 commit comments

Comments
 (0)