Skip to content

Commit c70e1c2

Browse files
test(io): aetherctl segverify - isolated per-segment decodability probe for #92
Deterministic, headless verifier for the #92 segmentation defect (and the later fix). Starts the engine, fetches init.mp4 + each segment SEQUENTIALLY from the loopback (sequential so the producer does not restart and re-anchor a clean IRAP), and SW-decodes each segment IN ISOLATION (init.mp4 + that one segment, fresh decoder). framesDecoded == 0 means the segment carries no usable IRAP to start from, i.e. it is not independently decodable. - AetherEngine+Probe: swDecodeProbe(data:formatHint:) overload reusing the existing decode loop over an in-memory DataIOReader. - aetherctl: `segverify [--from N] [--count K] [--dump <dir>] <url>`. Validated against ffmpeg as ground truth on synthetic HEVC fixtures: ffmpeg's own HLS fMP4 segmenter yields independently-decodable segments (init+seg1 -> 96 frames), AetherEngine's current segmenter does not (init+seg1 -> 0-2 frames, "Could not find ref with POC 92"). The probe reports this correctly. Notable: the defect is broader than open-GOP - it hits closed-GOP-with-B-frames too, because the constant reorder delay pushes every keyframe's DTS below its PTS, so the boundary IRAP lands in the previous segment and the next segment starts mid-GOP. Full suite green (XCTest 227, Swift Testing 268), strict-concurrency clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZTEfmztPE8hAdjHdBr9BH
1 parent 958bc7c commit c70e1c2

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

Sources/AetherEngine/AetherEngine+Probe.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,29 @@ extension AetherEngine {
9999
let demuxer = Demuxer()
100100
try demuxer.open(url: url, extraHeaders: options.httpHeaders)
101101
defer { demuxer.close() }
102+
return try swDecodeProbeRun(demuxer: demuxer, maxPackets: maxPackets)
103+
}
104+
105+
/// SW-decode an in-memory media blob (e.g. an HLS `init.mp4` + one fMP4 segment concatenation) with a
106+
/// fresh decoder and no render target. Used by `aetherctl segverify` to test whether one segment is
107+
/// independently decodable: `framesDecoded == 0` means the blob carries no usable IRAP to start from
108+
/// (the #92 open-GOP defect, where the segment's IRAP landed in the previous segment).
109+
public nonisolated static func swDecodeProbe(
110+
data: Data,
111+
formatHint: String? = "mp4",
112+
maxPackets: Int = 200,
113+
options: LoadOptions = .init()
114+
) throws -> SoftwareDecodeProbeResult {
115+
let demuxer = Demuxer()
116+
try demuxer.open(reader: DataIOReader(data: data), formatHint: formatHint)
117+
defer { demuxer.close() }
118+
return try swDecodeProbeRun(demuxer: demuxer, maxPackets: maxPackets)
119+
}
102120

