Skip to content

Commit 9a98899

Browse files
feat(cli): standalone aetherctl harness for offline HLSVideoEngine repro
Adds a small executable target that wraps `HLSVideoEngine` so we can reproduce the host-side AVPlayer hang against a real source on macOS without round-tripping through TestFlight + Apple TV. Argument is a file:// or http(s):// source the engine demuxes; once it parks on a loopback URL we can poke at the manifests + segments with curl, mediastreamvalidator, mp4dump, ffprobe, or just `open` the URL in QuickTime / macOS AVPlayer to see whether the desktop player reproduces the same waitingToPlay-with-no-TCP behaviour. `HLSVideoEngine`, its error enum, and `init` / `start` / `stop` are now `public` so the CLI can drive them directly. No behaviour change on existing call sites; the host's `AetherEngine.startNativeVideo Session` still wraps them the same way. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c41e5e1 commit 9a98899

3 files changed

Lines changed: 103 additions & 7 deletions

File tree

Package.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ let package = Package(
1414
name: "AetherEngine",
1515
targets: ["AetherEngine"]
1616
),
17+
// Standalone CLI used for offline reproduction of the host-side
18+
// HLSVideoEngine playback symptoms on macOS, without going
19+
// through TestFlight + Apple TV. Builds only on macOS in
20+
// practice (Apple platform reqs match the lib target).
21+
.executable(name: "aetherctl", targets: ["aetherctl"]),
1722
],
1823
dependencies: [
1924
// Minimal FFmpeg build (avcodec, avformat, avutil, swresample only).
@@ -36,5 +41,10 @@ let package = Package(
3641
.linkedFramework("AudioToolbox"),
3742
]
3843
),
44+
.executableTarget(
45+
name: "aetherctl",
46+
dependencies: ["AetherEngine"],
47+
path: "Sources/aetherctl"
48+
),
3949
]
4050
)

