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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions Sources/AetherEngine/AetherEngine+SubtitleOCR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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:
Expand All @@ -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[..<batchEnd]
var lastDecoded = subtitleOCRCursors[ordinal]?.lastDecodedPts
for entry in batch {
if let event = Self.decodeStoredSubtitlePacket(entry, with: decoder) {
Expand Down
Loading
Loading