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) + } +}