From 58cc7eeb45b2f6e3d0bc47f68c1f53285adbfff9 Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 12:35:16 +0800 Subject: [PATCH 1/2] fix(subtitles): preserve secondary sidecar ASS metadata --- CHANGELOG.md | 2 + .../AetherEngine/AetherEngine+Subtitles.swift | 9 +- Sources/AetherEngine/AetherEngine.swift | 5 + .../SidecarASSMarkupTests.swift | 133 ++++++++++++++++++ docs/api.md | 3 +- 5 files changed, 149 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f69ff13..934da2ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ the public-API contract. ### Added +- Secondary ASS sidecars honor `preserveASSMarkup` and publish their own `secondarySidecarASSHeader`, independently cleared with the channel and rebuilt on reload. Default text/rich-text output is unchanged. + - **`LiveTelemetry.objectAudio`: how many Atmos objects play, and how many overhead.** While `objectAudioRendering` plays TrueHD Atmos or DTS:X (a build with the `TrueHDObjectAudio` / `DTSXObjectAudio` traits; without them it stays nil), the 1 Hz snapshot carries an diff --git a/Sources/AetherEngine/AetherEngine+Subtitles.swift b/Sources/AetherEngine/AetherEngine+Subtitles.swift index 02ab63cbf..c5cb9fd50 100644 --- a/Sources/AetherEngine/AetherEngine+Subtitles.swift +++ b/Sources/AetherEngine/AetherEngine+Subtitles.swift @@ -196,6 +196,7 @@ extension AetherEngine { // own channel; the immediate tick backfills it. isSecondarySubtitleActive = true secondarySubtitleCues = [] + secondarySidecarASSHeader = nil pgsStaleArrivalGates[.secondary]?.reset() // #100 activeSecondaryEmbeddedSubtitleStreamIndex = Int32(index) subtitleDrainTargets[.secondary] = Int32(index) @@ -1609,16 +1610,18 @@ extension AetherEngine { loadedSecondarySidecarURL = url isSecondarySubtitleActive = true secondarySubtitleCues = [] + secondarySidecarASSHeader = nil pgsStaleArrivalGates[.secondary]?.reset() // #100 isLoadingSecondarySubtitles = true let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders + let preserveASS = loadedOptions.preserveASSMarkup secondarySidecarTask = Task { [weak self] in 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, sourceStreamIndex: sourceStreamIndex) + url: url, httpHeaders: effectiveHeaders, preserveASSMarkup: preserveASS, + sourceStreamIndex: sourceStreamIndex) } catch { EngineLog.emit("[AetherEngine] secondary sidecar decode failed: \(error)", category: .engine) await MainActor.run { @@ -1630,6 +1633,7 @@ extension AetherEngine { await MainActor.run { guard !Task.isCancelled, let self = self else { return } guard self.isSecondarySubtitleActive else { return } + self.secondarySidecarASSHeader = result.assHeader self.secondarySubtitleCues = result.cues self.isLoadingSecondarySubtitles = false } @@ -1714,6 +1718,7 @@ extension AetherEngine { loadedSecondarySidecarURL = nil isSecondarySubtitleActive = false secondarySubtitleCues = [] + secondarySidecarASSHeader = nil pgsStaleArrivalGates[.secondary]?.reset() // #100 isLoadingSecondarySubtitles = false } diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index bdbf1a191..d6cb20705 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -1415,6 +1415,10 @@ public final class AetherEngine: ObservableObject { /// Nil for SRT/VTT and when markup preservation is off. @Published public internal(set) var sidecarASSHeader: String? = nil + /// ASS script header for the active secondary sidecar when preserveASSMarkup is enabled. + /// Independent of the primary header; nil for other formats and after clearing or stopping. + @Published public internal(set) var secondarySidecarASSHeader: String? = nil + /// Cues for the secondary subtitle track (#47). Text-only (bitmap rejected); independent of primary. @Published public internal(set) var secondarySubtitleCues: [SubtitleCue] = [] @Published public internal(set) var isLoadingSecondarySubtitles: Bool = false @@ -7819,6 +7823,7 @@ public final class AetherEngine: ObservableObject { subtitleCues = [] pgsStaleArrivalGates = [:] // #100: both channels; a hold never survives the session sidecarASSHeader = nil + secondarySidecarASSHeader = nil isLoadingSubtitles = false nativeSubtitleTrackTable = [] nativeSubtitleReapplyOrdinal = nil diff --git a/Tests/AetherEngineTests/SidecarASSMarkupTests.swift b/Tests/AetherEngineTests/SidecarASSMarkupTests.swift index 042e1ceb9..6a75d8071 100644 --- a/Tests/AetherEngineTests/SidecarASSMarkupTests.swift +++ b/Tests/AetherEngineTests/SidecarASSMarkupTests.swift @@ -1,4 +1,6 @@ import XCTest +import Testing +import Foundation @testable import AetherEngine /// Covers AetherEngine#48: a sidecar ASS file decoded under @@ -82,3 +84,134 @@ final class SidecarASSMarkupTests: XCTestCase { XCTAssertEqual(second, "Second line") } } + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct SecondarySidecarASSMarkupTests { + @Test("secondary ASS preserves raw events and its own script header when requested") + func secondaryASSPreservesMarkupAndHeader() async throws { + let url = try MultiASSPlayResFixture.write() + defer { try? FileManager.default.removeItem(at: url) } + let engine = try AetherEngine() + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + let primary = engine.addExternalSubtitleTrack(ExternalSubtitleTrack( + url: url, name: "English ASS", language: "eng", formatHint: "ass", + sourceStreamIndex: MultiASSPlayResFixture.englishStreamIndex)) + let secondary = engine.addExternalSubtitleTrack(ExternalSubtitleTrack( + url: url, name: "Spanish ASS", language: "spa", formatHint: "ass", + sourceStreamIndex: MultiASSPlayResFixture.spanishStreamIndex)) + + engine.selectSubtitleTrack(index: primary.id) + engine.selectSecondarySubtitleTrack(index: secondary.id) + await engine.sidecarTask?.value + await engine.secondarySidecarTask?.value + + #expect(engine.sidecarASSHeader?.contains("PlayResX: 640") == true) + #expect(engine.secondarySidecarASSHeader?.contains("PlayResX: 1920") == true) + guard case .text(let primaryRaw) = try #require(engine.subtitleCues.first).body, + case .text(let secondaryRaw) = try #require(engine.secondarySubtitleCues.first).body + else { + Issue.record("preserved ASS cues must publish raw event lines on both channels") + return + } + #expect(primaryRaw.contains(#"{\pos(320,240)}"#)) + #expect(secondaryRaw.contains(#"{\pos(320,240)}"#)) + + engine.loadedURL = URL(string: "https://media.example/movie.mkv")! + engine.selectSecondarySubtitleTrack(index: 3) + #expect(engine.secondarySidecarASSHeader == nil) + } + + + @Test("default secondary ASS output stays headerless") + func defaultOutput() async throws { + let url = try MultiASSPlayResFixture.write() + defer { try? FileManager.default.removeItem(at: url) } + let engine = try AetherEngine() + defer { engine.clearSecondarySubtitle() } + engine.selectSecondarySidecarSubtitle(url: url) + await engine.secondarySidecarTask?.value + #expect(!engine.secondarySubtitleCues.isEmpty) + #expect(engine.secondarySidecarASSHeader == nil) + #expect(engine.secondarySubtitleCues.allSatisfy { !($0.text ?? "").contains("{\\pos") }) + } + + @Test("clear, stop and cancelled decode cannot leave secondary ASS metadata behind") + func lifecycle() async throws { + let url = try MultiASSPlayResFixture.write() + defer { try? FileManager.default.removeItem(at: url) } + let engine = try AetherEngine() + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + engine.selectSecondarySidecarSubtitle(url: url) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader != nil) + engine.clearSubtitle() + #expect(engine.secondarySidecarASSHeader != nil) + engine.clearSecondarySubtitle() + #expect(engine.secondarySidecarASSHeader == nil) + engine.selectSecondarySidecarSubtitle(url: url) + let pending = engine.secondarySidecarTask + engine.clearSecondarySubtitle() + await pending?.value + #expect(engine.secondarySidecarASSHeader == nil) + #expect(engine.secondarySubtitleCues.isEmpty) + engine.selectSecondarySidecarSubtitle(url: url) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader != nil) + engine.stopInternal() + #expect(engine.secondarySidecarASSHeader == nil) + } + + @Test("switching to SubRip removes ASS header without turning its text into ASS") + func switchToSubRip() async throws { + let ass = try MultiASSPlayResFixture.write() + let srt = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".srt") + try "1\n00:00:01,000 --> 00:00:03,000\nHello\n".write(to: srt, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: ass); try? FileManager.default.removeItem(at: srt) } + let engine = try AetherEngine() + defer { engine.clearSecondarySubtitle() } + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + engine.selectSecondarySidecarSubtitle(url: ass) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader != nil) + engine.selectSecondarySidecarSubtitle(url: srt) + #expect(engine.secondarySidecarASSHeader == nil) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader == nil) + #expect(engine.secondarySubtitleCues.compactMap(\.text) == ["Hello"]) + } + + @Test("session carryover restores the secondary ASS header from its sidecar") + func restoreAfterTeardown() async throws { + let url = try MultiASSPlayResFixture.write() + defer { try? FileManager.default.removeItem(at: url) } + let engine = try AetherEngine() + defer { engine.clearSecondarySubtitle() } + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + engine.selectSecondarySidecarSubtitle(url: url) + await engine.secondarySidecarTask?.value + let header = try #require(engine.secondarySidecarASSHeader) + let carryover = engine.captureSubtitleSessionCarryover() + engine.stopInternal() + #expect(engine.secondarySidecarASSHeader == nil) + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + engine.restoreSubtitleSelection(from: carryover, resumeAnchor: 0) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader == header) + #expect(!engine.secondarySubtitleCues.isEmpty) + } + + @Test("preserveASSMarkup does not turn secondary WebVTT into raw ASS") + func webVTTStaysText() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".vtt") + try "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello VTT\n".write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + let engine = try AetherEngine() + defer { engine.clearSecondarySubtitle() } + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: true)) + engine.selectSecondarySidecarSubtitle(url: url) + await engine.secondarySidecarTask?.value + #expect(engine.secondarySidecarASSHeader == nil) + #expect(engine.secondarySubtitleCues.compactMap(\.text) == ["Hello VTT"]) + } +} diff --git a/docs/api.md b/docs/api.md index 16362779c..93542ee8b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -815,7 +815,8 @@ suppressing `AVPlayerItemLegibleOutput` to keep the measurement running. | `$subtitleCues` | `[SubtitleCue]`: `.text`, `.richText([SubtitleTextRun])`, or `.image(SubtitleImage)`, with an optional `SubtitleTextPlacement`. Cues carry raw source PTS. | | `$isSubtitleActive`, `$isLoadingSubtitles` | State for a subtitle toggle: active is the selection, loading is a sidecar or side-demuxer pass in flight. | | `$activeSubtitleTrackIndex` | The selected id, including one resolved by `preferredSubtitleLanguages`. | -| `$sidecarASSHeader` | The ASS header for the active sidecar, for hosts rendering authored styling. | +| `$sidecarASSHeader` | The ASS header for the active primary sidecar, for hosts rendering authored styling. | +| `$secondarySidecarASSHeader` | Independent ASS header for the active secondary sidecar when `preserveASSMarkup` is enabled. Pair with `secondarySubtitleCues`, never the primary header: script resolution and styles can differ. Nil by default, for non-ASS formats, and after selection changes, clearing or stopping. Session-preserving reloads decode it again with the restored track. | | `selectSecondarySubtitleTrack(index:)`, `selectSecondarySidecarSubtitle(url:httpHeaders:)`, `clearSecondarySubtitle()`, `$secondarySubtitleCues`, `$isSecondarySubtitleActive`, `$isLoadingSecondarySubtitles` | The independent second track (bilingual / language learning). | | `addExternalSubtitleTrack(_:)`, `removeExternalSubtitleTrack(id:)` | Register or unregister an `ExternalSubtitleTrack` at runtime. Returns the `TrackInfo` it was listed as. Tracks added after load are overlay-only; declare them in `LoadOptions.externalSubtitles` to have them join the native renditions. | | `selectSidecarSubtitle(url:httpHeaders:)` | One-shot sidecar decode without listing a track. Prefer `addExternalSubtitleTrack` + `selectSubtitleTrack`, which keeps the track listed and `activeSubtitleTrackIndex` populated. nil headers forward `LoadOptions.httpHeaders`. | From e28cb8346802650732fe8ae80fe62336b9ae029e Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 13:11:24 +0800 Subject: [PATCH 2/2] feat(subtitles): prepare and commit external selections transactionally --- CHANGELOG.md | 2 + .../AetherEngine/AetherEngine+Subtitles.swift | 13 +- Sources/AetherEngine/AetherEngine.swift | 2 + .../Decoder/SubtitleDecoder.swift | 95 ++++++- .../ExternalSubtitlePreparationPolicy.swift | 29 ++ .../Subtitles/ExternalSubtitleTrack.swift | 2 + .../Subtitles/PreparedExternalSubtitle.swift | 127 +++++++++ .../PreparedExternalSubtitleTests.swift | 263 ++++++++++++++++++ docs/api.md | 10 + 9 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 Sources/AetherEngine/Subtitles/ExternalSubtitlePreparationPolicy.swift create mode 100644 Sources/AetherEngine/Subtitles/PreparedExternalSubtitle.swift create mode 100644 Tests/AetherEngineTests/PreparedExternalSubtitleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 934da2ddc..ab91a4c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ the public-API contract. ### Added +- Opt-in external subtitle preparation and single-use commit/discard: decode a bounded local text sidecar before changing the active selection. Configurable preparation limits and owner/session/selection-intent checks preserve the previous selection on failure; commit performs no file read. + - Secondary ASS sidecars honor `preserveASSMarkup` and publish their own `secondarySidecarASSHeader`, independently cleared with the channel and rebuilt on reload. Default text/rich-text output is unchanged. - **`LiveTelemetry.objectAudio`: how many Atmos objects play, and how many overhead.** While diff --git a/Sources/AetherEngine/AetherEngine+Subtitles.swift b/Sources/AetherEngine/AetherEngine+Subtitles.swift index c5cb9fd50..31a3de15a 100644 --- a/Sources/AetherEngine/AetherEngine+Subtitles.swift +++ b/Sources/AetherEngine/AetherEngine+Subtitles.swift @@ -44,6 +44,7 @@ extension AetherEngine { /// no xsub decoder, so such a track selects and then decodes to nothing; it is deliberately not /// claimed anywhere in the documentation. public func selectSubtitleTrack(index: Int) { + externalSubtitleIntentGeneration &+= 1 hostExplicitSubtitleAction = true selectSubtitleTrack(index: index, startAt: sourceTime) // Sodalite#156: while the picture is off this device the RENDITION is the display, so a pick @@ -169,6 +170,7 @@ extension AetherEngine { /// Activate an embedded subtitle stream as the secondary companion track (issue #47). Text-only; bitmap codecs are rejected. Runs a second side demuxer concurrently. External ids (#88) route onto the secondary sidecar decode. public func selectSecondarySubtitleTrack(index: Int) { + externalSubtitleIntentGeneration &+= 1 selectSecondarySubtitleTrack(index: index, startAt: sourceTime) } @@ -1507,6 +1509,7 @@ extension AetherEngine { /// 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) { + externalSubtitleIntentGeneration &+= 1 guard externalSubtitleRegistry.removeValue(forKey: id) != nil else { return } subtitleTracks.removeAll { $0.id == id } if activeSubtitleTrackIndex == id { clearSubtitle() } @@ -1515,6 +1518,7 @@ extension AetherEngine { /// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil forwards `LoadOptions.httpHeaders` (same auth as the media, #32). Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use. public func selectSidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) { + externalSubtitleIntentGeneration &+= 1 hostExplicitSubtitleAction = true startSidecarDecode(url: url, httpHeaders: httpHeaders, externalTrackID: nil) } @@ -1549,6 +1553,7 @@ extension AetherEngine { let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders // ASS/SSA sidecars honour preserveASSMarkup so hosts can drive a styled renderer. SRT/VTT fall back to plain text regardless. + let preparationPolicy = externalTrackID.flatMap { externalSubtitleRegistry[$0] }?.preparationPolicy let preserveASS = loadedOptions.preserveASSMarkup sidecarTask = Task { [weak self] in let result: SidecarDecodeResult @@ -1556,7 +1561,7 @@ extension AetherEngine { result = try await SubtitleDecoder.decodeFile( url: url, httpHeaders: effectiveHeaders, preserveASSMarkup: preserveASS, - sourceStreamIndex: sourceStreamIndex + sourceStreamIndex: sourceStreamIndex, preparationPolicy: preparationPolicy ) } catch { EngineLog.emit("[AetherEngine] sidecar decode failed: \(error)", category: .engine) @@ -1596,6 +1601,7 @@ extension AetherEngine { /// Decode a sidecar as the secondary companion track (issue #47), independent of the primary. public func selectSecondarySidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) { + externalSubtitleIntentGeneration &+= 1 hostExplicitSubtitleAction = true cancelSidecarTask(channel: .secondary) clearSubtitleDrainTarget(channel: .secondary, reason: .secondarySidecarSelected) // #112 rework @@ -1616,12 +1622,13 @@ extension AetherEngine { let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders let preserveASS = loadedOptions.preserveASSMarkup + let preparationPolicy = activeSecondaryExternalSubtitleTrackID.flatMap { externalSubtitleRegistry[$0] }?.preparationPolicy secondarySidecarTask = Task { [weak self] in let result: SidecarDecodeResult do { result = try await SubtitleDecoder.decodeFile( url: url, httpHeaders: effectiveHeaders, preserveASSMarkup: preserveASS, - sourceStreamIndex: sourceStreamIndex) + sourceStreamIndex: sourceStreamIndex, preparationPolicy: preparationPolicy) } catch { EngineLog.emit("[AetherEngine] secondary sidecar decode failed: \(error)", category: .engine) await MainActor.run { @@ -1642,6 +1649,7 @@ extension AetherEngine { /// Disable primary subtitles, clear cues, cancel sidecar task + side demuxer, cancel multi-decode reader, clear native mov_text stores (#55, all-tracks). `nativeSubtitleTracks` is NOT cleared: the host needs the list to re-select after an audio/subtitle switch; only `stop()` / `load()` reset it. public func clearSubtitle() { + externalSubtitleIntentGeneration &+= 1 hostExplicitSubtitleAction = true // AE#359: subtitles off ends the rendition poll. The renditions themselves stay listed, only // the fetching stops, so re-selecting the track starts fresh from the current window. @@ -1710,6 +1718,7 @@ extension AetherEngine { /// Turn the secondary subtitle off and clear its cues. Tears down /// the secondary sidecar decode task and the secondary side reader. public func clearSecondarySubtitle() { + externalSubtitleIntentGeneration &+= 1 hostExplicitSubtitleAction = true cancelSidecarTask(channel: .secondary) clearSubtitleDrainTarget(channel: .secondary, reason: .subtitlesCleared) // #112 rework diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index d6cb20705..bb1971e95 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2125,6 +2125,8 @@ public final class AetherEngine: ObservableObject { /// every suspension point. Without it, a load suspended at the probe/criteria/session-start could resume /// after a newer load, orphan the successor's producer+loopback server, and resurrect playback after /// dismissal. A superseded load throws CancellationError at the first checkpoint. + /// Shared primary/secondary selection intent invalidates prepared candidates. + var externalSubtitleIntentGeneration: UInt64 = 0 var loadGeneration: UInt64 = 0 /// Distinct across engine instances and refreshed whenever teardown invalidates preview work. diff --git a/Sources/AetherEngine/Decoder/SubtitleDecoder.swift b/Sources/AetherEngine/Decoder/SubtitleDecoder.swift index 2c2896d47..f9de9b009 100644 --- a/Sources/AetherEngine/Decoder/SubtitleDecoder.swift +++ b/Sources/AetherEngine/Decoder/SubtitleDecoder.swift @@ -11,6 +11,8 @@ enum SubtitleDecoderError: Error, CustomStringConvertible, LocalizedError { /// indistinguishable from the pre-#266 behaviour the index exists to escape. case streamIndexNotSubtitle(index: Int32) case noDecoder + case textLimitExceeded + case unsupportedTextCodec case codecOpenFailed(code: Int32) var description: String { @@ -19,6 +21,8 @@ enum SubtitleDecoderError: Error, CustomStringConvertible, LocalizedError { case .noSubtitleStream: "SubtitleDecoder: file has no subtitle stream" case .streamIndexNotSubtitle(let index): "SubtitleDecoder: stream \(index) is out of range or not a subtitle stream" case .noDecoder: "SubtitleDecoder: no decoder for the subtitle codec" + case .textLimitExceeded: "SubtitleDecoder: text resource limit" + case .unsupportedTextCodec: "SubtitleDecoder: unsupported text codec" case .codecOpenFailed(let code): "SubtitleDecoder: decoder open failed (\(FFmpegErr.text(for: code)))" } } @@ -36,6 +40,18 @@ struct SidecarDecodeResult { /// One-shot decoder for sidecar subtitle files (.srt/.ass/.vtt/.ssa). /// Opens the URL as its own AVFormatContext; sidecars are separate files the main demuxer never sees. enum SubtitleDecoder { + #if DEBUG + private final class ValidationProbe: @unchecked Sendable { + let lock = NSLock() + var observer: (@Sendable (URL) -> Void)? + } + private static let validationProbe = ValidationProbe() + static var preparedFileValidationObserverForTesting: (@Sendable (URL) -> Void)? { + get { validationProbe.lock.withLock { validationProbe.observer } } + set { validationProbe.lock.withLock { validationProbe.observer = newValue } } + } + #endif + /// Decode every cue from the subtitle file at `url`, cancellable via Task.cancel(). /// When preserveASSMarkup is true, ASS/SSA cues carry the raw libavcodec event line @@ -48,11 +64,12 @@ enum SubtitleDecoder { url: URL, httpHeaders: [String: String] = [:], preserveASSMarkup: Bool = false, - sourceStreamIndex: Int32? = nil + sourceStreamIndex: Int32? = nil, + preparationPolicy: ExternalSubtitlePreparationPolicy? = nil ) async throws -> SidecarDecodeResult { let results = try await decode( url: url, httpHeaders: httpHeaders, preserveASSMarkup: preserveASSMarkup, - requested: sourceStreamIndex.map { [$0] } + requested: sourceStreamIndex.map { [$0] }, preparationPolicy: preparationPolicy ) guard let only = results.first else { throw SubtitleDecoderError.noSubtitleStream } return only @@ -81,7 +98,7 @@ enum SubtitleDecoder { url: URL, httpHeaders: [String: String], preserveASSMarkup: Bool, - requested: [Int32?]? + requested: [Int32?]?, preparationPolicy: ExternalSubtitlePreparationPolicy? = nil ) async throws -> [SidecarDecodeResult] { // Task.cancel() does NOT propagate into detached tasks (isCancelled inside always false). // Bridge cancellation explicitly via CancelFlag so the decode loop + AVIO reader abort promptly. @@ -90,7 +107,7 @@ enum SubtitleDecoder { try await BlockingWork.detached(priority: .userInitiated) { try decodeFileSync( url: url, httpHeaders: httpHeaders, - preserveASSMarkup: preserveASSMarkup, requested: requested, cancel: token + preserveASSMarkup: preserveASSMarkup, requested: requested, cancel: token, preparationPolicy: preparationPolicy ) }.value } onCancel: { @@ -140,20 +157,28 @@ enum SubtitleDecoder { let codedWidth: Int let codedHeight: Int + let preparationPolicy: ExternalSubtitlePreparationPolicy? + var textBytes = 0 + var limitExceeded = false + var decodeFailed = false var cues: [SubtitleCue] = [] var nextID = 0 /// Indices of image cues still "open" (PGS-style: ended by the next composition event). var pendingImageCueIndices: [Int] = [] var lastPktPTS: Double = 0 // PTS anchor for flush events that have no packet of their own - init(streamIndex: Int32, stream: UnsafeMutablePointer, preserveASSMarkup: Bool) throws { + init(streamIndex: Int32, stream: UnsafeMutablePointer, preserveASSMarkup: Bool, preparationPolicy: ExternalSubtitlePreparationPolicy? = nil) throws { guard let codecpar = stream.pointee.codecpar else { throw SubtitleDecoderError.streamIndexNotSubtitle(index: streamIndex) } // ASS/SSA script header is in codec extradata (mirrors Demuxer.trackInfo for embedded tracks). // Only surfaced under preserveASSMarkup; the raw event-line path is the only consumer. + self.preparationPolicy = preparationPolicy let codecID = codecpar.pointee.codec_id + if preparationPolicy != nil && ![AV_CODEC_ID_SUBRIP, AV_CODEC_ID_WEBVTT, AV_CODEC_ID_ASS, AV_CODEC_ID_SSA].contains(codecID) { + throw SubtitleDecoderError.unsupportedTextCodec + } let isASS = codecID == AV_CODEC_ID_ASS || codecID == AV_CODEC_ID_SSA let keepMarkup = preserveASSMarkup && isASS var assHeader: String? = nil @@ -215,6 +240,7 @@ enum SubtitleDecoder { var sub = AVSubtitle() var gotSub: Int32 = 0 let ret = avcodec_decode_subtitle2(codecCtx, &sub, &gotSub, pkt) + if preparationPolicy != nil && ret < 0 { decodeFailed = true; return } guard ret >= 0, gotSub != 0 else { return } let pktPTS = pkt.pointee.pts == Int64.min @@ -306,13 +332,14 @@ enum SubtitleDecoder { /// Flush ASS/SSA buffered events (old code decoded one event and discarded it, silently losing the last cue). /// Flushed events have no packet; use lastPktPTS as the timing anchor. func flush(cancel: CancelFlag) { - while !cancel.isCancelled { + while !cancel.isCancelled && !limitExceeded { var flushPkt = AVPacket() flushPkt.data = nil flushPkt.size = 0 var flushSub = AVSubtitle() var gotFlush: Int32 = 0 let flushRet = avcodec_decode_subtitle2(codecCtx, &flushSub, &gotFlush, &flushPkt) + if preparationPolicy != nil && flushRet < 0 && flushRet != -541478725 { decodeFailed = true; break } guard flushRet >= 0, gotFlush != 0 else { break } let startOffset = Double(flushSub.start_display_time) / 1000.0 @@ -343,6 +370,20 @@ enum SubtitleDecoder { private func append(startTime: Double, endTime: Double, body: SubtitleCue.Body, placement: SubtitleTextPlacement?) { + if let preparationPolicy { + let count: Int + switch body { + case .text(let value): count = value.utf8.count + case .richText(let runs): count = runs.reduce(0) { $0 + $1.text.utf8.count } + case .image: limitExceeded = true; return + } + guard !limitExceeded, cues.count < preparationPolicy.maximumCueCount, count <= preparationPolicy.maximumCueBytes, + count <= preparationPolicy.maximumTextBytes - textBytes, + startTime.isFinite, endTime.isFinite, startTime >= 0, endTime > startTime else { + limitExceeded = true; return + } + textBytes += count + } cues.append(SubtitleCue(id: nextID, startTime: startTime, endTime: endTime, body: body, placement: placement)) nextID += 1 @@ -357,8 +398,14 @@ enum SubtitleDecoder { private static func decodeFileSync( url: URL, httpHeaders: [String: String], - preserveASSMarkup: Bool, requested: [Int32?]?, cancel: CancelFlag + preserveASSMarkup: Bool, requested: [Int32?]?, cancel: CancelFlag, preparationPolicy: ExternalSubtitlePreparationPolicy? ) throws -> [SidecarDecodeResult] { + if let preparationPolicy { + #if DEBUG + preparedFileValidationObserverForTesting?(url) + #endif + try preparationPolicy.validateFile(url) + } let isHTTP = url.scheme == "http" || url.scheme == "https" var formatContext: UnsafeMutablePointer? @@ -389,7 +436,23 @@ enum SubtitleDecoder { } else { var ctx: UnsafeMutablePointer? let urlString = url.isFileURL ? url.path : url.absoluteString - let ret = avformat_open_input(&ctx, urlString, nil, nil) + var options: OpaquePointer? + defer { av_dict_free(&options) } + var inputFormat: UnsafePointer? + if preparationPolicy != nil { + guard url.isFileURL else { throw SubtitleDecoderError.unsupportedTextCodec } + let name: String + switch url.pathExtension.lowercased() { + case "srt": name = "srt" + case "vtt": name = "webvtt" + case "ass", "ssa": name = "ass" + default: throw SubtitleDecoderError.unsupportedTextCodec + } + inputFormat = av_find_input_format(name) + guard inputFormat != nil else { throw SubtitleDecoderError.unsupportedTextCodec } + av_dict_set(&options, "protocol_whitelist", "file", 0) + } + let ret = avformat_open_input(&ctx, urlString, inputFormat, &options) guard ret == 0, ctx != nil else { throw SubtitleDecoderError.openFailed(code: ret) } @@ -439,7 +502,7 @@ enum SubtitleDecoder { throw SubtitleDecoderError.streamIndexNotSubtitle(index: index) } decodersByStream[index] = try StreamDecode( - streamIndex: index, stream: stream, preserveASSMarkup: preserveASSMarkup) + streamIndex: index, stream: stream, preserveASSMarkup: preserveASSMarkup, preparationPolicy: preparationPolicy) } while !cancel.isCancelled { @@ -448,6 +511,7 @@ enum SubtitleDecoder { let readRet = av_read_frame(fmt, pkt) if readRet < 0 { trackedPacketFree(&pktPtr) + if preparationPolicy != nil && readRet != -541478725 { throw SubtitleDecoderError.openFailed(code: readRet) } break } @@ -461,9 +525,20 @@ enum SubtitleDecoder { av_packet_unref(pkt) trackedPacketFree(&pktPtr) + if preparationPolicy != nil && decodersByStream.values.contains(where: { $0.decodeFailed }) { + throw SubtitleDecoderError.openFailed(code: -1) + } + if decodersByStream.values.contains(where: { $0.limitExceeded }) { + throw SubtitleDecoderError.textLimitExceeded + } } - for decoder in decodersByStream.values { decoder.flush(cancel: cancel) } + for decoder in decodersByStream.values { + decoder.flush(cancel: cancel) + if decoder.limitExceeded { throw SubtitleDecoderError.textLimitExceeded } + if preparationPolicy != nil && decoder.decodeFailed { throw SubtitleDecoderError.openFailed(code: -1) } + } + if preparationPolicy != nil && cancel.isCancelled { throw CancellationError() } return resolved.compactMap { decodersByStream[$0]?.result } } diff --git a/Sources/AetherEngine/Subtitles/ExternalSubtitlePreparationPolicy.swift b/Sources/AetherEngine/Subtitles/ExternalSubtitlePreparationPolicy.swift new file mode 100644 index 000000000..895000a5a --- /dev/null +++ b/Sources/AetherEngine/Subtitles/ExternalSubtitlePreparationPolicy.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Bounds for opt-in preparation of a downloaded text sidecar; ordinary selection is unchanged. +public struct ExternalSubtitlePreparationPolicy: Sendable, Equatable { + public var maximumFileBytes: Int + public var maximumCueCount: Int + public var maximumCueBytes: Int + public var maximumTextBytes: Int + + public init(maximumFileBytes: Int = 4 * 1024 * 1024, maximumCueCount: Int = 50_000, + maximumCueBytes: Int = 8192, maximumTextBytes: Int = 8 * 1024 * 1024) { + self.maximumFileBytes = maximumFileBytes + self.maximumCueCount = maximumCueCount + self.maximumCueBytes = maximumCueBytes + self.maximumTextBytes = maximumTextBytes + } + + /// Called only on the blocking decode worker, never by the MainActor preparation entry point. + func validateFile(_ url: URL) throws { + guard url.isFileURL else { throw ExternalSubtitlePreparationError.unsupportedFormat } + guard maximumFileBytes > 0, maximumCueCount > 0, maximumCueBytes > 0, + maximumTextBytes > 0 else { throw ExternalSubtitlePreparationError.resourceLimit } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true, + let size = values.fileSize, size > 0, size <= maximumFileBytes else { + throw ExternalSubtitlePreparationError.resourceLimit + } + } +} diff --git a/Sources/AetherEngine/Subtitles/ExternalSubtitleTrack.swift b/Sources/AetherEngine/Subtitles/ExternalSubtitleTrack.swift index 2fac40a44..00b10b73a 100644 --- a/Sources/AetherEngine/Subtitles/ExternalSubtitleTrack.swift +++ b/Sources/AetherEngine/Subtitles/ExternalSubtitleTrack.swift @@ -7,6 +7,8 @@ import Foundation /// (`AetherEngine.externalSubtitleTrackIDBase` + registration ordinal) and `isExternal == true`, /// selectable through the same `selectSubtitleTrack(index:)` as embedded streams. public struct ExternalSubtitleTrack: Sendable, Equatable { + var preparationPolicy: ExternalSubtitlePreparationPolicy? = nil + public var url: URL public var name: String? /// BCP-47 / ISO 639 code, same convention as `TrackInfo.language`. diff --git a/Sources/AetherEngine/Subtitles/PreparedExternalSubtitle.swift b/Sources/AetherEngine/Subtitles/PreparedExternalSubtitle.swift new file mode 100644 index 000000000..9a98814cb --- /dev/null +++ b/Sources/AetherEngine/Subtitles/PreparedExternalSubtitle.swift @@ -0,0 +1,127 @@ +import Foundation + +public enum ExternalSubtitlePreparationError: Error, Sendable, Equatable { + case unsupportedFormat, empty, resourceLimit, decodeFailed, cancelled, stale +} + +/// A single-use candidate. The engine retains no candidate; dropping the host's +/// handle releases its decoded data. It cannot outlive a load/stop generation. +@MainActor +public final class PreparedExternalSubtitle { + fileprivate weak var owner: AetherEngine? + fileprivate let generation: UInt64 + fileprivate let intent: UInt64 + fileprivate var registeredID: Int? + fileprivate let track: ExternalSubtitleTrack + fileprivate let policy: ExternalSubtitlePreparationPolicy + fileprivate var result: SidecarDecodeResult? + public let cueCount: Int + + fileprivate init(owner: AetherEngine, generation: UInt64, + track: ExternalSubtitleTrack, result: SidecarDecodeResult, policy: ExternalSubtitlePreparationPolicy) { + self.owner = owner + self.generation = generation + intent = owner.externalSubtitleIntentGeneration + self.track = track + self.policy = policy + self.result = result + cueCount = result.cues.count + } + + public func discard() { result = nil } +} + +extension AetherEngine { + /// Decode only an already downloaded, bounded text sidecar. No track, + /// preference, cue, translation or playback state changes during preparation. + public func prepareExternalSubtitle(_ track: ExternalSubtitleTrack, + policy: ExternalSubtitlePreparationPolicy = .init()) async throws -> PreparedExternalSubtitle { + let generation = loadGeneration + let intent = externalSubtitleIntentGeneration + guard state == .playing || state == .paused else { throw ExternalSubtitlePreparationError.stale } + guard track.url.isFileURL, track.sourceStreamIndex == nil, + ["srt", "vtt", "ass", "ssa"].contains(track.url.pathExtension.lowercased()) else { + throw ExternalSubtitlePreparationError.unsupportedFormat + } + do { + try Task.checkCancellation() + let decoded = try await SubtitleDecoder.decodeFile( + url: track.url, httpHeaders: [:], preserveASSMarkup: loadedOptions.preserveASSMarkup, preparationPolicy: policy) + try Task.checkCancellation() + guard generation == loadGeneration, intent == externalSubtitleIntentGeneration, + state == .playing || state == .paused else { + throw ExternalSubtitlePreparationError.stale + } + guard !decoded.cues.isEmpty else { throw ExternalSubtitlePreparationError.empty } + return PreparedExternalSubtitle(owner: self, generation: generation, track: track, result: decoded, policy: policy) + } catch let error as ExternalSubtitlePreparationError { throw error } + catch is CancellationError { throw ExternalSubtitlePreparationError.cancelled } + catch SubtitleDecoderError.unsupportedTextCodec { throw ExternalSubtitlePreparationError.unsupportedFormat } + catch SubtitleDecoderError.textLimitExceeded { throw ExternalSubtitlePreparationError.resourceLimit } + catch { throw ExternalSubtitlePreparationError.decodeFailed } + } + + /// Re-selecting an existing host sidecar obeys the same preparation contract. + public func prepareExternalSubtitle(id: Int, policy: ExternalSubtitlePreparationPolicy? = nil) async throws -> PreparedExternalSubtitle { + guard let track = externalSubtitleRegistry[id] else { throw ExternalSubtitlePreparationError.stale } + let prepared = try await prepareExternalSubtitle(track, policy: policy ?? track.preparationPolicy ?? .init()) + guard externalSubtitleRegistry[id] == track else { throw ExternalSubtitlePreparationError.stale } + prepared.registeredID = id + return prepared + } + + /// All rejection checks precede publication. A successful commit performs + /// no file/network reads and does not suspend or re-decode the candidate. + @discardableResult + public func commitExternalSubtitle(_ prepared: PreparedExternalSubtitle, + secondary: Bool = false) throws -> TrackInfo { + guard !Task.isCancelled else { throw ExternalSubtitlePreparationError.cancelled } + guard prepared.owner === self, prepared.generation == loadGeneration, + prepared.intent == externalSubtitleIntentGeneration, + state == .playing || state == .paused, + let decoded = prepared.result else { throw ExternalSubtitlePreparationError.stale } + let existingID = prepared.registeredID + if let existingID, externalSubtitleRegistry[existingID] != prepared.track { + throw ExternalSubtitlePreparationError.stale + } + // Consume before registration publishes its track list: a synchronous observer may + // attempt to commit the same handle again while receiving that publication. + prepared.result = nil + hostExplicitSubtitleAction = true + externalSubtitleIntentGeneration &+= 1 + let info: TrackInfo + if let existingID, let existing = subtitleTracks.first(where: { $0.id == existingID }) { + info = existing + var admitted = prepared.track + admitted.preparationPolicy = prepared.policy + externalSubtitleRegistry[existingID] = admitted + } else { + var admitted = prepared.track + admitted.preparationPolicy = prepared.policy + info = registerExternalSubtitleTrack(admitted) + } + if secondary { + clearSecondarySubtitle() + activeSecondaryExternalSubtitleTrackID = info.id + loadedSecondarySidecarURL = prepared.track.url + isSecondarySubtitleActive = true + secondarySubtitleCues = decoded.cues + secondarySidecarASSHeader = decoded.assHeader + isLoadingSecondarySubtitles = false + } else { + cancelSidecarTask() + clearSubtitleDrainTarget(channel: .primary, reason: .sidecarSelected) + activeEmbeddedSubtitleStreamIndex = -1 + pgsStaleArrivalGates[.primary]?.reset() + liveSubtitleFetchTask?.cancel() + liveSubtitleFetchTask = nil + activeSubtitleTrackIndex = info.id + loadedSidecarURL = prepared.track.url + isSubtitleActive = true + subtitleCues = decoded.cues + sidecarASSHeader = decoded.assHeader + isLoadingSubtitles = false + } + return info + } +} diff --git a/Tests/AetherEngineTests/PreparedExternalSubtitleTests.swift b/Tests/AetherEngineTests/PreparedExternalSubtitleTests.swift new file mode 100644 index 000000000..fe3eff86b --- /dev/null +++ b/Tests/AetherEngineTests/PreparedExternalSubtitleTests.swift @@ -0,0 +1,263 @@ +import Combine +import Testing +import Foundation +@testable import AetherEngine + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct PreparedExternalSubtitleTests { + private func file(_ text: String, ext: String = "srt") throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let url = root.appendingPathComponent("fixture.\(ext)") + try Data(text.utf8).write(to: url) + return url + } + private var text: String { "1\n00:02:01,000 --> 00:02:03,000\nFixture\n\n" } + private func player() throws -> AetherEngine { + let engine = try AetherEngine() + engine.state = .paused + engine.activeSubtitleTrackIndex = 7 + engine.subtitleCues = [SubtitleCue(id: 1, startTime: 0, endTime: 10, body: .text("Old"))] + return engine + } + + @Test func prepareDoesNotChangeOldSelectionAndCommitDoesNotReadFile() async throws { + let engine = try player() + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let prepared = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + #expect(engine.activeSubtitleTrackIndex == 7) + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.subtitleTracks.isEmpty) + #expect(prepared.cueCount == 1) + try FileManager.default.removeItem(at: url) + let track = try engine.commitExternalSubtitle(prepared) + #expect(track.isExternal) + #expect(engine.activeSubtitleTrackIndex == track.id) + #expect(engine.subtitleCues.first?.text == "Fixture") + #expect(engine.state == .paused) + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(prepared) } + } + + @Test func invalidAndPlaylistFilesNeverClearOldSubtitle() async throws { + for payload in ["not a subtitle", "", "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nhttps://127.0.0.1:1/video.m3u8\n"] { + let engine = try player() + let url = try file(payload) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + await #expect(throws: ExternalSubtitlePreparationError.self) { + try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + } + #expect(engine.activeSubtitleTrackIndex == 7) + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.subtitleTracks.isEmpty) + } + } + + @Test func stoppedGenerationAndNewIntentRejectPreparedCandidate() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let first = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + engine.stop() + engine.state = .paused // even a subsequently playable engine cannot revive the old handle + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(first) } + let second = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + engine.clearSubtitle() + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(second) } + #expect(engine.activeSubtitleTrackIndex == nil) + #expect(engine.subtitleTracks.isEmpty) + } + + @Test func exactRegisteredIDAndRemovedAssetAreRespected() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let descriptor = ExternalSubtitleTrack(url: url) + let first = engine.registerExternalSubtitleTrack(descriptor) + let second = engine.registerExternalSubtitleTrack(descriptor) + engine.activeSecondaryExternalSubtitleTrackID = first.id + let candidate = try await engine.prepareExternalSubtitle(id: second.id) + #expect(try engine.commitExternalSubtitle(candidate).id == second.id) + let removed = try await engine.prepareExternalSubtitle(id: second.id) + engine.removeExternalSubtitleTrack(id: second.id) + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(removed) } + } + + @Test func secondaryRemainsWhenPrimaryCommits() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + engine.activeSecondaryExternalSubtitleTrackID = 8 + engine.secondarySubtitleCues = [SubtitleCue(id: 2, startTime: 0, endTime: 10, body: .text("Secondary"))] + let candidate = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + try engine.commitExternalSubtitle(candidate) + #expect(engine.activeSecondaryExternalSubtitleTrackID == 8) + #expect(engine.secondarySubtitleCues.first?.text == "Secondary") + } + + @Test func ownerDiscardAndSharedIntent() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let other = try player() + let candidate = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + #expect(throws: ExternalSubtitlePreparationError.self) { try other.commitExternalSubtitle(candidate) } + candidate.discard() + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(candidate) } + let first = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + let second = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + _ = try engine.commitExternalSubtitle(first, secondary: true) + #expect(throws: ExternalSubtitlePreparationError.self) { try engine.commitExternalSubtitle(second) } + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.secondarySubtitleCues.first?.text == "Fixture") + } + + @Test func configuredLimitsAndNonRegularFilesPreserveSelection() async throws { + // Pin the defaults documented in docs/api.md; callers may choose other positive bounds. + let defaults = ExternalSubtitlePreparationPolicy() + #expect(defaults.maximumFileBytes == 4 * 1024 * 1024) + #expect(defaults.maximumCueCount == 50_000) + #expect(defaults.maximumCueBytes == 8192) + #expect(defaults.maximumTextBytes == 8 * 1024 * 1024) + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + for policy in [ExternalSubtitlePreparationPolicy(maximumFileBytes: 1), + .init(maximumCueCount: 0), .init(maximumCueBytes: 1), .init(maximumTextBytes: 1)] { + await #expect(throws: ExternalSubtitlePreparationError.resourceLimit) { + try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url), policy: policy) + } + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.subtitleTracks.isEmpty) + } + let link = url.deletingLastPathComponent().appendingPathComponent("link.srt") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: url) + await #expect(throws: ExternalSubtitlePreparationError.resourceLimit) { + try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: link)) + } + let prepared = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url), + policy: .init(maximumFileBytes: 1000, maximumCueCount: 1, maximumCueBytes: 100, maximumTextBytes: 100)) + #expect(prepared.cueCount == 1) + } + + @Test func cancelledPreparationDoesNotPublish() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let task = Task { try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) } + task.cancel() + await #expect(throws: ExternalSubtitlePreparationError.cancelled) { try await task.value } + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.subtitleTracks.isEmpty) + } + + @Test func oneRegisteredTrackCanOccupyBothChannels() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let track = engine.registerExternalSubtitleTrack(ExternalSubtitleTrack(url: url)) + let primary = try await engine.prepareExternalSubtitle(id: track.id) + _ = try engine.commitExternalSubtitle(primary) + let secondary = try await engine.prepareExternalSubtitle(id: track.id) + _ = try engine.commitExternalSubtitle(secondary, secondary: true) + #expect(engine.activeSubtitleTrackIndex == track.id) + #expect(engine.activeSecondaryExternalSubtitleTrackID == track.id) + #expect(engine.subtitleCues.compactMap(\.text) == engine.secondarySubtitleCues.compactMap(\.text)) + } + + private final class ValidationThread: @unchecked Sendable { + let lock = NSLock() + var observedMainThread: Bool? + func record() { lock.withLock { observedMainThread = Thread.isMainThread } } + var observed: Bool? { lock.withLock { observedMainThread } } + } + + @Test func fileAdmissionRunsOffMainActor() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let observed = ValidationThread() + SubtitleDecoder.preparedFileValidationObserverForTesting = { candidate in + if candidate == url { observed.record() } + } + defer { SubtitleDecoder.preparedFileValidationObserverForTesting = nil } + let engine = try player() + let prepared = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + #expect(prepared.cueCount == 1) + #expect(observed.observed == false) + } + + @Test(arguments: [false, true]) + func preparedASSKeepsRequestedStyleAndSecondaryHeader(preserve: Bool) async throws { + let ass = """ + [Script Info] + ScriptType: v4.00+ + PlayResX: 1920 + PlayResY: 1080 + + [V4+ Styles] + Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding + Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 + + [Events] + Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text + Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\i1}Hello{\\i0} world + """ + let url = try file(ass, ext: "ass") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + engine.setLoadedOptionsForTesting(LoadOptions(preserveASSMarkup: preserve)) + let candidate = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + let track = try engine.commitExternalSubtitle(candidate, secondary: true) + let cue = try #require(engine.secondarySubtitleCues.first) + if preserve { + let header = try #require(engine.secondarySidecarASSHeader) + #expect(header.contains("PlayResX: 1920")) + let builder = ASSScriptBuilder(header: header) + #expect(builder.add(rawEventText: try #require(cue.text), start: cue.startTime, end: cue.endTime)) + #expect(builder.script().contains("{\\i1}Hello{\\i0} world")) + } else { + #expect(engine.secondarySidecarASSHeader == nil) + #expect(cue.text == "Hello world") + guard case .richText(let runs) = cue.body else { Issue.record("default ASS styling was lost"); return } + #expect(runs.first?.isItalic == true) + } + #expect(engine.subtitleCues.first?.text == "Old") + #expect(engine.externalSubtitleRegistry[track.id]?.preparationPolicy != nil) + } + + @Test func registeredPolicyIsRetainedAndCanBeExplicitlyChanged() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let policy = ExternalSubtitlePreparationPolicy(maximumCueCount: 1) + let candidate = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url), policy: policy) + let track = try engine.commitExternalSubtitle(candidate) + try Data((text + "2\n00:02:04,000 --> 00:02:06,000\nSecond\n\n").utf8).write(to: url) + await #expect(throws: ExternalSubtitlePreparationError.resourceLimit) { + try await engine.prepareExternalSubtitle(id: track.id) + } + #expect(engine.subtitleCues.count == 1) + let replacementPolicy = ExternalSubtitlePreparationPolicy(maximumCueCount: 2) + let replacement = try await engine.prepareExternalSubtitle(id: track.id, policy: replacementPolicy) + #expect(try engine.commitExternalSubtitle(replacement).id == track.id) + #expect(engine.subtitleCues.count == 2) + #expect(engine.externalSubtitleRegistry[track.id]?.preparationPolicy == replacementPolicy) + } + + @Test func synchronousTrackObserverCannotConsumeCandidateTwice() async throws { + let url = try file(text) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let engine = try player() + let candidate = try await engine.prepareExternalSubtitle(ExternalSubtitleTrack(url: url)) + var rejection: ExternalSubtitlePreparationError? + let observer = engine.$subtitleTracks.dropFirst().sink { _ in + do { _ = try engine.commitExternalSubtitle(candidate) } + catch { rejection = error as? ExternalSubtitlePreparationError } + } + defer { observer.cancel() } + _ = try engine.commitExternalSubtitle(candidate) + #expect(rejection == .stale) + #expect(engine.subtitleTracks.count == 1) + } +} diff --git a/docs/api.md b/docs/api.md index 93542ee8b..8005b9dc8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1379,3 +1379,13 @@ Public for the CLI, the test suite, or a diagnostic overlay, and outside the sha - **Dolby Vision Profile 7 FEL tooling** (`@_spi(Diagnostics)`): `FelProbeResult` / `felDecodeProbe` (`aetherctl feltest`) and `felResidualEnabled` (`aetherctl play --cycle-el`), which exist only with the `DualLayerDolbyVision` trait, `DoviRpuSurvey` / `DoviRpuSurveyResult` / `DoviRpuFrameSurvey` / `doviRpuSurvey` (`aetherctl dvsurvey`), and, in `DEBUG` builds with the `DualLayerDolbyVision` trait only, `armDolbyVisionPinTest` / `dolbyVisionPinIsArmed` / `dolbyVisionPinIsActive` (`aetherctl play --dv-pin`). Hosts import none of them; the host surface of the FEL route is `LoadOptions.preferDualLayerDolbyVision` and, in `SessionReloadRefusal`, the new case `.dualLayerRouteNeedsFreshLoad`. See [Dolby Vision FEL](dolby-vision-fel.md) and [CLI](cli.md). - **`HLSLiveIngestReader`'s internals** (`terminalError`, `upstreamTargetDuration`, `observedLiveCadenceSeconds`, `closedLiveCadenceSeconds`, `upstreamSegmentDurationSeconds`, `companionAudioReader`): fixture and diagnostic reads. The last two are the closed evidence the served TARGETDURATION is sealed from (AE#447); `upstreamTargetDuration` is the upstream's own claim, reported in the seal line and derived from nowhere. - **`SubtitleChannel`**: the primary / secondary selector on the engine's internal subtitle routing. No public signature takes one; a host picks the channel by calling the primary or the secondary method. + +### Prepared external subtitle selection + +`prepareExternalSubtitle(_:policy:)` decodes a downloaded regular SRT, WebVTT, ASS or SSA file without changing the active tracks, preferences or cue lists. `prepareExternalSubtitle(id:policy:)` prepares an existing registry entry without creating another ID. Preparation requires a playing or paused session and returns a MainActor-owned `PreparedExternalSubtitle`; `cueCount` lets the host inspect whether preparation succeeded. The existing `preserveASSMarkup` option governs ASS output. + +`ExternalSubtitlePreparationPolicy` bounds `maximumFileBytes` (default 4 MiB), `maximumCueCount` (50,000), `maximumCueBytes` (8,192 UTF-8 bytes) and `maximumTextBytes` (8 MiB). Hosts can supply different positive limits. Ordinary subtitle selection retains its existing behavior. A track registered by a successful prepared commit remembers its policy for later selections. Preparation performs file metadata checks and decoding on the existing blocking worker, not the MainActor. Remote downloading remains the host's responsibility. The local filename must have a supported extension; `formatHint` does not permit arbitrary containers. Keep the file stable during preparation. Non-regular files, symbolic links, unsupported codecs and multistream containers are outside this API. + +`commitExternalSubtitle(_:secondary:)` consumes the handle once and publishes already-decoded cues synchronously: it does not reopen or decode the file. `discard()` releases an unused candidate. `ExternalSubtitlePreparationError` distinguishes `unsupportedFormat`, `empty`, `resourceLimit`, `decodeFailed`, `cancelled` and `stale`. A failed preparation or rejected commit preserves the prior selection. + +Prepared candidates belong to one engine, one load/stop generation and one shared subtitle-selection intent. A selection or clear on **either** channel, registry removal, a successful commit, stop or a new load invalidates older candidates. Two independently prepared candidates therefore are not an atomic primary/secondary pair: committing one invalidates the other. This prevents a late picker result from replacing a newer user choice. The same registered subtitle remains selectable on both channels; the engine adds no host-specific exclusivity policy.