Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Added

- **`ExternalSubtitleTrack.sourceStreamIndex`, so an external subtitle URL can be a container rather than a sidecar.** An external track's URL may hold several subtitle streams (an MKV with English, English SDH and Spanish), and a host would register one track per stream against that same URL. The sidecar decoder stopped at the container's first subtitle stream and the descriptor carried no index, so every such track decoded that same stream and the host got three selectable tracks rendering identical cues. The new field names the stream to decode as an absolute `AVStream` index inside the container, matching the convention that embedded track ids are stream indices; nil keeps decoding the first subtitle stream. An index that is out of range or names a non-subtitle stream fails the decode rather than falling back to the first subtitle stream, because a silent fallback is indistinguishable from the behaviour the index exists to escape. Reported by edde746. (#266)

### Changed

- **External tracks sharing a container are now filled from a single pass over it.** Each load-declared external track used to be decoded by its own whole-file read, so three tracks pointing at one MKV meant three full downloads at load, and `AVDISCARD_ALL` cannot shorten them (a Matroska demuxer reads every discarded byte anyway). Tracks sharing a URL and headers are now decoded together in one pass, one stream decoder per requested stream. Since a pass covering several streams fails as a whole, a failure retries the targets individually, so one host-side index mistake cannot blank the container's other tracks; a store that still could not be filled stays unfinished rather than serving a complete but blank rendition.

## [6.2.1] - 2026-07-30

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ let track = player.addExternalSubtitleTrack(
player.selectSubtitleTrack(index: track.id)
// Declared at load instead, external tracks also join the native WebVTT renditions (PiP):
// LoadOptions(prepareNativeSubtitles: true, externalSubtitles: [ExternalSubtitleTrack(url: srtURL, language: "en")])
// When the URL is a container holding several subtitle streams, register one track per stream
// with its absolute AVStream index; tracks sharing a URL are decoded in one pass (#266).
ExternalSubtitleTrack(url: mkvURL, name: "Spanish", language: "es", sourceStreamIndex: 3)

// Native WebVTT subtitle renditions (subtitles in PiP / AirPlay / external display; opt-in
// via LoadOptions.prepareNativeSubtitles, details in docs/formats.md)
Expand Down
108 changes: 84 additions & 24 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ extension AetherEngine {
clearSubtitleDrainTarget(channel: .secondary) // #112 rework
activeSecondaryEmbeddedSubtitleStreamIndex = -1
activeSecondaryExternalSubtitleTrackID = index
startSecondarySidecarDecode(url: external.url, httpHeaders: external.httpHeaders)
startSecondarySidecarDecode(url: external.url, httpHeaders: external.httpHeaders,
sourceStreamIndex: external.sourceStreamIndex)
return
}
guard index < Self.externalSubtitleTrackIDBase else { return }
Expand Down Expand Up @@ -873,7 +874,8 @@ extension AetherEngine {
EngineLog.emit("[AetherEngine] external subtitle backfilled from finished store: id=\(id) cues=\(subtitleCues.count)", category: .engine)
return
}
startSidecarDecode(url: track.url, httpHeaders: track.httpHeaders, externalTrackID: id)
startSidecarDecode(url: track.url, httpHeaders: track.httpHeaders, externalTrackID: id,
sourceStreamIndex: track.sourceStreamIndex)
}

/// Store lookup for the external backfill: test-hook override first, else the live session's stores.
Expand All @@ -892,32 +894,54 @@ extension AetherEngine {
func startExternalNativeStoreFill(session: HLSVideoEngine) {
externalNativeStoreFillTask?.cancel()
externalNativeStoreFillTask = nil
var jobs: [(url: URL, headers: [String: String], store: NativeSubtitleCueStore)] = []
for (ordinal, entry) in nativeSubtitleTrackTable.enumerated() {
// Phase D: OCR entries defer to the selection-time sidecar decode (OCR of a whole
// .sup at load would violate the selection gating).
guard !entry.needsOCR,
let extID = entry.externalID,
let track = externalSubtitleRegistry[extID],
ordinal < session.nativeSubtitleCueStoresForSession.count else { continue }
jobs.append((track.url,
track.httpHeaders ?? loadedOptions.httpHeaders,
session.nativeSubtitleCueStoresForSession[ordinal]))
}
let jobs = Self.externalSubtitleFillJobs(
table: nativeSubtitleTrackTable,
registry: externalSubtitleRegistry,
stores: session.nativeSubtitleCueStoresForSession,
defaultHeaders: loadedOptions.httpHeaders)
guard !jobs.isEmpty else { return }
externalNativeStoreFillTask = Task.detached(priority: .utility) { [jobs] in
for job in jobs {
if Task.isCancelled { return }
if let result = try? await SubtitleDecoder.decodeFile(url: job.url, httpHeaders: job.headers) {
job.store.appendCues(result.cues)
job.store.markFinished()
} else {
EngineLog.emit("[AetherEngine] external native store fill failed: \(job.url.lastPathComponent)", category: .engine)
}
await AetherEngine.runExternalSubtitleFill(job: job)
}
}
}

/// #266: fill one container's stores from a single decode pass. A pass covering several streams
/// fails as a whole (an out-of-range index throws), so on failure the targets are retried
/// individually: one host-side index mistake must not blank the container's other tracks. A
/// store that could not be filled stays UNfinished, or the rendition would serve a complete but
/// blank .vtt.
nonisolated static func runExternalSubtitleFill(job: ExternalSubtitleFillJob) async {
if let results = try? await SubtitleDecoder.decodeFile(
url: job.url, httpHeaders: job.headers,
sourceStreamIndices: job.targets.map(\.streamIndex)
) {
for (target, result) in zip(job.targets, results) {
target.store.appendCues(result.cues)
target.store.markFinished()
}
return
}
guard job.targets.count > 1 else {
EngineLog.emit("[AetherEngine] external native store fill failed: \(job.url.lastPathComponent)", category: .engine)
return
}
EngineLog.emit("[AetherEngine] external native store fill: shared pass over \(job.url.lastPathComponent) failed, retrying \(job.targets.count) targets individually", category: .engine)
for target in job.targets {
if Task.isCancelled { return }
guard let result = try? await SubtitleDecoder.decodeFile(
url: job.url, httpHeaders: job.headers, sourceStreamIndex: target.streamIndex
) else {
EngineLog.emit("[AetherEngine] external native store fill failed: \(job.url.lastPathComponent) stream=\(target.streamIndex.map(String.init) ?? "auto")", category: .engine)
continue
}
target.store.appendCues(result.cues)
target.store.markFinished()
}
}

/// Unregister an external track: delist + drop the registry entry; an active selection
/// (primary or secondary) is cleared. Embedded ids no-op.
public func removeExternalSubtitleTrack(id: Int) {
Expand All @@ -937,7 +961,8 @@ extension AetherEngine {
/// track id (if any) to publish as active. Also clears the pump-tap overlay stream so a prior
/// tap-fed selection stops forwarding into the sidecar's cues (latent pre-#88 bug: the tap
/// forward-guard matched the stale index and kept appending).
func startSidecarDecode(url: URL, httpHeaders: [String: String]?, externalTrackID: Int?) {
func startSidecarDecode(url: URL, httpHeaders: [String: String]?, externalTrackID: Int?,
sourceStreamIndex: Int32? = nil) {
cancelSidecarTask()
// Sidecar replaces any active embedded stream.
clearSubtitleDrainTarget(channel: .primary) // #112 rework
Expand All @@ -959,7 +984,8 @@ extension AetherEngine {
do {
result = try await SubtitleDecoder.decodeFile(
url: url, httpHeaders: effectiveHeaders,
preserveASSMarkup: preserveASS
preserveASSMarkup: preserveASS,
sourceStreamIndex: sourceStreamIndex
)
} catch {
EngineLog.emit("[AetherEngine] sidecar decode failed: \(error)", category: .engine)
Expand Down Expand Up @@ -998,7 +1024,8 @@ extension AetherEngine {
}

/// Shared secondary sidecar-decode start (#88): the pre-#88 selectSecondarySidecarSubtitle body.
func startSecondarySidecarDecode(url: URL, httpHeaders: [String: String]?) {
func startSecondarySidecarDecode(url: URL, httpHeaders: [String: String]?,
sourceStreamIndex: Int32? = nil) {
loadedSecondarySidecarURL = url
isSecondarySubtitleActive = true
secondarySubtitleCues = []
Expand All @@ -1010,7 +1037,8 @@ extension AetherEngine {
let result: SidecarDecodeResult
do {
// Secondary is plain text only (never drives libass, mirroring embedded secondary #47).
result = try await SubtitleDecoder.decodeFile(url: url, httpHeaders: effectiveHeaders)
result = try await SubtitleDecoder.decodeFile(
url: url, httpHeaders: effectiveHeaders, sourceStreamIndex: sourceStreamIndex)
} catch {
EngineLog.emit("[AetherEngine] secondary sidecar decode failed: \(error)", category: .engine)
await MainActor.run {
Expand Down Expand Up @@ -1573,6 +1601,38 @@ extension AetherEngine {
table.firstIndex { $0.sourceStreamIndex == id || $0.externalID == id }
}

/// #266: group the load-declared external tracks into one fill job per container, so a URL
/// backing several tracks (an MKV with three subtitle streams) is read once instead of once per
/// track. Headers are part of the grouping key: differing auth means differing requests.
/// Duplicate registrations of one stream stay separate targets, both stores get the cues.
/// Ordering is by first appearance in the table, so the jobs are deterministic.
nonisolated static func externalSubtitleFillJobs(
table: [NativeSubtitleTrackEntry],
registry: [Int: ExternalSubtitleTrack],
stores: [NativeSubtitleCueStore],
defaultHeaders: [String: String]
) -> [ExternalSubtitleFillJob] {
struct Key: Hashable {
let url: URL
let headers: [String: String]
}
var order: [Key] = []
var targetsByKey: [Key: [ExternalSubtitleFillJob.Target]] = [:]
for (ordinal, entry) in table.enumerated() {
// Phase D: OCR entries defer to the selection-time sidecar decode (OCR of a whole
// .sup at load would violate the selection gating).
guard !entry.needsOCR, let extID = entry.externalID,
let track = registry[extID], ordinal < stores.count else { continue }
let key = Key(url: track.url, headers: track.httpHeaders ?? defaultHeaders)
if targetsByKey[key] == nil { order.append(key) }
targetsByKey[key, default: []].append(
.init(streamIndex: track.sourceStreamIndex, store: stores[ordinal]))
}
return order.map {
ExternalSubtitleFillJob(url: $0.url, headers: $0.headers, targets: targetsByKey[$0] ?? [])
}
}

/// Phase D: bitmap tracks eligible for an OCR-fed rendition. VOD only; embedded entries carry
/// their source stream index (the worker's packet-store key), external .sup entries their
/// synthetic id (the sidecar OCR fill key).
Expand Down
14 changes: 14 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,20 @@ public final class AetherEngine: ObservableObject {
}
var nativeSubtitleTrackTable: [NativeSubtitleTrackEntry] = []

/// #266: one pass over one container, filling every native store whose external track points at
/// it. Tracks that share a URL and headers collapse into a single job, so a container holding
/// several subtitle streams is fetched once rather than once per registered track.
struct ExternalSubtitleFillJob: Sendable {
struct Target: Sendable {
/// Absolute AVStream index in this container, nil for its first subtitle stream.
let streamIndex: Int32?
let store: NativeSubtitleCueStore
}
let url: URL
let headers: [String: String]
let targets: [Target]
}

/// Native WebVTT rendition store for the in-band CEA-608 track (#98). The CC tap feeds it (via
/// `updateClosedCaptionCues`) so 608 captions ride a native AVKit-selectable rendition and
/// survive PiP / AirPlay, not just the overlay. Nil when there is no 608 track or native
Expand Down
Loading
Loading