--- a/Package.swift +++ b/Package.swift @@ -2,43 +2,22 @@ import PackageDescription +// AetherEngine 5.8.4, vendored from upstream revision +// b05d384705792e949f22b0bd7c98ac8453cbd208. Local source changes and the +// reduced target set are documented in UPSTREAM.md. let package = Package( - name: "AetherEngine", + name: "CloudVideoAetherEnginePackage", platforms: [ .iOS(.v16), .tvOS(.v16), .macOS(.v14), ], products: [ - .library( - name: "AetherEngine", - targets: ["AetherEngine"] - ), - .library( - name: "AetherEngineSMB", - targets: ["AetherEngineSMB"] - ), - // aetherctl is intentionally not exposed as a product. The target - // uses Foundation.Process, which is unavailable on tvOS/iOS, so - // exposing it would force SPM consumers to compile it on those - // platforms. The target is preserved below so `swift build` on - // macOS still produces the CLI for upstream development. + .library(name: "CloudVideoAetherEngine", targets: ["AetherEngine"]), ], dependencies: [ - // Minimal FFmpeg build (avcodec, avformat, avutil, swresample only). - // No network stack, we use custom AVIO + URLSession for HTTP streams. - // Resolved over Git rather than a local path so consumers (and - // Xcode Cloud) can build without a sibling FFmpegBuild checkout. - .package(url: "https://github.com/superuser404notfound/FFmpegBuild", from: "2.1.0"), // 2.1.0: yadif_videotoolbox + hwupload (Metal GPU deinterlace); 2.0.0: dynamic frameworks (LGPL), zvbi GPL excision - // Pure-Swift SMB2 client (MIT) that speaks the protocol over - // NWConnection. Replaces AMSMB2/libsmb2, which EPERMs on tvOS/iOS. - // Pinned to the 0.3.x minor: SMBClient is pre-1.0 with an actively - // moving API, so allow patch updates but not a minor bump. - .package(url: "https://github.com/kishikawakatsumi/SMBClient", .upToNextMinor(from: "0.3.1")), - // libdovi (Dolby Vision RPU parser/converter). Resolved over Git like - // FFmpegBuild so consumers (and Xcode Cloud) build without a sibling - // LibDovi checkout; the prebuilt xcframework needs no Rust at build time. - .package(url: "https://github.com/superuser404notfound/LibDovi", from: "1.0.2"), // 1.0.2: iOS slices + x86_64 (Intel Macs) + .package(url: "https://github.com/superuser404notfound/FFmpegBuild", from: "2.1.0"), + .package(url: "https://github.com/superuser404notfound/LibDovi", from: "1.0.2"), ], targets: [ .target( @@ -56,28 +35,9 @@ .linkedFramework("AudioToolbox"), ] ), - .target( - name: "AetherEngineSMB", - dependencies: [ - "AetherEngine", - .product(name: "SMBClient", package: "SMBClient"), - ], - path: "Sources/AetherEngineSMB" - ), - .executableTarget( - name: "aetherctl", - dependencies: ["AetherEngine", "AetherEngineSMB"], - path: "Sources/aetherctl" - ), .testTarget( name: "AetherEngineTests", - dependencies: ["AetherEngine"], - path: "Tests/AetherEngineTests" + dependencies: ["AetherEngine"] ), - .testTarget( - name: "AetherEngineSMBTests", - dependencies: ["AetherEngineSMB"], - path: "Tests/AetherEngineSMBTests" - ), ] ) --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -539,10 +539,9 @@ try session.start() }.value #if os(iOS) - // AirPlay (#86): while external playback is active, serve the loopback over the device's LAN IP and - // force the media playlist, so the receiver reaches the engine-processed stream (DV/Atmos/subtitles - // preserved) and isn't handed a DV/HDR master it rejects on an SDR panel (DrHurt). Reverts on the - // reload when AirPlay ends. + // AirPlay (#86): while external playback is active, serve the loopback over the device's LAN IP. A + // source master becomes the reduced master so native subtitle renditions survive while DV supplemental + // signaling is removed; sessions without a master remain media-only. Reverts when AirPlay ends. if airPlayActive, let lanURL = airPlayPlaybackURL(base: playbackURL) { EngineLog.emit("[AirPlay] loadNative serving via \(lanURL.absoluteString)", category: .engine) playbackURL = lanURL @@ -1164,6 +1163,7 @@ var customPreopened: Demuxer? = nil if isCustomSource, let reader = customReader { let hint = customFormatHint + let probeForDiscImage = customProbeForDiscImage do { let isLiveReload = loadedOptions.isLive let discCacheKey = url.absoluteString @@ -1173,7 +1173,9 @@ // selectTitleID rebuilds the disc concat stream for the chosen title (#67). // discCacheKey reuses the disc recognition cached at load so an audio switch on a // remote ISO does not re-parse the UDF directory / playlists (#76). - try d.open(reader: reader, formatHint: hint, profile: reloadProfile, isLive: isLiveReload, selectTitleID: titleToReopen, discCacheKey: discCacheKey) + try d.open(reader: reader, formatHint: hint, profile: reloadProfile, + isLive: isLiveReload, selectTitleID: titleToReopen, + discCacheKey: discCacheKey, probeForDiscImage: probeForDiscImage) return d }.value } catch { @@ -1370,6 +1372,13 @@ // #112 (audio-switch reanchor): same collapsed-sourceTime slip on the secondary channel. selectSecondarySubtitleTrack(index: Int(secondaryEmbeddedToResume), startAt: preSwitchSourceTime) } + #if os(iOS) + // AirPlay replaces the AVPlayerItem during this reload. Re-apply the active native rendition to + // the replacement item after the embedded subtitle selection has been restored. + if airPlayActive { + setNativeSubtitleRendering(true) + } + #endif } private func elapsedMs(since start: DispatchTime) -> Int { --- a/Sources/AetherEngine/AetherEngine+Probe.swift +++ b/Sources/AetherEngine/AetherEngine+Probe.swift @@ -33,8 +33,12 @@ case .url(let u): try demuxer.open(url: u, extraHeaders: options.httpHeaders) displayURL = u - case .custom(let reader, let formatHint): - try demuxer.open(reader: reader, formatHint: formatHint) + case .custom(let reader, let formatHint, let probeForDiscImage): + try demuxer.open( + reader: reader, + formatHint: formatHint, + probeForDiscImage: probeForDiscImage + ) displayURL = URL(string: "aether-custom://source")! } defer { demuxer.close() } --- a/Sources/AetherEngine/AetherEngine+Subtitles.swift +++ b/Sources/AetherEngine/AetherEngine+Subtitles.swift @@ -739,6 +739,7 @@ } let headers = loadedOptions.httpHeaders let formatHint = customFormatHint + let probeForDiscImage = customProbeForDiscImage let w = sourceVideoWidth > 0 ? sourceVideoWidth : 1920 let h = sourceVideoHeight > 0 ? sourceVideoHeight : 1080 let startAt = startAtSeconds ?? sourceTime @@ -752,7 +753,8 @@ url: url, reader: reader, formatHint: formatHint, headers: headers, pairs: pairs, startAt: startAt, videoWidth: w, videoHeight: h, callerProbesize: probesize, callerMaxAnalyzeDuration: maxAnalyzeDuration, - selectTitleID: titleID, readToEOF: readToEOF + selectTitleID: titleID, probeForDiscImage: probeForDiscImage, + readToEOF: readToEOF ) } } @@ -811,7 +813,8 @@ pairs: [(streamIndex: Int32, store: NativeSubtitleCueStore)], startAt: Double, videoWidth: Int32, videoHeight: Int32, callerProbesize: Int64? = nil, callerMaxAnalyzeDuration: Int64? = nil, - selectTitleID: Int? = nil, readToEOF: Bool = false + selectTitleID: Int? = nil, probeForDiscImage: Bool = true, + readToEOF: Bool = false ) async { let demuxer = Demuxer() let openProfile = DemuxerOpenProfile.subtitleSideDemuxer( @@ -834,7 +837,9 @@ } do { if let reader = reader { - try demuxer.open(reader: reader, formatHint: formatHint, profile: openProfile, selectTitleID: selectTitleID, discCacheKey: url.absoluteString) + try demuxer.open(reader: reader, formatHint: formatHint, profile: openProfile, + selectTitleID: selectTitleID, discCacheKey: url.absoluteString, + probeForDiscImage: probeForDiscImage) } else { try demuxer.open(url: url, extraHeaders: headers, profile: openProfile, selectTitleID: selectTitleID) } --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -599,6 +599,11 @@ /// Format hint for the active custom source; reused on reload and when opening clones. private(set) var customFormatHint: String? + /// Whether custom-reader reopens should inspect the source as a disc image. Remote ordinary + /// media disables this so subtitle, track-switch, and thumbnail side readers do not repeat + /// sparse ISO/UDF reads over the network. + private(set) var customProbeForDiscImage = true + /// False for forward-only custom sources; reload features (audio switch, background reload) no-op for them. private(set) var customSourceIsSeekable = false @@ -1507,11 +1512,13 @@ isCustomSource = false customReader = nil customFormatHint = nil - case .custom(let reader, let hint): + customProbeForDiscImage = true + case .custom(let reader, let hint, let probeForDiscImage): url = URL(string: "aether-custom://source")! isCustomSource = true customReader = reader customFormatHint = hint + customProbeForDiscImage = probeForDiscImage } loadedURL = url loadedOptions = options @@ -1625,9 +1632,16 @@ // isLive configures the AVIOReader for endless-feed mode; must be set at open time because // the probe demuxer is reused as the session demuxer (avformat_open_input runs only once). try probe.open(url: u, extraHeaders: options.httpHeaders, profile: probeProfile, isLive: options.isLive, selectTitleID: discTitleID) - case .custom(let reader, let formatHint): + case .custom(let reader, let formatHint, let probeForDiscImage): // isLive suppresses SEEK_END duration estimate on forward-only live readers; same open-time requirement. - try probe.open(reader: reader, formatHint: formatHint, profile: probeProfile, isLive: options.isLive, selectTitleID: discTitleID) + try probe.open( + reader: reader, + formatHint: formatHint, + profile: probeProfile, + isLive: options.isLive, + selectTitleID: discTitleID, + probeForDiscImage: probeForDiscImage + ) } }.value probeOpened = true @@ -2485,8 +2499,8 @@ } /// AirPlay (#86, DrHurt): true while the native AVPlayer reports external playback. loadNative reads it to - /// serve the loopback over the device's LAN IP (the receiver can't reach 127.0.0.1) AND to force the MEDIA - /// playlist (AVPlayer rejects a DV/HDR MASTER playlist on an SDR receiver and won't auto-switch, DrHurt). + /// serve the loopback over the device's LAN IP (the receiver can't reach 127.0.0.1) and use either the + /// reduced master or the media-only playlist according to what the session produced. /// Loopback native path only; a remote-HLS source is already receiver-reachable, so it's left untouched. private(set) var airPlayActive = false private var externalPlaybackObservation: NSKeyValueObservation? @@ -2515,9 +2529,9 @@ guard wantAirPlay != airPlayActive else { return } airPlayActive = wantAirPlay // Loopback native path only: remote-HLS is already receiver-reachable. Reload so loadNative rebuilds - // the playback URL on the LAN IP + media playlist (active) or back on 127.0.0.1 master/media (inactive). + // the playback URL on the LAN IP (active) or back on 127.0.0.1 (inactive). guard playbackBackend == .native, !loadedOptions.nativeRemoteHLS, loadedURL != nil else { return } - EngineLog.emit("[AirPlay] external playback \(wantAirPlay ? "active (wireless) -> LAN media reload" : "ended -> loopback reload")", category: .engine) + EngineLog.emit("[AirPlay] external playback \(wantAirPlay ? "active (wireless) -> LAN HLS reload" : "ended -> loopback reload")", category: .engine) Task { try? await reloadAtCurrentPosition() } } @@ -2534,17 +2548,26 @@ #endif } - /// AirPlay loopback URL (#86): rewrite the loopback playback URL to the device's LAN IP and force the MEDIA - /// playlist, so the receiver reaches the engine-processed stream and isn't handed a DV/HDR master it rejects - /// on an SDR panel (DrHurt). nil if no LAN IP (caller keeps the original 127.0.0.1 URL). + /// AirPlay loopback URL (#86): rewrite the loopback playback URL to the device's LAN IP. Sessions that + /// produced a master use the reduced master, which removes DV supplemental signaling but preserves native + /// WebVTT subtitle renditions. Media-only sessions continue to use the media playlist. func airPlayPlaybackURL(base: URL) -> URL? { guard let lanIP = HLSLocalServer.localActiveIPAddress() else { return nil } var c = URLComponents(url: base, resolvingAgainstBaseURL: false) c?.host = lanIP - c?.path = "/media.m3u8" + c?.path = Self.airPlayPlaylistPath(for: base) return c?.url } + nonisolated static func airPlayPlaylistPath(for base: URL) -> String { + switch base.lastPathComponent.lowercased() { + case "master.m3u8", "master_hdr.m3u8": + return "/master_hdr.m3u8" + default: + return "/media.m3u8" + } + } + #if os(tvOS) || os(iOS) /// MPNowPlayingSession for the active AVPlayer audio path, or nil. The host registers transport commands /// and writes metadata here to stay the active Now-Playing app across a background pause (tvOS drops a @@ -2832,6 +2855,7 @@ customReader?.close() customReader = nil customFormatHint = nil + customProbeForDiscImage = true customSourceIsSeekable = false } @@ -3146,7 +3170,16 @@ // MARK: - Errors -public enum AetherEngineError: Error { +public enum AetherEngineError: Error, LocalizedError, Sendable { case noVideoStream case noAudioStream + + public var errorDescription: String? { + switch self { + case .noVideoStream: + "No video track was found." + case .noAudioStream: + "No audio track was found." + } + } } --- a/Sources/AetherEngine/Audio/AudioDecoder.swift +++ b/Sources/AetherEngine/Audio/AudioDecoder.swift @@ -406,11 +406,28 @@ } } -enum AudioDecoderError: Error { +public enum AudioDecoderError: Error, LocalizedError, Sendable { case noCodecParameters case unsupportedCodec case contextAllocationFailed case parameterCopyFailed case openFailed case formatDescriptionFailed + + public var errorDescription: String? { + switch self { + case .noCodecParameters: + "Missing audio codec information." + case .unsupportedCodec: + "Unsupported audio codec." + case .contextAllocationFailed: + "Unable to create the audio decoder." + case .parameterCopyFailed: + "Unable to configure the audio decoder." + case .openFailed: + "Unable to open the audio decoder." + case .formatDescriptionFailed: + "Unable to read the audio format." + } + } } --- a/Sources/AetherEngine/Decoder/VideoDecoderTypes.swift +++ b/Sources/AetherEngine/Decoder/VideoDecoderTypes.swift @@ -28,14 +28,14 @@ func close() } -enum VideoDecoderError: Error, LocalizedError { +public enum VideoDecoderError: Error, LocalizedError, Sendable { case noCodecParameters case unsupportedCodec(id: UInt32) case noExtradata case formatDescriptionFailed(status: OSStatus) case sessionCreationFailed(status: OSStatus) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .noCodecParameters: "No codec parameters" case .unsupportedCodec(let id): "Unsupported video codec (id: \(id))" --- a/Sources/AetherEngine/Demuxer/Demuxer.swift +++ b/Sources/AetherEngine/Demuxer/Demuxer.swift @@ -314,9 +314,10 @@ /// no filename is available. `isLive` suppresses SEEK_END that latches EOF /// on forward-only readers (38ad60b). AetherEngine#36: DiscReader adapts /// DVD/BD ISOs to VOB/MPEGTS concat streams. - func open(reader: IOReader, formatHint: String? = nil, profile: DemuxerOpenProfile = .playback, isLive: Bool = false, selectTitleID: Int? = nil, discCacheKey: String? = nil) throws { + func open(reader: IOReader, formatHint: String? = nil, profile: DemuxerOpenProfile = .playback, isLive: Bool = false, selectTitleID: Int? = nil, discCacheKey: String? = nil, probeForDiscImage: Bool = true) throws { self.openProfile = profile - if let discInfo = try DiscReader.wrap(reader, selectTitleID: selectTitleID, cacheKey: discCacheKey) { + if probeForDiscImage, + let discInfo = try DiscReader.wrap(reader, selectTitleID: selectTitleID, cacheKey: discCacheKey) { adoptDiscInfo(discInfo) let bridge = CustomIOReaderBridge(reader: discInfo.reader) let inputFormat = av_find_input_format(discInfo.formatHint) @@ -1190,8 +1191,19 @@ } } -enum DemuxerError: Error { +public enum DemuxerError: Error, LocalizedError, Sendable { case openFailed(code: Int32) case streamInfoFailed(code: Int32) case readFailed(code: Int32) + + public var errorDescription: String? { + switch self { + case .openFailed(let code): + "Unable to open the media container (code \(code))." + case .streamInfoFailed(let code): + "Unable to read the media tracks (code \(code))." + case .readFailed(let code): + "Unable to continue reading the media data (code \(code))." + } + } } --- a/Sources/AetherEngine/FrameExtractor/AetherEngine+FrameExtractor.swift +++ b/Sources/AetherEngine/FrameExtractor/AetherEngine+FrameExtractor.swift @@ -59,6 +59,7 @@ // needs an independent reader; nil (scrub skipped) if the source can't clone. guard let clone = customReader?.makeIndependentReader() else { return nil } return FrameExtractor(reader: clone, formatHint: customFormatHint, + probeForDiscImage: customProbeForDiscImage, yieldWhile: sessionYieldSignal()) } guard let url = loadedURL else { return nil } --- a/Sources/AetherEngine/FrameExtractor/FrameDecodeContext.swift +++ b/Sources/AetherEngine/FrameExtractor/FrameDecodeContext.swift @@ -18,6 +18,7 @@ /// idle-reopen path can rebuild over the still-alive reader). private let reader: IOReader? private let formatHint: String? + private let probeForDiscImage: Bool /// For a disc image (Blu-ray / DVD ISO) URL, the title to extract stills from. nil = the default /// (main) title. Threaded into `Demuxer.open` so a still follows the currently-selected disc title /// instead of always decoding the default one (AE#105). @@ -101,15 +102,17 @@ self.httpHeaders = httpHeaders self.reader = nil self.formatHint = nil + self.probeForDiscImage = true self.selectTitleID = selectTitleID } - init(reader: IOReader, formatHint: String?) { + init(reader: IOReader, formatHint: String?, probeForDiscImage: Bool) { // Placeholder; unused when reader != nil (openInternal opens the reader). self.url = URL(string: "aether-custom://frame-extractor")! self.httpHeaders = [:] self.reader = reader self.formatHint = formatHint + self.probeForDiscImage = probeForDiscImage self.selectTitleID = nil } @@ -151,7 +154,8 @@ private func openInternal() throws { let demuxer = Demuxer() if let reader = reader { - try demuxer.open(reader: reader, formatHint: formatHint, profile: .stillExtraction) + try demuxer.open(reader: reader, formatHint: formatHint, profile: .stillExtraction, + probeForDiscImage: probeForDiscImage) } else { try demuxer.open(url: url, extraHeaders: httpHeaders, profile: .stillExtraction, selectTitleID: selectTitleID) } --- a/Sources/AetherEngine/FrameExtractor/FrameExtractor.swift +++ b/Sources/AetherEngine/FrameExtractor/FrameExtractor.swift @@ -62,8 +62,10 @@ /// Construct over a custom `IOReader` source (a clone with its own cursor). /// The extractor owns the reader and closes it on teardown. public init(reader: IOReader, formatHint: String? = nil, + probeForDiscImage: Bool = true, yieldWhile: (@Sendable () -> Bool)? = nil) { - self.init(context: FrameDecodeContext(reader: reader, formatHint: formatHint), + self.init(context: FrameDecodeContext(reader: reader, formatHint: formatHint, + probeForDiscImage: probeForDiscImage), yieldWhile: yieldWhile) } --- a/Sources/AetherEngine/IO/IOReader.swift +++ b/Sources/AetherEngine/IO/IOReader.swift @@ -26,5 +26,12 @@ public enum MediaSource: Sendable { case url(URL) /// `formatHint`: optional container short name ("mp4", "matroska", "mpegts") to disambiguate probing when no filename is present; nil probes from content. - case custom(IOReader, formatHint: String? = nil) + /// `probeForDiscImage`: keep enabled for raw ISO/UDF sources. Disable it when the caller already + /// knows the custom reader represents an ordinary media file, avoiding remote random reads used + /// solely to sniff ISO 9660/UDF signatures before the supplied container hint is applied. + case custom( + IOReader, + formatHint: String? = nil, + probeForDiscImage: Bool = true + ) } --- a/Tests/AetherEngineTests/AirPlayPlaylistRoutingTests.swift +++ b/Tests/AetherEngineTests/AirPlayPlaylistRoutingTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import AetherEngine + +@Suite("AirPlay playlist routing") +struct AirPlayPlaylistRoutingTests { + @Test("Master playlists retain subtitle renditions") + func masterUsesReducedMaster() { + let base = URL(string: "http://127.0.0.1:1234/master.m3u8")! + #expect(AetherEngine.airPlayPlaylistPath(for: base) == "/master_hdr.m3u8") + } + + @Test("Media-only sessions remain media-only") + func mediaStaysMedia() { + let base = URL(string: "http://127.0.0.1:1234/media.m3u8")! + #expect(AetherEngine.airPlayPlaylistPath(for: base) == "/media.m3u8") + } +} --- a/Tests/AetherEngineTests/CustomDiscProbePolicyTests.swift +++ b/Tests/AetherEngineTests/CustomDiscProbePolicyTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import AetherEngine + +final class CustomDiscProbePolicyTests: XCTestCase { + func testDisabledDiscProbeSkipsISOAndUDFSignatureOffsets() { + let reader = TrackingIOReader() + let demuxer = Demuxer() + + XCTAssertThrowsError( + try demuxer.open( + reader: reader, + formatHint: "matroska", + probeForDiscImage: false + ) + ) + + XCTAssertFalse(reader.seekOffsets.contains(0x8001)) + XCTAssertFalse(reader.seekOffsets.contains(256 * 2048)) + } + + func testDefaultDiscProbeChecksISOAndUDFSignatureOffsets() { + let reader = TrackingIOReader() + let demuxer = Demuxer() + + XCTAssertThrowsError( + try demuxer.open(reader: reader, formatHint: "matroska") + ) + + XCTAssertTrue(reader.seekOffsets.contains(0x8001)) + XCTAssertTrue(reader.seekOffsets.contains(256 * 2048)) + } +} + +private final class TrackingIOReader: IOReader, @unchecked Sendable { + private let lock = NSLock() + private var offset: Int64 = 0 + private(set) var seekOffsets: [Int64] = [] + + func read(_ buffer: UnsafeMutablePointer?, size: Int32) -> Int32 { + guard let buffer, size > 0 else { return 0 } + let count = min(Int(size), 4096) + buffer.initialize(repeating: 0, count: count) + lock.withLock { offset += Int64(count) } + return Int32(count) + } + + func seek(offset requestedOffset: Int64, whence: Int32) -> Int64 { + lock.withLock { + if whence & 0x10000 != 0 { return 4096 } + guard whence & ~0x20000 == SEEK_SET else { return -1 } + offset = requestedOffset + seekOffsets.append(requestedOffset) + return requestedOffset + } + } + + func close() {} +}