121+
private nonisolated static func swDecodeProbeRun(
122+
demuxer: Demuxer,
123+
maxPackets: Int
124+
) throws -> SoftwareDecodeProbeResult {
103125
let videoIdx = demuxer.videoStreamIndex
104126
guard videoIdx >= 0, let stream = demuxer.stream(at: videoIdx) else {
105127
throw AetherEngineError.noVideoStream

Sources/aetherctl/SegVerify.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
import AetherEngine
3+
4+
/// `aetherctl segverify <url> [--from N] [--count K]` — deterministic, headless #92 verifier.
5+
///
6+
/// Starts the engine, then fetches `init.mp4` plus each media segment SEQUENTIALLY from the loopback
7+
/// server (sequential on purpose: jumping straight to a deep segment triggers a producer restart whose
8+
/// keyframe gate re-anchors a clean IRAP, masking the defect). Each tested segment is SW-decoded IN
9+
/// ISOLATION (`init.mp4` + that one segment, fresh decoder, no predecessor). `framesDecoded == 0` means
10+
/// the segment carries no usable IRAP to start from, i.e. it is not independently decodable, which is
11+
/// exactly what AVPlayer hits on a fresh decode at a mid-stream open-GOP boundary (#92).
12+
func runSegVerify(url: URL, from: Int, count: Int, dvModeAvailable: Bool, dumpDir: String? = nil) -> Int32 {
13+
setvbuf(stdout, nil, _IONBF, 0) // unbuffered: progressive output survives a long-running run
14+
print("segverify: starting engine for \(url.absoluteString)")
15+
let engine = HLSVideoEngine(url: url, dvModeAvailable: dvModeAvailable)
16+
let playbackURL: URL
17+
do {
18+
playbackURL = try engine.start()
19+
} catch {
20+
print("ERROR: engine.start failed: \(error)")
21+
return 1
22+
}
23+
print("segverify: engine started, playlist=\(playbackURL.absoluteString)")
24+
defer { engine.stop() }
25+
26+
guard var comps = URLComponents(url: playbackURL, resolvingAgainstBaseURL: false) else {
27+
print("ERROR: cannot parse playback URL \(playbackURL)")
28+
return 1
29+
}
30+
comps.path = ""
31+
comps.query = nil
32+
guard let base = comps.url else {
33+
print("ERROR: cannot derive loopback base from \(playbackURL)")
34+
return 1
35+
}
36+
37+
let initData: Data
38+
do {
39+
initData = try Data(contentsOf: base.appendingPathComponent("init.mp4"))
40+
} catch {
41+
print("ERROR: fetch init.mp4 failed: \(error)")
42+
return 1
43+
}
44+
print("init.mp4: \(initData.count) B (loopback \(base.absoluteString))")
45+
46+
let last = from + count
47+
var independent = 0
48+
var tested = 0
49+
for n in 0..<last {
50+
let segData: Data
51+
do {
52+
// Sequential fetch advances the producer's consumer target; without it the producer parks.
53+
segData = try Data(contentsOf: base.appendingPathComponent("seg\(n).mp4"))
54+
} catch {
55+
print("seg\(n): FETCH FAILED \(error)")
56+
if n >= from { tested += 1 }
57+
continue
58+
}
59+
guard n >= from else { continue } // fetched only to keep production sequential; not decoded
60+
tested += 1
61+
62+
var blob = initData
63+
blob.append(segData)
64+
if let dumpDir {
65+
let p = "\(dumpDir)/segverify_seg\(n).mp4"
66+
try? blob.write(to: URL(fileURLWithPath: p))
67+
print("seg\(n): dumped \(blob.count) B -> \(p)")
68+
}
69+
do {
70+
let r = try AetherEngine.swDecodeProbe(data: blob, formatHint: "mp4", maxPackets: 400)
71+
let ok = r.framesDecoded > 0
72+
if ok { independent += 1 }
73+
var line = "seg\(n): \(segData.count) B fed=\(r.packetsFedToDecoder) decoded=\(r.framesDecoded)"
74+
line += " -> \(ok ? "INDEPENDENT" : "NOT-INDEPENDENT")"
75+
if !r.openSucceeded { line += " (decoder open failed: \(r.openError ?? "?"))" }
76+
print(line)
77+
} catch {
78+
print("seg\(n): PROBE ERROR \(error)")
79+
}
80+
}
81+
82+
print("---")
83+
print("segverify: \(independent)/\(tested) segments independently decodable [\(from)..<\(last)]")
84+
if tested == 0 { return 1 }
85+
return independent == tested ? 0 : 2
86+
}

Sources/aetherctl/main.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ func printUsage() {
6969
aetherctl serve [--no-dv] <url>
7070
aetherctl validate [--no-dv] <url>
7171
aetherctl swdecode [--frames N] <url>
72+
aetherctl segverify [--from N] [--count K] [--no-dv] [--dump <dir>] <url>
73+
(#92: SW-decode each segment in isolation; framesDecoded==0 => not independent)
7274
aetherctl disc-inspect <disc.iso>
7375
aetherctl dovitest <file>
7476
aetherctl extract [--at <sec>] [--snapshot] [--width <px>] [--loops <n>] <url>
@@ -232,6 +234,22 @@ if first == "dvr" {
232234
exit(runDVR(path: path, seconds: seconds, dvrWindow: dvrWin))
233235
}
234236

237+
// #92 verifier: SW-decode each segment in isolation; framesDecoded==0 => not independently decodable.
238+
if first == "segverify" {
239+
var rest = Array(args.dropFirst(2))
240+
let fromIdx = takeIntFlag("--from", from: &rest) ?? 0
241+
let count = takeIntFlag("--count", from: &rest) ?? 12
242+
let noDV = takeFlag("--no-dv", from: &rest)
243+
let dumpDir = takeStringFlag("--dump", from: &rest)
244+
guard let urlArg = rest.first(where: { !$0.hasPrefix("--") }) else {
245+
print("ERROR: segverify requires a <url> argument")
246+
exit(64)
247+
}
248+
rest.removeAll { $0 == urlArg }
249+
rejectStrayFlags(rest, subcommand: "segverify")
250+
exit(runSegVerify(url: parseSourceURL(urlArg), from: fromIdx, count: count, dvModeAvailable: !noDV, dumpDir: dumpDir))
251+
}
252+
235253
// Rapid-seek burst repro (issue #35).
236254
if first == "seektest" {
237255
var rest = Array(args.dropFirst(2))

0 commit comments

Comments
 (0)