Skip to content

Commit e1cf6c2

Browse files
fix(subtitles): skip find_stream_info on the side demuxer to kill the ~5s PGS startup stall (#87)
The embedded-subtitle side demuxer needs only codec_id / codec_type, which avformat_open_input resolves from the container header / MPEG-TS PMT. PGS/HDMV bitmap tracks keep has_codec_parameters false to the probe cap (the #75 pattern), so even the #76 5s analyze ceiling is paid in full on a remote URL source, landing as a flat ~5s stall when the track is selected at load. Add DemuxerOpenProfile.skipStreamInfo (set by subtitleSideDemuxer); probeStreams drops the find_stream_info pass (and the cover-art reclassify, which only exists to bound that pass) when set. The side reader runs a bounded resolveStreamInfo() on demand only when its target stream's codec is genuinely unresolved at open (a container that does not declare the subtitle codec in its header); the probe ceiling still bounds that fallback pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZTEfmztPE8hAdjHdBr9BH
1 parent 97cf279 commit e1cf6c2

4 files changed

Lines changed: 92 additions & 13 deletions

File tree

Sources/AetherEngine/AetherEngine+Subtitles.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@ extension AetherEngine {
314314
let seekTo = max(0, effectiveStart - 2.0)
315315
demuxer.seek(to: seekTo)
316316

317+
// #87: a fresh open skips find_stream_info (codec_id comes from the container header / PMT). For the rare
318+
// container that does not declare the subtitle codec there, run a bounded find_stream_info before decoding.
319+
if !reused, demuxer.streamCodecUnresolved(at: streamIndex) {
320+
demuxer.resolveStreamInfo()
321+
}
322+
317323
guard let stream = demuxer.stream(at: streamIndex),
318324
let decoder = EmbeddedSubtitleDecoder(
319325
stream: stream,

Sources/AetherEngine/Demuxer/Demuxer.swift

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,22 @@ struct DemuxerOpenProfile: Sendable {
2323
/// to a single attempt: a scrub thumbnail is disposable and must fail fast
2424
/// rather than ride a 3-retry-times-2-URL storm (issue #27).
2525
var avioMaxRetries: Int
26+
/// Skip the open-time `avformat_find_stream_info` pass (#87). The subtitle side demuxer
27+
/// needs only `codec_id` / `codec_type`, which `avformat_open_input` already resolves from the
28+
/// container header / MPEG-TS PMT; find_stream_info would then chase sparse PGS/DVB tracks to the
29+
/// probe cap (they keep `has_codec_parameters` false, the #75 pattern) for nothing, landing as a
30+
/// flat ~5 s startup stall on a slow remote source. The side reader runs a bounded find_stream_info
31+
/// on demand only if its target subtitle stream's codec is genuinely unresolved at open.
32+
var skipStreamInfo: Bool
2633

2734
static let playback = DemuxerOpenProfile(
2835
probesize: 50 * 1024 * 1024,
2936
maxAnalyzeDuration: 60 * 1_000_000,
3037
avioPrefetch: true,
3138
avioChunkSize: 4 * 1024 * 1024,
3239
avioRequestTimeout: 35,
33-
avioMaxRetries: 3
40+
avioMaxRetries: 3,
41+
skipStreamInfo: false
3442
)
3543

3644
static let stillExtraction = DemuxerOpenProfile(
@@ -39,7 +47,8 @@ struct DemuxerOpenProfile: Sendable {
3947
avioPrefetch: false,
4048
avioChunkSize: 1 * 1024 * 1024,
4149
avioRequestTimeout: 8,
42-
avioMaxRetries: 1
50+
avioMaxRetries: 1,
51+
skipStreamInfo: false
4352
)
4453

4554
/// A copy of `self` with only the open-time probe budget overridden (#68).
@@ -55,23 +64,26 @@ struct DemuxerOpenProfile: Sendable {
5564
return copy
5665
}
5766

58-
/// Open profile for the embedded subtitle side-demuxer (#76). `EmbeddedSubtitleDecoder`
67+
/// Open profile for the embedded subtitle side-demuxer (#76, #87). `EmbeddedSubtitleDecoder`
5968
/// needs only `codec_id` / `codec_type` (carried in the container header / MPEG-TS PMT,
6069
/// resolved by `avformat_open_input` itself) and seeds bitmap (PGS/DVB/DVD) canvas dims
61-
/// from the source video size, so the full 50 MB `find_stream_info` chase after sparse,
62-
/// never-resolving subtitle streams is pure cost. On a remote disc that chase reads tens
63-
/// of MB over HTTP (every PGS track keeps `has_codec_parameters` false to the budget cap,
64-
/// the #75 pattern); the side reader is then superseded by a seek / title switch before it
65-
/// reads a single packet, so subtitles never appear (works on a local ISO, where the open
66-
/// is instant). Cap the probe to a subtitle-sized ceiling; honor an even tighter caller
67-
/// budget (#68). Keeps the playback AVIO tuning (prefetch, chunk size, per-chunk timeout):
68-
/// the reader does sustained paced reads, not a one-shot still fetch.
70+
/// from the source video size, so the `find_stream_info` chase after sparse, never-resolving
71+
/// subtitle streams is pure cost. Every PGS track keeps `has_codec_parameters` false to the
72+
/// budget cap (the #75 pattern), so even the #76 5 s ceiling is paid in full on a remote URL
73+
/// source, landing as a flat ~5 s startup stall when the track is selected at load (#87). So
74+
/// `skipStreamInfo` opts out of the chase entirely; the side reader runs a bounded find_stream_info
75+
/// on demand only if its target subtitle stream's codec is genuinely unresolved at open. The probe
76+
/// ceiling still bounds that fallback pass and honors an even tighter caller budget (#68). Keeps the
77+
/// playback AVIO tuning (prefetch, chunk size, per-chunk timeout): the reader does sustained paced
78+
/// reads, not a one-shot still fetch.
6979
static func subtitleSideDemuxer(callerProbesize: Int64?, callerMaxAnalyzeDuration: Int64?) -> DemuxerOpenProfile {
7080
let probeCeiling: Int64 = 4 * 1024 * 1024
7181
let analyzeCeiling: Int64 = 5 * 1_000_000
7282
let probesize = min(callerProbesize ?? probeCeiling, probeCeiling)
7383
let analyze = min(callerMaxAnalyzeDuration ?? analyzeCeiling, analyzeCeiling)
74-
return playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: analyze)
84+
var profile = playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: analyze)
85+
profile.skipStreamInfo = true
86+
return profile
7587
}
7688
}
7789

@@ -325,12 +337,47 @@ public final class Demuxer: @unchecked Sendable {
325337
}
326338

327339
private func probeStreams(_ ctx: UnsafeMutablePointer<AVFormatContext>) throws {
340+
// #87: the subtitle side demuxer opts out of find_stream_info. avformat_open_input already
341+
// carries codec_id / codec_type for every subtitle track (container header / PMT), and
342+
// reclassifyAttachedPictures only exists to bound the find_stream_info cost, so both are skipped.
343+
// The reader runs `resolveStreamInfo()` on demand if its target stream's codec is unresolved.
344+
guard !openProfile.skipStreamInfo else {
345+
logStreams(ctx)
346+
return
347+
}
328348
reclassifyAttachedPictures(ctx)
329349
let findRet = avformat_find_stream_info(ctx, nil)
330350
guard findRet >= 0 else {
331351
throw DemuxerError.streamInfoFailed(code: findRet)
332352
}
353+
logStreams(ctx)
354+
}
355+
356+
/// Run a bounded `avformat_find_stream_info` on an already-open context (#87). Used by the subtitle
357+
/// side reader as a fallback when its target stream's codec is still unresolved after a `skipStreamInfo`
358+
/// open (a container that does not declare the subtitle codec in its header). The probe budget applied
359+
/// at open already caps the pass, so this stays bounded by the side demuxer's subtitle-sized ceiling.
360+
func resolveStreamInfo() {
361+
accessLock.lock()
362+
defer { accessLock.unlock() }
363+
guard let ctx = formatContext else { return }
364+
reclassifyAttachedPictures(ctx)
365+
_ = avformat_find_stream_info(ctx, nil)
366+
}
367+
368+
/// True if the stream at `index` is missing or carries no resolved codec yet (`AV_CODEC_ID_NONE`).
369+
/// The side reader uses this to decide whether a `skipStreamInfo` open needs a `resolveStreamInfo()`
370+
/// fallback before handing the stream to `EmbeddedSubtitleDecoder` (#87).
371+
func streamCodecUnresolved(at index: Int32) -> Bool {
372+
accessLock.lock()
373+
defer { accessLock.unlock() }
374+
guard let ctx = formatContext, index >= 0, index < ctx.pointee.nb_streams,
375+
let stream = ctx.pointee.streams[Int(index)],
376+
let codecpar = stream.pointee.codecpar else { return true }
377+
return codecpar.pointee.codec_id == AV_CODEC_ID_NONE
378+
}
333379

380+
private func logStreams(_ ctx: UnsafeMutablePointer<AVFormatContext>) {
334381
#if DEBUG
335382
EngineLog.emit("[Demuxer] Opened: \(ctx.pointee.nb_streams) streams, duration=\(ctx.pointee.duration) us", category: .demux)
336383
for i in 0..<Int(ctx.pointee.nb_streams) {

Tests/AetherEngineTests/DemuxerProfileTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,30 @@ struct DemuxerProfileTests {
123123
#expect(p.probesize == 4 * 1024 * 1024)
124124
#expect(p.maxAnalyzeDuration == 5 * 1_000_000)
125125
}
126+
127+
// MARK: - skipStreamInfo (#87: drop the find_stream_info chase on the side demuxer)
128+
129+
/// #87: PGS/HDMV bitmap tracks keep `has_codec_parameters` false to the budget cap, so
130+
/// find_stream_info reads to the full 5 s ceiling on a remote URL source, landing as a flat
131+
/// startup stall. The side demuxer only needs `codec_id` / `codec_type` (carried in the
132+
/// container header / PMT, resolved by avformat_open_input), so it opts out of the chase.
133+
@Test("subtitleSideDemuxer opts out of find_stream_info")
134+
func subtitleSideDemuxerSkipsStreamInfo() {
135+
let p = DemuxerOpenProfile.subtitleSideDemuxer(callerProbesize: nil, callerMaxAnalyzeDuration: nil)
136+
#expect(p.skipStreamInfo == true)
137+
}
138+
139+
@Test("playback + stillExtraction keep find_stream_info")
140+
func mainProfilesKeepStreamInfo() {
141+
#expect(DemuxerOpenProfile.playback.skipStreamInfo == false)
142+
#expect(DemuxerOpenProfile.stillExtraction.skipStreamInfo == false)
143+
}
144+
145+
@Test("withProbeBudget preserves the receiver's skipStreamInfo")
146+
func withProbeBudgetPreservesSkipStreamInfo() {
147+
var skipping = DemuxerOpenProfile.playback
148+
skipping.skipStreamInfo = true
149+
#expect(skipping.withProbeBudget(probesize: 1, maxAnalyzeDuration: nil).skipStreamInfo == true)
150+
#expect(DemuxerOpenProfile.playback.withProbeBudget(probesize: 1, maxAnalyzeDuration: nil).skipStreamInfo == false)
151+
}
126152
}

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Sources/AetherEngine/
126126
│ ├── AVIOProvider.swift Internal seam over a custom-AVIO byte source; AVIOReader and CustomIOReaderBridge both plug into the Demuxer through it
127127
│ ├── AVIOReader.swift URLSession-backed avio_alloc_context, three modes: persistent forward-streaming connection with reconnect-on-drop (playback, incl. live), discrete Range chunks (still extraction), single sequential GET with backpressure (non-live sources without Content-Length). Optional read deadline bounds a degenerate matroska Cues seek
128128
│ ├── CustomIOReaderBridge.swift Bridges a host-supplied IOReader into avio_alloc_context read / seek callbacks
129-
│ └── Demuxer.swift libavformat wrapper; seek + bounded seek (deadline-capped); per-open `DemuxerOpenProfile` budgets `find_stream_info` (probesize / max_analyze_duration), caller-overridable on the main playback open via `LoadOptions.probesize` / `maxAnalyzeDuration`
129+
│ └── Demuxer.swift libavformat wrapper; seek + bounded seek (deadline-capped); per-open `DemuxerOpenProfile` budgets `find_stream_info` (probesize / max_analyze_duration), caller-overridable on the main playback open via `LoadOptions.probesize` / `maxAnalyzeDuration`. The subtitle side demuxer sets `skipStreamInfo` to drop the `find_stream_info` pass entirely (codec_id / codec_type come from the container header / PMT at open), so a PGS / bitmap track no longer chases the probe cap as a flat ~5 s startup stall on a remote URL source; the reader runs a bounded `resolveStreamInfo()` on demand only if its target stream's codec is genuinely unresolved at open (#87)
130130
├── Diagnostics/
131131
│ ├── EngineDiagnostics.swift engine.diagnostics: timer-sampled values (liveTelemetry) as a separate ObservableObject
132132
│ ├── EngineLog.swift Gated OSLog emission with severity levels (.verbose suppressed from default + host handler)

0 commit comments

Comments
 (0)