Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ the public-API contract.
after its response head reports the hop that answered and its status. The observer does not alter
request policy and requires hosts to redact source URLs.

- **Precise pixel aspect ratios no longer fall back to square pixels.** The sanity gate bounds the
ratio's magnitude instead of each rational component, preserving values such as 759:712 while
still rejecting extreme pixel shapes and implausible resulting display aspects.

- **A remote disc image on an extensionless URL is recognized by its `Content-Type` (AE#860, thanks @qoli).**
The HTTP disc adapter was gated on the `.iso` / `.img` / `.udf` extension alone, so an ISO behind an
opaque proxy path reached libavformat as raw filesystem bytes and failed with `Invalid data found
Expand Down
21 changes: 13 additions & 8 deletions Sources/AetherEngine/Decoder/PixelAspectPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import AetherLibavutil
/// Whether a declared sample aspect ratio deserves to be believed. Two gates that catch different
/// lies; both are cheap enough to run per frame.
///
/// #177 (components): live TS has been seen carrying 1088:1 on the codec context. No legitimate
/// pixel aspect ratio needs a component above 256.
/// #177 (magnitude): live TS has been seen carrying 1088:1 on the codec context. Reject extreme
/// pixel shapes, not large rational components: a container can precisely describe ordinary
/// non-square pixels as 759:712, and rejecting that ratio squeezes a 759x574 picture to 712x574.
///
/// #290 (display aspect): a wrong SAR does not have to be a large one. A live 1080p H.264 channel
/// declaring 3:1, and 2:1 on a later run, passes the component gate and smears 1920x1080 into a
/// declaring 3:1, and 2:1 on a later run, passes the magnitude gate and smears 1920x1080 into a
/// 5.33:1 or 3.56:1 band. The magnitude is not the defect, the aspect it produces is, and that
/// cannot be judged without the frame the SAR applies to: 2:1 is a standard VUI value (H.264
/// Table E-1, `ff_h2645_pixel_aspect` idc 16) and exactly right on a 960x1080 broadcast frame,
Expand All @@ -26,15 +27,19 @@ enum PixelAspectPolicy {
static let maxDisplayAspect: Double = 3.0
static let minDisplayAspect: Double = 1.0 / 3.0

/// #177 component gate. Prefer `saneSAR(_:width:height:)` wherever the frame is known; this
/// #177 magnitude gate. Prefer `saneSAR(_:width:height:)` wherever the frame is known; this
/// overload can only judge the numbers, not what they do.
static func saneSAR(_ sar: AVRational) -> AVRational? {
guard sar.num > 0, sar.den > 0, sar.num <= 256, sar.den <= 256 else { return nil }
guard sar.num > 0, sar.den > 0 else { return nil }
// AVRational components are Int32, so these Int64 products cannot overflow.
let numerator = Int64(sar.num)
let denominator = Int64(sar.den)
guard numerator <= denominator * 256, denominator <= numerator * 256 else { return nil }
return sar
}

/// #290: the component gate plus the display aspect this SAR produces on this frame.
/// Non-positive dimensions fall back to the component gate alone: a frame that cannot state
/// #290: the magnitude gate plus the display aspect this SAR produces on this frame.
/// Non-positive dimensions fall back to the magnitude gate alone: a frame that cannot state
/// its own size is not evidence against the SAR.
static func saneSAR(_ sar: AVRational, width: Int32, height: Int32) -> AVRational? {
guard let sar = saneSAR(sar) else { return nil }
Expand All @@ -54,7 +59,7 @@ enum PixelAspectPolicy {
}

/// The ratio a stream actually deserves, or nil when there is nothing to correct. Bitstream
/// first, container second (`declaredStreamSAR`), then both gates above. Square pixels return
/// and container are reconciled by `declaredStreamSAR`, then both gates above. Square pixels return
/// nil rather than 1:1, because a correction of one is a correction a consumer cannot tell from
/// a real one.
///
Expand Down
34 changes: 34 additions & 0 deletions Tests/AetherEngineTests/Issue177SARLatchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ struct Issue177SARLatchTests {
}
}

@Test("precise and unreduced pixel ratios keep their declared components")
func largeComponentsPreserveRatios() {
for sar in [rational(759, 712), rational(1280, 900), rational(.max, .max - 1)] {
let sane = PixelAspectPolicy.saneSAR(sar)
#expect(sane?.num == sar.num)
#expect(sane?.den == sar.den)
}
let resolved = SoftwareVideoDecoder.resolveSAR(
frame: rational(0, 1), codecCtx: rational(0, 1), stream: rational(759, 712),
width: 712, height: 574)
#expect(resolved?.num == 759)
#expect(resolved?.den == 712)
#expect(PixelAspectPolicy.displayAspect(
sar: rational(759, 712), width: 712, height: 574) == 759.0 / 574.0)
}

@Test("magnitude bounds apply independently of rational representation")
func magnitudeBoundsAreScaleInvariant() {
for sar in [rational(256, 1), rational(512, 2), rational(1, 256), rational(2, 512)] {
#expect(PixelAspectPolicy.saneSAR(sar) != nil)
}
for sar in [rational(257, 1), rational(514, 2), rational(1, 257), rational(2, 514),
rational(.max, 1), rational(1, .max)] {
#expect(PixelAspectPolicy.saneSAR(sar) == nil)
}
}

@Test("large components still obey the display aspect gate")
func preciseRatiosKeepDisplayBounds() {
#expect(PixelAspectPolicy.saneSAR(rational(2000, 1000), width: 1920, height: 1080) == nil)
#expect(PixelAspectPolicy.saneSAR(rational(2000, 1000), width: 960, height: 1080) != nil)
#expect(PixelAspectPolicy.saneSAR(rational(.max, .max), width: 5120, height: 1440) != nil)
}

// MARK: - Resolution order

@Test("frame SAR wins over codec context and stream")
Expand Down
12 changes: 6 additions & 6 deletions Tests/AetherEngineTests/Issue290DisplayAspectGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import AetherLibavutil
@testable import AetherEngine

/// #290: the #177 component gate bounds each SAR component to 256, which catches the pathological
/// #290: the #177 magnitude gate bounds the SAR rather than its components, which catches the pathological
/// values but admits small-but-wrong ratios. A live 1080p H.264 channel declaring 3:1, and 2:1 on a
/// later run, smeared 1920x1080 into a 5.33:1 / 3.56:1 band with both values well inside the gate.
///
Expand All @@ -24,7 +24,7 @@ struct Issue290DisplayAspectGateTests {
func reportedRatiosRejected() {
#expect(PixelAspectPolicy.saneSAR(rational(3, 1), width: 1920, height: 1080) == nil)
#expect(PixelAspectPolicy.saneSAR(rational(2, 1), width: 1920, height: 1080) == nil)
// Both clear the #177 component gate, which is why it could not catch them.
// Both clear the #177 magnitude gate, which is why it could not catch them.
#expect(PixelAspectPolicy.saneSAR(rational(3, 1)) != nil)
#expect(PixelAspectPolicy.saneSAR(rational(2, 1)) != nil)
}
Expand Down Expand Up @@ -87,8 +87,8 @@ struct Issue290DisplayAspectGateTests {
#expect(sane?.den == 1)
}

@Test("a frame that cannot state its size falls back to the component gate")
func unknownDimensionsUseComponentGate() {
@Test("a frame that cannot state its size falls back to the magnitude gate")
func unknownDimensionsUseMagnitudeGate() {
#expect(PixelAspectPolicy.saneSAR(rational(16, 11), width: 0, height: 0) != nil)
#expect(PixelAspectPolicy.saneSAR(rational(3, 1), width: 0, height: 0) != nil)
#expect(PixelAspectPolicy.saneSAR(rational(1088, 1), width: 0, height: 0) == nil)
Expand Down Expand Up @@ -172,9 +172,9 @@ struct Issue290DisplayAspectGateTests {
#expect(message?.contains("(frame=3:1 ctx=0:1 stream=2:1)") == true)
}

@Test("a ratio too broken for the component gate still reports the one that was used")
@Test("a ratio too broken for the magnitude gate still reports the one that was used")
func rejectionLineSkipsComponentGarbage() {
// #177's 1088:1 fails the component gate, so the ctx value is what the resolution would
// #177's 1088:1 fails the magnitude gate, so the ctx value is what the resolution would
// have attached, but the frame axis still has to be visible to explain the drop.
let message = SoftwareVideoDecoder.rejectedSARMessage(
frame: rational(1088, 1), ctx: rational(3, 1), stream: rational(0, 1),
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ Sources/AetherEngine/
│ ├── SubtitleDecoder.swift Sidecar URL one-shot decode (text only); decodes several streams of one container in a single pass (#266)
│ ├── VideoDecoderTypes.swift DecodedFrameHandler typealias + VideoDecoderError
│ ├── DeinterlaceHardwareWarmup.swift Process-wide one-shot prewarm of the deinterlace filter's hardware pipeline, awaited only by `.auto` mode, so the first interlaced frame does not pay for it
│ └── PixelAspectPolicy.swift Whether a declared sample aspect ratio deserves to be believed: an absurd-component gate (#177, live TS carrying 1088:1) plus a display-aspect gate judged against the frame the SAR applies to, since 2:1 is right on 960x1080 and wrong on 1920x1080 (#290)
│ └── PixelAspectPolicy.swift Whether a declared sample aspect ratio deserves to be believed: a ratio-magnitude gate (#177, live TS carrying 1088:1), which admits precise or unreduced ratios such as 759:712, plus a display-aspect gate judged against the frame the SAR applies to, since 2:1 is right on 960x1080 and wrong on 1920x1080 (#290)
├── Demuxer/
│ ├── AVIOProvider.swift Internal seam over a custom-AVIO byte source; AVIOReader and CustomIOReaderBridge both plug into the Demuxer through it, incl. the bounded-seek read deadline and the resolved byte size backing the byte-estimate seek fallback (#112)
│ ├── 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. Cold start on a non-faststart MP4 keeps the open window across the parse seek to the trailing moov (so the return trip to the first sample is a copy, not a third connection) and issues one speculative 64 KB suffix range alongside the open, rejected at the response header unless it is a well-formed 206. A read landing in that range waits for the fetch rather than connecting past it, bounded by the measured cost of one round trip against the origin, because the demuxer reaches the tail before a fetch issued at the same instant can land (#281). A suffix-range denial is remembered per origin only from the origin's answer to the range form (a 200, a 416), never from a 403/404/429/5xx; a source refused with an HTTP status fails the open typed (`AVIOReaderError.httpStatus`) instead of handing FFmpeg the error page (#378). A post-redirect target is pinned from its first 2xx and dropped again on an auth-expiry status, on an unproductive-reconnect streak, and on a bounded grace of rate-limited attempts (#307/#380); a pin that has carried no bytes for a minute is dropped by its FIRST refusal instead, because the lingering-slot 509 that grace exists for cannot arise with nothing of ours in flight (#392)
Expand Down