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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,14 @@ A test rig that only captures stdout / stderr (`devicectl device process launch
EngineLog.handler = { print($0) } // every info-level line, verbatim
```

The first line of any session names the FFmpeg that answered, because which one does is decided by your executable's link rather than by the package graph:

```
[FFmpeg] libavcodec 62.28.102, libavformat 62.6.100, libavutil 60.13.100, libswresample 6.0.100
```

If a second FFmpeg in the app takes those symbols, that line turns into an `ERROR:` naming the mismatch and how to find it. Worth reading once per integration: the failures a wrong FFmpeg produces look like engine defects, and one cost a reporter five fixtures and two devices before anyone looked at the link. Details in [docs/api.md › One FFmpeg](docs/api.md#one-ffmpeg-and-it-has-to-be-the-engines).

The handler fires from whatever thread emitted the line (demuxer, producer pump, local server, audio bridge), so it must be thread-safe and non-blocking; serialize onto a queue before writing to a file. Per-segment trace lines are emitted at `.verbose` and reach os_log's debug level only, never the handler, so the mirrored stream stays readable. `aetherctl` installs exactly this handler, which is why the CLI prints what the app hides.

## Non-goals
Expand All @@ -509,7 +517,7 @@ Things AetherEngine deliberately doesn't do, so you don't have to read the sourc

Browse all of this as a searchable site at **[aetherengine.superuser404.de](https://aetherengine.superuser404.de)**, or read the source Markdown here:

- **[docs/api.md](docs/api.md)**: every public surface a host consumes, and the contracts that require it to act (how a load ends, `.ended`, the live retune, the audio tap, what must be set before `load`). A test fails the build when a host-facing public symbol is named nowhere in the docs.
- **[docs/api.md](docs/api.md)**: every public surface a host consumes, and the contracts that require it to act (how a load ends, `.ended`, the live retune, the audio tap, what must be set before `load`, and which FFmpeg your link hands the engine). A test fails the build when a host-facing public symbol is named nowhere in the docs.
- **[docs/architecture.md](docs/architecture.md)**: the three playback pipelines, the source-file map, dependencies, the SwiftUI `Menu` pattern.
- **[docs/formats.md](docs/formats.md)**: codec / container coverage, HDR routing, audio bridging, subtitles, frame extraction, disc playback, live ingest, and known limitations.
- **[docs/cli.md](docs/cli.md)**: the `aetherctl` repro CLI (twenty-one subcommands).
Expand Down Expand Up @@ -563,3 +571,5 @@ The exception covers AetherEngine's own code; it does not extend to its dependen
### Linking the engine statically

An SPM library product links statically by default, and for the engine itself that is the intended shape on the App Store path. LGPL-3.0 section 4(d)(0) would otherwise ask for your application's object code in a relinkable form; the exception's fourth bullet names end-user re-linking explicitly, so that half does not apply. What the exception does not waive is the source side: point at the exact tag you built against rather than the repository root, and if you patched the engine, publish the patched source under LGPL. No separate written offer is needed while that pointer resolves. The dependencies above keep their own terms either way, which is why the FFmpeg frameworks have to stay dynamically embedded in `YourApp.app/Frameworks/` instead of merged into the app binary.

Being dynamic is not the same as being reached. The engine calls `avcodec_*` as ordinary external symbols, so a second FFmpeg elsewhere in the app can serve them instead: a static archive pulled in with `-force_load` becomes a definition inside your executable and beats every dylib, and a dependency that exports the same symbols (libVLC does, and CocoaPods sorts a pod ahead of a vendored framework) wins on order alone. Link AetherEngine's frameworks first. `nm -m <executable> | grep _avcodec_find_encoder` says who currently wins; the engine's own startup line says the same thing from the inside.
2 changes: 2 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2493,6 +2493,8 @@ public final class AetherEngine: ObservableObject {
public init() throws {
// Route av_log into EngineLog before any libav* entry point so probe/load diagnostics are captured.
FFmpegLogBridge.install()
// Which FFmpeg answers is decided by the host's link, not by the package graph (AE#396).
FFmpegRuntimeCheck.logOnce()
_ = DeinterlaceHardwareWarmup.shared

// Declare category + multichannel support but do NOT activate the session here.
Expand Down
123 changes: 123 additions & 0 deletions Sources/AetherEngine/Diagnostics/FFmpegRuntimeCheck.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import Libavcodec
import Libavformat
import Libavutil
import Libswresample

/// Witnesses which FFmpeg actually answers in this process.
///
/// AetherEngine calls `avcodec_*` / `avformat_*` as ordinary external symbols, so which binary
/// serves them is decided by the host's link, not by the package graph. A second FFmpeg in the
/// executable wins wherever it sorts first: a static archive pulled in with `-force_load` becomes a
/// definition in the binary itself and beats every dylib, and a dependency that re-exports the same
/// symbols (libVLC does) wins whenever the build system lists it ahead of a vendored framework.
/// The engine then executes against headers it was never compiled against.
///
/// AE#396 was exactly that, and cost its reporter five fixtures and two devices, because the only
/// trace was `flac bridge encoder absent from this FFmpeg build`: a sentence about the build that
/// answered, which read as a sentence about ours. Nothing here fixes a bad link (nothing in a
/// library can), it only makes the first minute say so.
enum FFmpegRuntimeCheck {

/// One linked FFmpeg library: what the headers declared when this engine compiled, against what
/// the loaded binary reports now.
struct Library: Sendable, Equatable {
let name: String
/// `LIB<NAME>_VERSION_MAJOR` as seen by the compiler.
let compiledMajor: UInt32
/// `<name>_version()` from whichever binary served the call: major<<16 | minor<<8 | micro.
let loadedVersion: UInt32

var loadedMajor: UInt32 { loadedVersion >> 16 }
var loadedVersionString: String {
"\(loadedMajor).\((loadedVersion >> 8) & 0xFF).\(loadedVersion & 0xFF)"
}
/// Only the major is load-bearing: FFmpeg keeps ABI within a major and breaks it across one.
var matches: Bool { loadedMajor == compiledMajor }
}

static var loadedLibraries: [Library] {
[
Library(name: "libavcodec",
compiledMajor: UInt32(LIBAVCODEC_VERSION_MAJOR),
loadedVersion: avcodec_version()),
Library(name: "libavformat",
compiledMajor: UInt32(LIBAVFORMAT_VERSION_MAJOR),
loadedVersion: avformat_version()),
Library(name: "libavutil",
compiledMajor: UInt32(LIBAVUTIL_VERSION_MAJOR),
loadedVersion: avutil_version()),
Library(name: "libswresample",
compiledMajor: UInt32(LIBSWRESAMPLE_VERSION_MAJOR),
loadedVersion: swresample_version()),
]
}

/// The configure line of the libavcodec that answered. This is the value the encoder-cascade
/// message is really about: whether `--enable-encoder=flac` is present decides that path.
static var loadedConfiguration: String? {
guard let raw = avcodec_configuration() else { return nil }
return String(validatingCString: raw)
}

/// Compact identity for messages that describe the FFmpeg build's contents, so a claim about
/// "this FFmpeg build" always says which one. Silent about the expected major while it matches.
static func identity(of library: Library) -> String {
library.matches
? "\(library.name) \(library.loadedVersionString)"
: "\(library.name) \(library.loadedVersionString), NOT the \(library.compiledMajor).x this engine was built against"
}

static var avcodecIdentity: String {
guard let avcodec = loadedLibraries.first(where: { $0.name == "libavcodec" }) else {
return "libavcodec (version unavailable)"
}
return identity(of: avcodec)
}

/// nil while every major matches. Otherwise a line that has to survive being read by someone who
/// believes the engine is at fault, so it names the mismatch, the cause, and the two commands
/// that show it.
static func mismatchReport(for libraries: [Library], configuration: String?) -> String? {
let mismatched = libraries.filter { !$0.matches }
guard !mismatched.isEmpty else { return nil }

let list = mismatched
.map { "\($0.name) compiled against \($0.compiledMajor).x, loaded \($0.loadedVersionString)" }
.joined(separator: "; ")

var text = "ERROR: AetherEngine is executing against a different FFmpeg than it was built against: "
+ "\(list). A second FFmpeg sorts ahead of AetherEngine's frameworks in this executable's link "
+ "(a static archive force-loaded into the binary, or another dependency exporting the same "
+ "symbols, libVLC among them). `nm -m <executable> | grep _avcodec_find_encoder` names the "
+ "binary that wins and `otool -L <executable>` shows the order; link AetherEngine's frameworks "
+ "first, or remove the second copy. Decoding, encoding and struct layouts are undefined past "
+ "this point, and any message about what this FFmpeg build contains describes that one, not ours."
if let configuration {
text += " Loaded libavcodec configuration: \(configuration)"
}
return text
}

/// What the engine says about its FFmpeg at startup: the mismatch report when there is one, and
/// otherwise the four loaded versions. Healthy sessions carry it too, because "which FFmpeg
/// answered" is the question a host cannot reconstruct afterwards from a silent log.
static var verdictLine: String {
let libraries = loadedLibraries
if let report = mismatchReport(for: libraries, configuration: loadedConfiguration) {
return report
}
return libraries.map { "\($0.name) \($0.loadedVersionString)" }.joined(separator: ", ")
}

/// Emits the verdict once per process. Called from `AetherEngine.init`, after the av_log bridge
/// is installed so the line lands in the host's sink like every other diagnostic.
static func logOnce() {
_ = hasLogged
}

private static let hasLogged: Bool = {
EngineLog.emit("[FFmpeg] \(verdictLine)", category: .engine)
return true
}()
}
14 changes: 12 additions & 2 deletions Sources/AetherEngine/Video/HLSVideoEngine+AudioRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ extension HLSVideoEngine {
avcodec_get_name(id).map { String(cString: $0) } ?? "id \(id.rawValue)"
}

/// AE#396: "absent from this FFmpeg build" was true and useless, because the build that answered
/// was a second FFmpeg the host had linked ahead of AetherEngine's. The sentence read as a claim
/// about our build, so the reporter spent five fixtures and two devices before anyone looked at
/// the link. Naming the libavcodec that actually answered makes it a question about the process.
static func encoderAbsentMessage(missing: AVCodecID, cascadingTo: AVCodecID) -> String {
"[HLSVideoEngine] \(encoderLabel(missing)) bridge encoder absent from "
+ "\(FFmpegRuntimeCheck.avcodecIdentity), cascading to \(encoderLabel(cascadingTo))"
}

/// Guards `audioSourceStreamIndexOverride` against stale picker selections from a previous title.
static func isAudioStream(demuxer: Demuxer, index: Int32) -> Bool {
guard index >= 0, let stream = demuxer.stream(at: index) else {
Expand Down Expand Up @@ -210,8 +219,9 @@ extension HLSVideoEngine {
)
} catch AudioBridge.AudioBridgeError.encoderNotFound(let missing) where !isLastAttempt {
EngineLog.emit(
"[HLSVideoEngine] \(Self.encoderLabel(missing)) bridge encoder absent from this FFmpeg build, "
+ "cascading to \(Self.encoderLabel(AudioBridge.alternateEncoder(to: missing)))",
Self.encoderAbsentMessage(
missing: missing,
cascadingTo: AudioBridge.alternateEncoder(to: missing)),
category: .session
)
continue attempts
Expand Down
132 changes: 132 additions & 0 deletions Tests/AetherEngineTests/FFmpegRuntimeCheckTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import XCTest
import Libavcodec
import Libavformat
import Libavutil
import Libswresample
@testable import AetherEngine

/// AE#396 cost a downstream adopter five fixtures and two devices, and the defect was never in the
/// engine: their app linked a second FFmpeg (a static archive, `-force_load`ed, so its `avcodec_*`
/// became ordinary definitions in the executable and beat our dylib for every other object in the
/// same link). The engine expects libavcodec 62 and was executing against 61. The only trace it left
/// was `flac bridge encoder absent from this FFmpeg build` - a sentence about a build that was not
/// ours, and reads as a statement about ours.
///
/// The engine could not notice, because `avcodec_version` and `avcodec_configuration` appeared zero
/// times in `Sources/`. These tests pin the witness that closes that hole: what the headers said at
/// compile time against what the loaded binary answers at runtime.
///
/// The sharp one is `loadedLibrariesMatchTheHeadersThisBuildCompiledAgainst`. In a host with a
/// second FFmpeg ahead of ours in the link, that test is the one that fails.
final class FFmpegRuntimeCheckTests: XCTestCase {

// MARK: - The report

private func library(_ name: String, compiled: UInt32, loaded: (UInt32, UInt32, UInt32)) -> FFmpegRuntimeCheck.Library {
FFmpegRuntimeCheck.Library(
name: name,
compiledMajor: compiled,
loadedVersion: (loaded.0 << 16) | (loaded.1 << 8) | loaded.2)
}

func testReportIsNilWhenEveryMajorMatches() {
let libs = [
library("libavcodec", compiled: 62, loaded: (62, 28, 102)),
library("libavutil", compiled: 60, loaded: (60, 13, 100)),
]
XCTAssertNil(FFmpegRuntimeCheck.mismatchReport(for: libs, configuration: "--enable-encoder=flac"))
}

func testReportNamesTheLibraryAndBothMajors() throws {
let libs = [library("libavcodec", compiled: 62, loaded: (61, 19, 101))]
let report = try XCTUnwrap(FFmpegRuntimeCheck.mismatchReport(for: libs, configuration: nil))
XCTAssertTrue(report.contains("libavcodec"), report)
XCTAssertTrue(report.contains("62"), "the compiled-against major must be named: \(report)")
XCTAssertTrue(report.contains("61.19.101"), "the loaded version must be named in full: \(report)")
}

func testReportBlamesASecondFFmpegRatherThanTheEnginesOwn() throws {
let libs = [library("libavcodec", compiled: 62, loaded: (61, 19, 101))]
let report = try XCTUnwrap(FFmpegRuntimeCheck.mismatchReport(for: libs, configuration: nil))
// A reader who sees this line has to end up looking at their link, not at our build.
XCTAssertTrue(report.lowercased().contains("link"),
"the report must point at the link order, that is the only place a host can fix it: \(report)")
XCTAssertTrue(report.contains("nm -m") || report.contains("otool"),
"the report must name the command that shows which binary wins: \(report)")
}

func testReportListsEveryMismatchedLibraryAndNoMatchingOne() throws {
let libs = [
library("libavcodec", compiled: 62, loaded: (61, 19, 101)),
library("libavformat", compiled: 62, loaded: (61, 7, 100)),
library("libavutil", compiled: 60, loaded: (60, 13, 100)),
]
let report = try XCTUnwrap(FFmpegRuntimeCheck.mismatchReport(for: libs, configuration: nil))
XCTAssertTrue(report.contains("libavcodec"), report)
XCTAssertTrue(report.contains("libavformat"), report)
XCTAssertFalse(report.contains("libavutil"),
"a library that matches is noise in a report about the ones that do not: \(report)")
}

func testReportCarriesTheLoadedConfigurationWhenThereIsOne() throws {
let libs = [library("libavcodec", compiled: 62, loaded: (61, 19, 101))]
let report = try XCTUnwrap(
FFmpegRuntimeCheck.mismatchReport(for: libs, configuration: "--enable-encoder=eac3"))
// The encoder-absence line is the symptom this witness explains; the configuration of the
// binary that actually answered is what makes the two legible together.
XCTAssertTrue(report.contains("--enable-encoder=eac3"), report)
}

// MARK: - The process this test runs in

func testLoadedLibrariesCoverEveryFFmpegLibraryTheEngineLinks() {
let names = Set(FFmpegRuntimeCheck.loadedLibraries.map(\.name))
XCTAssertEqual(names, ["libavcodec", "libavformat", "libavutil", "libswresample"])
}

/// The sharp one. Fails in exactly the situation AE#396 turned out to be.
func testLoadedLibrariesMatchTheHeadersThisBuildCompiledAgainst() {
for lib in FFmpegRuntimeCheck.loadedLibraries {
XCTAssertEqual(
lib.loadedMajor, lib.compiledMajor,
"\(lib.name): compiled against \(lib.compiledMajor), loaded \(lib.loadedVersionString). "
+ "A second FFmpeg is ahead of AetherEngine's in this link.")
}
XCTAssertNil(FFmpegRuntimeCheck.mismatchReport(
for: FFmpegRuntimeCheck.loadedLibraries,
configuration: FFmpegRuntimeCheck.loadedConfiguration))
}

func testLoadedConfigurationIsTheOneTheEngineShips() throws {
let configuration = try XCTUnwrap(FFmpegRuntimeCheck.loadedConfiguration)
// Both bridge encoders are FFmpegBuild's; their absence is what AE#396 reported.
XCTAssertTrue(configuration.contains("--enable-encoder=flac"), configuration)
XCTAssertTrue(configuration.contains("--enable-encoder=eac3"), configuration)
}

// MARK: - The identity the encoder-absence line carries

func testAvcodecIdentityNamesOnlyTheVersionWhenItMatches() {
let identity = FFmpegRuntimeCheck.identity(
of: library("libavcodec", compiled: 62, loaded: (62, 28, 102)))
XCTAssertEqual(identity, "libavcodec 62.28.102")
}

func testAvcodecIdentityNamesTheExpectedMajorWhenItDoesNot() {
let identity = FFmpegRuntimeCheck.identity(
of: library("libavcodec", compiled: 62, loaded: (61, 19, 101)))
XCTAssertTrue(identity.contains("61.19.101"), identity)
XCTAssertTrue(identity.contains("62"), "an unexpected build has to say what was expected: \(identity)")
}

// MARK: - The line the engine emits at init

func testVerdictLineNamesEveryLinkedLibraryWithItsVersionWhenHealthy() {
let line = FFmpegRuntimeCheck.verdictLine
for lib in FFmpegRuntimeCheck.loadedLibraries {
XCTAssertTrue(line.contains("\(lib.name) \(lib.loadedVersionString)"),
"\(lib.name) missing from the init line: \(line)")
}
XCTAssertFalse(line.contains("ERROR:"), line)
}
}
Loading
Loading