From 08d63fc19cf3f351a237ef79e66d0be4a5c9b39f Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Sat, 1 Aug 2026 15:35:28 +0200 Subject: [PATCH] perf(subtitles): publish the overlay cue array once per drain tick $subtitleCues was published once per DECODED PACKET. applyEventMutations takes the channel's array inout and @Published exposes get/set with no _modify, so every event both copy-on-wrote the array and republished it. Each publication carries the whole cumulative store, and a snapshot does not say which elements are new, so every consumer walked all of it: O(n) per packet, O(n^2) per drain window. On a typeset ASS track edde746 measured 104 publications and 608,608 cue visits per second in one consumer, with add=0, and 43 ms/s of host time spent on walks that found nothing. The tick now binds the channel's array once, applies the whole batch to it and writes it back once, and only when the batch changed something: an event that decodes but resolves to a cue the store already holds, or a trim matching no open window, no longer costs a publication. The mutation helpers report change up the chain for that. Retention pruning moves out of the per-event path to once per batch, and the retained-store insert finds same-start cues over the equal-start run by binary search instead of scanning the whole array (the dedupe and image-replace keys both require an exact startTime match, and the store is start-sorted). Two further items from the same report: The decode loop is now bounded per tick. The window is bounded in seconds of content and never in packets, so its size was set by the file's density while the loop ran synchronously on the main actor. The bound falls on a PTS boundary: the cursor is a bare PTS advanced by lastDecodedPts.nextUp, so a cut inside a same-PTS run would SKIP its remainder instead of resuming it, and dense ASS deliberately keeps hundreds of distinct payloads on one timestamp. The OCR worker's existing prefix() cap gets the same correction. A capped batch also defers reconstruction finalization, whose contract is "after the whole window decoded". drainPlan compared the live playhead against the playhead captured at the previous tick's start, so any tick longer than the 2.5 s jump threshold made the next one reset onto a fresh disjoint window: a positive feedback loop, since the reset window is the expensive one. Forward drift is now forgiven up to the elapsed wall time; backward drift is not, because playback never moves the playhead backwards. Reported by @edde746 (#271). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HX5zV3Fcf7Nzq4DYGdXvQE --- CHANGELOG.md | 34 ++- .../AetherEngine+SubtitleOCR.swift | 18 +- .../AetherEngine/AetherEngine+Subtitles.swift | 198 +++++++++++------ Sources/AetherEngine/AetherEngine.swift | 13 ++ .../Subtitles/SubtitleOverlayDrainer.swift | 34 ++- .../Issue271DrainPublicationTests.swift | 207 ++++++++++++++++++ 6 files changed, 437 insertions(+), 67 deletions(-) create mode 100644 Tests/AetherEngineTests/Issue271DrainPublicationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c5e69d..4c769b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,39 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Changed + +- **`$subtitleCues` publishes once per drain tick instead of once per decoded + subtitle packet.** Every publication carries the whole cumulative cue array, + and a snapshot cannot tell a consumer which of its elements are new, so each + one cost every subscriber a full walk: O(n) per packet, O(n²) per drain + window. On a typeset ASS track that was 104 publications and 608,608 cue + visits per second in a single consumer, none of which found new work. The + tick now binds the channel's array once, applies the whole batch of decoded + events to it, and publishes only when the batch actually changed something. + The retained-store insert also looks up same-start cues by binary search + rather than scanning the whole array. Reported and measured by @edde746 + (#271). + +### Fixed + +- **One drain tick no longer decodes an unbounded number of subtitle packets.** + The drain window is bounded in seconds of content (backscan plus lead), never + in packets, so its size was set by the file's subtitle density while the + decode loop ran synchronously on the main actor with no suspension point. It + is now capped per tick, with the boundary extended to the end of the run + sharing the last packet's PTS: the drain cursor is a bare PTS advanced past + what it decoded, so a cut inside a same-PTS run would skip the remainder + rather than resume it on the next tick. Dense ASS deliberately keeps hundreds + of distinct payloads on one timestamp. The subtitle OCR worker's existing cap + gets the same PTS-boundary correction (#271). +- **A slow drain tick no longer reads its own duration as a seek.** The plan + compared the live playhead against the playhead captured at the previous + tick's start, so a tick lasting longer than the 2.5 s jump threshold made the + next one reset onto a fresh, disjoint window: a positive feedback loop, since + the reset window is the expensive one. Forward drift is now forgiven up to + the wall time the previous tick consumed. Backward drift is not, because + playback never moves the playhead backwards (#271). ## [6.4.0] - 2026-07-31 diff --git a/Sources/AetherEngine/AetherEngine+SubtitleOCR.swift b/Sources/AetherEngine/AetherEngine+SubtitleOCR.swift index 4076c6a7..8749fdc5 100644 --- a/Sources/AetherEngine/AetherEngine+SubtitleOCR.swift +++ b/Sources/AetherEngine/AetherEngine+SubtitleOCR.swift @@ -36,6 +36,7 @@ extension AetherEngine { subtitleOCRSidecarFillTask?.cancel() subtitleOCRSidecarFillTask = nil subtitleOCRDecoder = nil + subtitleOCRLastTickUptime = nil // #271 } /// Load/stop teardown: forget covered-region state too (new session, new axis). @@ -56,11 +57,16 @@ extension AetherEngine { closed.append(contentsOf: pending.expired(asOf: playhead)) subtitleOCRPendingStates[ordinal] = pending } + // #271: same rule as the overlay drainer, a tick that ran long is not a seek. + let tickUptime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + let elapsed = subtitleOCRLastTickUptime.map { tickUptime - $0 } ?? 0 + subtitleOCRLastTickUptime = tickUptime let plan = SubtitleOverlayDrainer.drainPlan( cursor: subtitleOCRCursors[ordinal], playhead: playhead, lead: Self.subtitleOCRLeadSeconds, backscan: Self.subtitleDrainBackscanSeconds, - jumpThreshold: Self.subtitleDrainJumpThresholdSeconds) + jumpThreshold: Self.subtitleDrainJumpThresholdSeconds, + elapsedSinceLastPlan: elapsed) let window: (from: Double, through: Double) switch plan { case .idle: @@ -79,7 +85,15 @@ extension AetherEngine { guard let decoder = subtitleOCRDecoder else { return closed } let entries = packetStore.entries(streamIndex: streamIndex, from: window.from, through: window.through) - let batch = entries.prefix(Self.subtitleOCRMaxPacketsPerTick) + // #271: the cap has to fall on a PTS boundary. The cursor is a bare PTS advanced by + // `lastDecodedPts.nextUp`, so a cut inside a same-PTS run skips its remainder instead of + // resuming it next tick. One composition per PTS is the norm on a bitmap track, but a + // container that splits a display set across packets (see splitDisplaySetSubtitleStreamIndices) + // shares one, and half a display set OCRs to nothing. + let batchEnd = SubtitleOverlayDrainer.batchEnd( + count: entries.count, cap: Self.subtitleOCRMaxPacketsPerTick, + ptsAt: { entries[$0].ptsSeconds }) + let batch = entries[.. [SubtitleCue] { + switch channel { + case .primary: return subtitleCues + case .secondary: return secondarySubtitleCues + } + } + + private func publishRetainedSubtitleCues(_ cues: [SubtitleCue], for channel: SubtitleChannel) { + switch channel { + case .primary: subtitleCues = cues + case .secondary: secondarySubtitleCues = cues + } + } + + /// Returns whether the event changed anything. An event that decodes but resolves to nothing new + /// (a re-decoded cue the store already holds, a trim matching no open window) must not cost a + /// publication: on a dense track that is the common case, and each publication makes every + /// consumer walk the whole cumulative snapshot (#271). + @discardableResult + private func applySubtitleEvent(_ event: EmbeddedSubtitleDecoder.SubtitleEvent, + to cues: inout [SubtitleCue], + channel: SubtitleChannel) -> Bool { + guard isSubtitleActive(for: channel) else { return false } // Per-session diagnostics: primary-only, capped at 20 to keep the in-app log readable. if channel == .primary, subtitleCueDiagnosticCount < 20, let firstCue = event.cues.first { @@ -673,23 +733,23 @@ extension AetherEngine { ) } - switch channel { - case .primary: - applyEventMutations(event, to: &subtitleCues, channel: .primary) - case .secondary: - applyEventMutations(event, to: &secondarySubtitleCues, channel: .secondary) - } + return applyEventMutations(event, to: &cues, channel: channel) } - /// PGS clear-event trim + sorted insert + prune. Native mov_text stores (#55) are NOT fed here; those are owned by the multi-decode reader. + /// PGS clear-event trim + sorted insert. Native mov_text stores (#55) are NOT fed here; those are owned by the multi-decode reader. + /// #271: retention pruning moved to the drain tick (once per batch, not once per event) and the + /// return value reports whether `cues` actually changed. @MainActor - private func applyEventMutations(_ event: EmbeddedSubtitleDecoder.SubtitleEvent, to cues: inout [SubtitleCue], channel: SubtitleChannel = .primary) { + @discardableResult + private func applyEventMutations(_ event: EmbeddedSubtitleDecoder.SubtitleEvent, to cues: inout [SubtitleCue], channel: SubtitleChannel = .primary) -> Bool { + var changed = false if let trimAt = event.pgsTrimAt { for i in 0.. trimAt { cues[i] = cue.with(endTime: trimAt) + changed = true } } // #100: this event is the held stale arrival's successor; its start closes the held @@ -697,13 +757,13 @@ extension AetherEngine { // genuinely active cue), drop replayed history silently. for cue in pgsStaleArrivalGates[channel, default: PGSStaleArrivalGate()] .resolveHeld(trimAt: trimAt, playhead: sourceTime) { - insertSorted(cue, into: &cues) + if insertSorted(cue, into: &cues) { changed = true } } } // #107: teletext page-state semantics; every event (content or erase) closes earlier // open text cues at its start, since libzvbi emits pages open-ended ("until replaced"). - if let trimAt = event.textTrimAt { - Self.trimTextCues(&cues, at: trimAt) + if let trimAt = event.textTrimAt, Self.trimTextCues(&cues, at: trimAt) { + changed = true } // #100: a PGS event whose cues start well behind the playhead is a catch-up replay; its // open-ended placeholder window would cover the playhead the instant it inserts and flash @@ -715,40 +775,34 @@ extension AetherEngine { .admit(cues: event.cues, isPGS: event.isPGS, isSelfContained: event.isSelfContainedPGS, playhead: sourceTime) for cue in admitted { - insertSorted(cue, into: &cues) + if insertSorted(cue, into: &cues) { changed = true } } - pruneOldSubtitleCues(&cues) + return changed } @MainActor - private func insertSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue]) { + @discardableResult + private func insertSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue]) -> Bool { Self.insertCueSorted(cue, into: &cues, nextID: &nextRetainedSubtitleCueID) } - /// #143 follow-up: insert a finalized reconstruction candidate straight into the channel's store. - /// The candidate is the genuinely active line at the seek target, so it bypasses `admit`, whose - /// steady-state stale check would re-hold a landing line sitting more than the epsilon behind the - /// playhead and re-dark the overlay this fix exists to light. - @MainActor - private func insertFinalizedReconstructionCue(_ cue: SubtitleCue, channel: SubtitleChannel) { - switch channel { - case .primary: insertSorted(cue, into: &subtitleCues) - case .secondary: insertSorted(cue, into: &secondarySubtitleCues) - } - } - /// #107: close every non-image cue (text or rich text) whose window covers `trimAt` (teletext /// page-state semantics: each page transmission or erase replaces what came before it). Image /// cues are untouched; they have their own PGS trim. Static and pure for unit tests. - nonisolated static func trimTextCues(_ cues: inout [SubtitleCue], at trimAt: Double) { + /// Returns whether any cue was actually closed (#271). + @discardableResult + nonisolated static func trimTextCues(_ cues: inout [SubtitleCue], at trimAt: Double) -> Bool { + var changed = false for i in 0.. trimAt { cues[i] = cue.with(endTime: trimAt) + changed = true } } + return changed } /// #112 full umbau: sorted insert of a decoded cue into the retained store, keeping ascending start order. An @@ -767,7 +821,20 @@ extension AetherEngine { /// re-decodes cues still retained here; without a store-level guard the cues accumulate (report: 4 -> 7 -> 11) /// and the reset ids collide with retained ids (`ForEach(id:)` "occurs multiple times"). The retained store /// is the session-wide source of truth, so the invariant lives here, not on the ephemeral decoder. - nonisolated static func insertCueSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue], nextID: inout Int) { + /// + /// #271: both same-start lookups below run over the equal-start RUN found by binary search, not + /// over the whole array. The store is kept sorted by startTime by the insert at the bottom, and + /// both keys require an exact startTime match, so the run is the only place a match can live. On + /// a dense typeset track the retained array is thousands of cues and every decoded packet used + /// to walk all of them. Returns whether a cue was actually inserted or replaced. + @discardableResult + nonisolated static func insertCueSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue], nextID: inout Int) -> Bool { + // Index range, deliberately not an ArraySlice: a live slice keeps a second reference to the + // array's buffer, so the insert below would copy-on-write the whole store on every call. + let lower = lowerBoundByStartTime(cue.startTime, in: cues) + var upper = lower + while upper < cues.count, cues[upper].startTime == cue.startTime { upper += 1 } + // A non-image cue already present with the same start and flattened text is a re-decode of a retained // line, not a new one. `cue.text` flattens both `.text` and `.richText` (#107 coloured teletext pages) // and is nil for `.image`, so image cues correctly skip this guard and use their own same-start replace @@ -776,49 +843,56 @@ extension AetherEngine { // of the key: a retained teletext cue may have been trimmed by its successor (#107) while the re-decode // emits the original open-ended window; the retained (trimmed) cue stays authoritative. Deduped cues // consume no id. - if let text = cue.text, - cues.contains(where: { other in - other.startTime == cue.startTime && other.text == text - }) { - return + if let text = cue.text { + for i in lower.. Int { var lo = 0, hi = cues.count while lo < hi { let mid = (lo + hi) / 2 - if cues[mid].startTime < stamped.startTime { lo = mid + 1 } else { hi = mid } + if cues[mid].startTime < startTime { lo = mid + 1 } else { hi = mid } } - cues.insert(stamped, at: lo) + return lo } /// Legacy 2-arg entry that preserves the caller's cue id (test / utility use). The engine path uses the `nextID` /// overload so ids stay session-monotonic across decoder rebuilds (#121). - nonisolated static func insertCueSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue]) { + @discardableResult + nonisolated static func insertCueSorted(_ cue: SubtitleCue, into cues: inout [SubtitleCue]) -> Bool { var id = cue.id - insertCueSorted(cue, into: &cues, nextID: &id) + return insertCueSorted(cue, into: &cues, nextID: &id) } - /// Prune cues whose `endTime` is older than the retention window. Uses `sourceTime` because cue.startTime/endTime are absolute source PTS seconds (see EmbeddedSubtitleDecoder.decode). - @MainActor - private func pruneOldSubtitleCues(_ cues: inout [SubtitleCue]) { - guard !cues.isEmpty else { return } - let cutoff = sourceTime - subtitleCueRetentionSeconds - guard cutoff > 0 else { return } + /// Prune cues whose `endTime` is older than the retention window. The caller passes + /// `sourceTime - subtitleCueRetentionSeconds` because cue.startTime/endTime are absolute source + /// PTS seconds (see EmbeddedSubtitleDecoder.decode). Returns whether anything was dropped (#271). + nonisolated static func pruneCues(_ cues: inout [SubtitleCue], before cutoff: Double) -> Bool { + guard !cues.isEmpty, cutoff > 0 else { return false } + let before = cues.count cues.removeAll { $0.endTime < cutoff } + return cues.count != before } diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index eedbc924..8bcc0429 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -709,6 +709,12 @@ public final class AetherEngine: ObservableObject { var softwareSubtitlePacketStore: SubtitlePacketStore? var subtitleDrainDecoders: [SubtitleChannel: EmbeddedSubtitleDecoder] = [:] var subtitleDrainCursors: [SubtitleChannel: SubtitleDrainCursor] = [:] + /// #271: monotonic timestamp of the previous drain tick, so a tick that ran long is not read as + /// a seek by the next one (`SubtitleOverlayDrainer.drainPlan`). Per tick, not per channel: both + /// channels are planned in the same pass off the same playhead. nil before the first tick. + var subtitleDrainLastTickUptime: Double? + /// #271: the OCR worker's own tick timestamp; same rule, separate cadence. + var subtitleOCRLastTickUptime: Double? /// #250: the frontier source of the last statement emitted per channel, so a change of source /// (the prefetcher dying, EOF landing) gets its own line instead of waiting for the 30 s /// cadence. nil before the first statement of a session. @@ -741,6 +747,13 @@ public final class AetherEngine: ObservableObject { nonisolated static let subtitleDrainBackscanSeconds: Double = 15 nonisolated static let subtitleDrainJumpThresholdSeconds: Double = 2.5 nonisolated static let subtitleDrainTickNanoseconds: UInt64 = 500_000_000 + /// #271: per-tick decode cap for the overlay drainer, extended to the next PTS boundary + /// (`SubtitleOverlayDrainer.batchEnd`). The drain window is bounded in seconds of content, so on + /// a dense typeset track one window is thousands of packets and the loop holds the main actor + /// for all of them. Generous on purpose: the backscan sits at the head of the window, so the + /// cues around the playhead still land in the first batch and the rest of the 60 s lead fills + /// over the following ticks. + nonisolated static let subtitleDrainMaxPacketsPerTick: Int = 256 /// Phase D: the OCR worker decodes bitmap compositions to playhead + this lead so AVKit's /// ~240 s forward .vtt prefetch burst at selection is served populated, never cached empty. nonisolated static let subtitleOCRLeadSeconds: Double = 240 diff --git a/Sources/AetherEngine/Subtitles/SubtitleOverlayDrainer.swift b/Sources/AetherEngine/Subtitles/SubtitleOverlayDrainer.swift index 83aa462c..c6da7a78 100644 --- a/Sources/AetherEngine/Subtitles/SubtitleOverlayDrainer.swift +++ b/Sources/AetherEngine/Subtitles/SubtitleOverlayDrainer.swift @@ -32,14 +32,24 @@ enum SubtitleOverlayDrainer { /// the scan never skips late-arriving packets. static let minimumScanWindowSeconds: Double = 1.0 + /// #271: `elapsedSinceLastPlan` is the wall time between this plan and the previous one. Steady + /// playback advances the playhead by roughly that much, so a tick that itself took longer than + /// `jumpThreshold` would otherwise read its own duration as a seek and reset onto a fresh, + /// disjoint window: a positive feedback loop where one slow tick guarantees the next one is + /// slower. Only FORWARD drift is forgiven; playback never moves the playhead backwards, so a + /// backward delta is a seek at any tick duration. Rate is not modelled: above 1x a pathologically + /// slow tick can still trip the threshold, which costs a redundant window decode, not correctness. static func drainPlan(cursor: SubtitleDrainCursor?, playhead: Double, lead: Double, backscan: Double, - jumpThreshold: Double) -> SubtitleDrainPlan { + jumpThreshold: Double, + elapsedSinceLastPlan: Double = 0) -> SubtitleDrainPlan { let through = playhead + lead guard let cursor else { return .resetAndDecode(from: playhead - backscan, through: through) } - if abs(playhead - cursor.lastPlayhead) > jumpThreshold { + let delta = playhead - cursor.lastPlayhead + let allowance = delta > 0 ? jumpThreshold + max(0, elapsedSinceLastPlan) : jumpThreshold + if abs(delta) > allowance { return .resetAndDecode(from: playhead - backscan, through: through) } guard through - cursor.lastDecodedPts >= minimumScanWindowSeconds else { @@ -48,6 +58,26 @@ enum SubtitleOverlayDrainer { return .decode(from: cursor.lastDecodedPts.nextUp, through: through) } + /// #271: how far into a PTS-ordered store window one tick may decode. The window is bounded in + /// seconds of content (backscan + lead) and never in packets, so its size is set by the file's + /// subtitle density; a typeset ASS track puts thousands of packets in one window and the decode + /// loop has no suspension point. `cap` bounds the batch, and the boundary is then extended + /// forward to the end of the run sharing the last packet's PTS. + /// + /// That extension is required, not a nicety: the drain cursor is a bare PTS advanced by + /// `lastDecodedPts.nextUp`, so a cut INSIDE a same-PTS run would skip its remainder rather than + /// resume it on the next tick. Bitmap tracks put one composition on a PTS and never notice; + /// dense ASS deliberately keeps hundreds of distinct payloads on one timestamp and would lose + /// every one of them past the cut. A single run longer than the cap is therefore decoded whole. + static func batchEnd(count: Int, cap: Int, ptsAt: (Int) -> Double) -> Int { + guard cap > 0 else { return count } + guard count > cap else { return count } + let boundary = ptsAt(cap - 1) + var end = cap + while end < count, ptsAt(end) == boundary { end += 1 } + return end + } + /// Whether a reconstruction pass should be finalized after its drain window has decoded. /// A renderable composition at/after the playhead ends the pass inside /// `admitDuringReconstruction`. If the pass is still active with a seeded candidate, only diff --git a/Tests/AetherEngineTests/Issue271DrainPublicationTests.swift b/Tests/AetherEngineTests/Issue271DrainPublicationTests.swift new file mode 100644 index 00000000..36271e19 --- /dev/null +++ b/Tests/AetherEngineTests/Issue271DrainPublicationTests.swift @@ -0,0 +1,207 @@ +import Foundation +import CoreGraphics +import Testing +@testable import AetherEngine + +/// #271: `$subtitleCues` published once per DECODED PACKET, each publication carrying the whole +/// cumulative array. `applyEventMutations` takes the channel's cue array inout, and `@Published` +/// exposes get/set with no `_modify`, so every event copy-on-wrote the array and republished it. +/// On a typeset ASS track (5,852 retained cues, ~52 packets per 500 ms tick) the reporter measured +/// 104 publications and 608,608 cue visits per second in one consumer, with zero new cues found: +/// a cumulative snapshot does not say which elements are new, so no host can skip the walk. +/// +/// Three things are asserted here, all of them decisions the drain tick makes: +/// +/// - the batch is bounded per tick and the bound falls on a PTS boundary (`batchEnd`), +/// - a tick that ran long is not mistaken for a seek by the next one (`drainPlan`), +/// - the insert reports whether it changed anything and finds same-start cues without walking the +/// whole retained array. +struct Issue271DrainPublicationTests { + + private func textCue(id: Int, start: Double, end: Double, _ s: String) -> SubtitleCue { + SubtitleCue(id: id, startTime: start, endTime: end, body: .text(s)) + } + private func img(width: Int = 1) -> SubtitleCue.Body { + let ctx = CGContext(data: nil, width: width, height: 1, bitsPerComponent: 8, + bytesPerRow: 4 * width, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + return .image(SubtitleImage(cgImage: ctx.makeImage()!, position: .zero)) + } + + // MARK: - Batch bound on a PTS boundary + + @Test("a window at or under the cap decodes whole") + func batchUnderCapIsWhole() { + let pts: [Double] = [1, 2, 3, 4] + #expect(SubtitleOverlayDrainer.batchEnd(count: 4, cap: 8) { pts[$0] } == 4) + #expect(SubtitleOverlayDrainer.batchEnd(count: 4, cap: 4) { pts[$0] } == 4) + #expect(SubtitleOverlayDrainer.batchEnd(count: 0, cap: 4) { pts[$0] } == 0) + } + + @Test("a cap landing between two timestamps cuts there") + func capOnPTSBoundaryCutsExactly() { + let pts: [Double] = [1, 2, 3, 4, 5, 6] + #expect(SubtitleOverlayDrainer.batchEnd(count: 6, cap: 3) { pts[$0] } == 3) + } + + /// The load-bearing case. The cursor is a bare PTS advanced by `lastDecodedPts.nextUp`, so the + /// next tick asks the store for packets AFTER the last decoded timestamp. A cut inside a + /// same-PTS run would therefore skip its remainder for good, and a dense typeset track puts + /// hundreds of distinct payloads on one timestamp (the reporter measured 303 on the densest). + @Test("a cap landing inside a same-PTS run extends to the end of that run") + func capExtendsThroughSamePTSRun() { + let pts: [Double] = [1, 2, 2, 2, 2, 5, 6] + #expect(SubtitleOverlayDrainer.batchEnd(count: 7, cap: 2) { pts[$0] } == 5) + #expect(SubtitleOverlayDrainer.batchEnd(count: 7, cap: 3) { pts[$0] } == 5) + #expect(SubtitleOverlayDrainer.batchEnd(count: 7, cap: 5) { pts[$0] } == 5) + } + + @Test("a single run longer than the cap is decoded whole rather than split") + func oversizedRunIsNotSplit() { + let pts = [Double](repeating: 107.680, count: 303) + #expect(SubtitleOverlayDrainer.batchEnd(count: 303, cap: 48) { pts[$0] } == 303) + } + + @Test("a non-positive cap disables bounding") + func zeroCapIsUnbounded() { + let pts: [Double] = [1, 2, 3] + #expect(SubtitleOverlayDrainer.batchEnd(count: 3, cap: 0) { pts[$0] } == 3) + } + + // MARK: - A slow tick is not a seek + + /// `drainPlan` compares the live playhead against the playhead captured at the PREVIOUS tick's + /// start, so a tick lasting longer than the 2.5 s jump threshold made the next one see a + /// discontinuity and reset onto a fresh, disjoint window: a positive feedback loop, since the + /// reset window is the expensive one. + @Test("forward drift within the tick's own duration is not a seek") + func slowTickIsNotASeek() { + let cursor = SubtitleDrainCursor(lastDecodedPts: 150, lastPlayhead: 100) + let plan = SubtitleOverlayDrainer.drainPlan(cursor: cursor, playhead: 104, + lead: 60, backscan: 15, jumpThreshold: 2.5, + elapsedSinceLastPlan: 4.0) + guard case .decode = plan else { + Issue.record("expected decode, got \(plan)"); return + } + } + + @Test("a real forward seek during a slow tick still resets") + func realSeekDuringSlowTickStillResets() { + let cursor = SubtitleDrainCursor(lastDecodedPts: 150, lastPlayhead: 100) + let plan = SubtitleOverlayDrainer.drainPlan(cursor: cursor, playhead: 400, + lead: 60, backscan: 15, jumpThreshold: 2.5, + elapsedSinceLastPlan: 4.0) + guard case .resetAndDecode(let from, _) = plan else { + Issue.record("expected resetAndDecode, got \(plan)"); return + } + #expect(from == 385) + } + + /// Playback never moves the playhead backwards, so elapsed wall time explains nothing about a + /// backward delta and must not forgive one. + @Test("a backward jump is a seek at any tick duration") + func backwardJumpIsNeverForgiven() { + let cursor = SubtitleDrainCursor(lastDecodedPts: 150, lastPlayhead: 100) + let plan = SubtitleOverlayDrainer.drainPlan(cursor: cursor, playhead: 96, + lead: 60, backscan: 15, jumpThreshold: 2.5, + elapsedSinceLastPlan: 30) + guard case .resetAndDecode(let from, _) = plan else { + Issue.record("expected resetAndDecode, got \(plan)"); return + } + #expect(from == 81) + } + + @Test("elapsed defaults to zero, so the threshold alone still governs a fast tick") + func fastTickKeepsThresholdOnly() { + let cursor = SubtitleDrainCursor(lastDecodedPts: 150, lastPlayhead: 100) + let plan = SubtitleOverlayDrainer.drainPlan(cursor: cursor, playhead: 104, + lead: 60, backscan: 15, jumpThreshold: 2.5) + guard case .resetAndDecode = plan else { + Issue.record("expected resetAndDecode, got \(plan)"); return + } + } + + // MARK: - The insert reports change, and finds same-start cues without a full walk + + @Test("a re-decoded text cue reports no change and consumes no id") + func dedupedInsertReportsNoChange() { + var cues: [SubtitleCue] = [] + var nextID = 0 + #expect(AetherEngine.insertCueSorted(textCue(id: 0, start: 100, end: 110, "line"), + into: &cues, nextID: &nextID)) + #expect(!AetherEngine.insertCueSorted(textCue(id: 0, start: 100, end: 110, "line"), + into: &cues, nextID: &nextID)) + #expect(cues.count == 1) + #expect(nextID == 1) + } + + @Test("a same-start image re-decode reports a change: it replaces the retained bitmap") + func imageReplaceReportsChange() { + var cues: [SubtitleCue] = [] + var nextID = 0 + #expect(AetherEngine.insertCueSorted(SubtitleCue(id: 0, startTime: 100, endTime: 110, body: img()), + into: &cues, nextID: &nextID)) + #expect(AetherEngine.insertCueSorted(SubtitleCue(id: 0, startTime: 100, endTime: 118, body: img()), + into: &cues, nextID: &nextID)) + #expect(cues.count == 1) + #expect(cues[0].endTime == 118) + } + + /// The dedupe key requires an exact start match, so the equal-start run is the only place a + /// match can live and the binary-search lookup must find it wherever the run sits in a large + /// sorted array. Same text at a DIFFERENT start is a genuine repeat and still inserts. + @Test("dedupe over a large sorted store finds the buried same-start cue, and only that one") + func dedupeFindsBuriedRunInLargeStore() { + var cues: [SubtitleCue] = [] + var nextID = 0 + for i in 0..<2000 { + AetherEngine.insertCueSorted(textCue(id: 0, start: Double(i), end: Double(i) + 0.5, "l\(i)"), + into: &cues, nextID: &nextID) + } + #expect(cues.count == 2000) + #expect(!AetherEngine.insertCueSorted(textCue(id: 0, start: 1337, end: 1337.5, "l1337"), + into: &cues, nextID: &nextID)) + #expect(cues.count == 2000) + // Same text, different start: a genuine repeat. + #expect(AetherEngine.insertCueSorted(textCue(id: 0, start: 4000, end: 4000.5, "l1337"), + into: &cues, nextID: &nextID)) + #expect(cues.count == 2001) + // Simultaneous speaker at a start already present: distinct text, both kept. + #expect(AetherEngine.insertCueSorted(textCue(id: 0, start: 1337, end: 1337.5, "other"), + into: &cues, nextID: &nextID)) + #expect(cues.count == 2002) + #expect(cues.map(\.startTime) == cues.map(\.startTime).sorted()) + } + + @Test("insertion order among cues sharing a start is unchanged by the binary-search lookup") + func sameStartInsertPositionUnchanged() { + var cues: [SubtitleCue] = [] + var nextID = 0 + AetherEngine.insertCueSorted(textCue(id: 0, start: 100, end: 110, "first"), into: &cues, nextID: &nextID) + AetherEngine.insertCueSorted(textCue(id: 0, start: 100, end: 110, "second"), into: &cues, nextID: &nextID) + AetherEngine.insertCueSorted(textCue(id: 0, start: 100, end: 110, "third"), into: &cues, nextID: &nextID) + #expect(cues.map(\.text) == ["third", "second", "first"]) + } + + // MARK: - Trim and prune report change + + @Test("a trim covering no open window reports no change") + func trimReportsNoChange() { + var cues = [textCue(id: 0, start: 10, end: 12, "done")] + #expect(!AetherEngine.trimTextCues(&cues, at: 50)) + #expect(AetherEngine.trimTextCues(&cues, at: 11)) + #expect(cues[0].endTime == 11) + } + + @Test("a prune that drops nothing reports no change") + func pruneReportsNoChange() { + var cues = [textCue(id: 0, start: 100, end: 110, "a"), + textCue(id: 1, start: 500, end: 510, "b")] + #expect(!AetherEngine.pruneCues(&cues, before: 50)) + // A non-positive cutoff is "no retention pressure yet", not "drop everything". + #expect(!AetherEngine.pruneCues(&cues, before: -300)) + #expect(cues.count == 2) + #expect(AetherEngine.pruneCues(&cues, before: 200)) + #expect(cues.count == 1) + } +}