Sources/AetherEngine/Video/HLSVideoEngine.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import Libavutil
1919
/// Phase 4 of the rollout: video-only fragments. Audio routing into
2020
/// the same fMP4 stream lands in phase 6. AVPlayer will play silent
2121
/// video for now, which is enough to verify the DV signalling path.
22-
final class HLSVideoEngine: @unchecked Sendable {
22+
public final class HLSVideoEngine: @unchecked Sendable {
2323

2424
// MARK: - Errors
2525

26-
enum HLSVideoEngineError: Error, CustomStringConvertible, LocalizedError {
26+
public enum HLSVideoEngineError: Error, CustomStringConvertible, LocalizedError {
2727
case openFailed(reason: String)
2828
case noVideoStream
2929
case unsupportedCodec(rawCodecID: UInt32)
@@ -33,7 +33,7 @@ final class HLSVideoEngine: @unchecked Sendable {
3333
case alreadyStarted
3434
case notStarted
3535

36-
var description: String {
36+
public var description: String {
3737
switch self {
3838
case .openFailed(let r): return "HLSVideoEngine: open failed (\(r))"
3939
case .noVideoStream: return "HLSVideoEngine: source has no video stream"
@@ -46,7 +46,7 @@ final class HLSVideoEngine: @unchecked Sendable {
4646
}
4747
}
4848

49-
var errorDescription: String? { description }
49+
public var errorDescription: String? { description }
5050
}
5151

5252
/// DV profile + base-layer compatibility classification per the
@@ -115,15 +115,15 @@ final class HLSVideoEngine: @unchecked Sendable {
115115
/// authoring recommendation.
116116
private static let targetSegmentDuration: Double = 6.0
117117

118-
init(url: URL) {
118+
public init(url: URL) {
119119
self.sourceURL = url
120120
}
121121

122122
// MARK: - Public API
123123

124124
/// Open the source, build the segment plan, start the server,
125125
/// return the URL the host hands to AVPlayer.
126-
func start() throws -> URL {
126+
public func start() throws -> URL {
127127
guard demuxer == nil else { throw HLSVideoEngineError.alreadyStarted }
128128

129129
// 1. Open the source. Reuses AetherEngine's existing AVIO +
@@ -389,7 +389,7 @@ final class HLSVideoEngine: @unchecked Sendable {
389389
return url
390390
}
391391

392-
func stop() {
392+
public func stop() {
393393
server?.stop()
394394
server = nil
395395
provider?.close()

Sources/aetherctl/main.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// aetherctl: standalone reproduction harness for HLSVideoEngine on macOS.
2+
//
3+
// Spins up the same engine the tvOS app uses, against any source URL
4+
// (file:// or http(s)://), and parks the loopback HLS-fMP4 server in
5+
// the foreground so curl / mediastreamvalidator / mp4dump / ffprobe
6+
// can poke at the manifests + segments without an Apple TV in the
7+
// loop. The build-122 spinner-and-back symptom on tvOS isn't
8+
// reproducible from the device side without TestFlight cycles; this
9+
// CLI lets us iterate locally.
10+
11+
import Foundation
12+
import AetherEngine
13+
14+
let args = CommandLine.arguments
15+
guard args.count >= 2 else {
16+
print("usage: aetherctl <url>")
17+
print("")
18+
print(" url: file:// or http(s):// to a video source the engine demuxes")
19+
print(" (typically a Dolby Vision MKV, same kind of source the")
20+
print(" tvOS app's `.native` route would feed to AVPlayer).")
21+
print("")
22+
print("Once the engine is running it prints the loopback URL it served.")
23+
print("Useful next steps from another terminal:")
24+
print(" curl -i http://127.0.0.1:<port>/master.m3u8")
25+
print(" curl -o /tmp/init.mp4 http://127.0.0.1:<port>/init.mp4")
26+
print(" curl -o /tmp/seg0.mp4 http://127.0.0.1:<port>/seg0.mp4")
27+
print(" mediastreamvalidator http://127.0.0.1:<port>/master.m3u8")
28+
print(" mp4dump --verbosity 1 /tmp/init.mp4")
29+
print(" ffprobe -v debug /tmp/seg0.mp4")
30+
print(" open 'http://127.0.0.1:<port>/master.m3u8' # macOS QuickTime")
31+
print("")
32+
exit(64)
33+
}
34+
35+
let raw = args[1]
36+
let sourceURL: URL = {
37+
if let parsed = URL(string: raw), parsed.scheme != nil {
38+
return parsed
39+
}
40+
return URL(fileURLWithPath: raw)
41+
}()
42+
43+
// Mirror what the tvOS app does: route every engine log to stdout
44+
// instead of into a host overlay buffer, so the CLI session reads
45+
// linearly.
46+
EngineLog.handler = { line in
47+
let timestamp = ISO8601DateFormatter.string(from: Date(),
48+
timeZone: .current,
49+
formatOptions: [.withTime, .withFractionalSeconds])
50+
print("[\(timestamp)] \(line)")
51+
}
52+
53+
print("aetherctl: opening \(sourceURL.absoluteString)")
54+
print("")
55+
56+
let engine = HLSVideoEngine(url: sourceURL)
57+
let playbackURL: URL
58+
do {
59+
playbackURL = try engine.start()
60+
} catch {
61+
print("ERROR: \(error)")
62+
exit(1)
63+
}
64+
65+
print("")
66+
print("=== PLAYBACK URL ===")
67+
print(playbackURL.absoluteString)
68+
print("====================")
69+
print("")
70+
print("Engine is parked. Hit Ctrl-C to tear down.")
71+
print("")
72+
73+
// Trap SIGINT to clean up so the next run can rebind the same
74+
// (ephemeral) port if needed and so the demuxer's HTTP session
75+
// doesn't leak.
76+
signal(SIGINT, SIG_IGN)
77+
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
78+
sigintSource.setEventHandler {
79+
print("")
80+
print("aetherctl: SIGINT, stopping engine")
81+
engine.stop()
82+
exit(0)
83+
}
84+
sigintSource.resume()
85+
86+
RunLoop.main.run()

0 commit comments

Comments
 (0)