Skip to content

Commit fcfebfe

Browse files
Merge branch 'fix/ae447-subtitle-td-seal'
2 parents fab1e65 + 61dd253 commit fcfebfe

2 files changed

Lines changed: 110 additions & 7 deletions

File tree

Sources/AetherEngine/Network/HLSLocalServer.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,13 +1262,16 @@ final class HLSLocalServer: @unchecked Sendable {
12621262
for i in firstVisible..<count {
12631263
maxDuration = max(maxDuration, provider.segmentDuration(at: i))
12641264
}
1265-
var targetDuration = Int(ceil(max(1.0, maxDuration)))
1266-
if typeIsLive, let liveTarget = provider.liveTargetSegmentDuration {
1267-
targetDuration = max(targetDuration, Int(ceil(liveTarget * 1.5)))
1268-
}
1269-
if typeIsLive, let cadenceFloor = provider.liveTargetDurationFloorSeconds {
1270-
targetDuration = max(targetDuration, Int(ceil(cadenceFloor)))
1271-
}
1265+
// AE#447 follow-up: the SEALED value, the same one the video playlist carries. This rebuilt the
1266+
// derivation by hand instead, so it read the live cadence floor on every render and could hand a
1267+
// subtitle rendition a TARGETDURATION that grew mid-session. RFC 8216 forbids that in any Media
1268+
// Playlist, and AE#209 measured the cost on the video one: an item that reached readyToPlay,
1269+
// showed a first frame, and then sat at `waitingToPlay` at time zero for the rest of the session.
1270+
// A rendition is a Media Playlist like any other, and it is built from this provider's own
1271+
// segments, so the two values are the same number and may as well come from the same place.
1272+
let targetDuration = (typeIsLive || liveOutage)
1273+
? provider.liveTargetDurationSeconds(maxSegmentDuration: maxDuration)
1274+
: Int(ceil(max(1.0, maxDuration)))
12721275

12731276
var lines: [String] = []
12741277
lines.append("#EXTM3U")
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Tests/AetherEngineTests/Issue447SubtitleTargetDurationSealTests.swift
2+
// AE#447 follow-up: a subtitle rendition is a Media Playlist, so RFC 8216 forbids its TARGETDURATION
3+
// to change for its lifetime just as firmly as the video one's. The rendition builder rebuilt the
4+
// derivation by hand and read the live cadence floor on every render, so a floor that rose mid-session
5+
// (which is what a floor is for) moved the served value. AE#209 measured what that costs on the video
6+
// playlist: readyToPlay, a first frame, and then waitingToPlay at time zero for the rest of the session.
7+
import Testing
8+
import Foundation
9+
@testable import AetherEngine
10+
11+
/// Says one thing through the sealed accessor and a contradicting thing through the raw floor, so a
12+
/// builder that rebuilds the derivation cannot pass by arriving at the same number by accident.
13+
private final class SentinelSealProvider: HLSSegmentProvider, @unchecked Sendable {
14+
func initSegment() -> Data? { Data([0x00]) }
15+
func mediaSegment(at index: Int) -> Data? { Data([0x00]) }
16+
var segmentCount: Int { 6 }
17+
func segmentDuration(at index: Int) -> Double { 4.0 }
18+
var playlistType: HLSPlaylistType { .live }
19+
var liveTargetSegmentDuration: Double? { 4.0 }
20+
var liveTargetDurationFloorSeconds: Double? { 20.0 }
21+
func liveTargetDurationSeconds(maxSegmentDuration: Double) -> Int { 7 }
22+
func nativeSubtitleVTT(ordinal: Int, segmentIndex: Int) -> String? { "WEBVTT\n" }
23+
var nativeSubtitleRenditions: [(ordinal: Int, language: String?, name: String, isForced: Bool)] {
24+
[(0, "en", "English", false)]
25+
}
26+
func notePlaylistBuild() -> (visibleCount: Int, firstVisible: Int, refreshCounter: Int,
27+
endlistAdded: Bool, discontinuitySequence: Int) {
28+
(6, 0, 1, false, 0)
29+
}
30+
}
31+
32+
private final class ScriptedFloor: @unchecked Sendable {
33+
private let lock = NSLock()
34+
private var _value: Double?
35+
var value: Double? {
36+
get { lock.lock(); defer { lock.unlock() }; return _value }
37+
set { lock.lock(); _value = newValue; lock.unlock() }
38+
}
39+
}
40+
41+
@Suite("AE#447 subtitle rendition TARGETDURATION seal")
42+
struct Issue447SubtitleTargetDurationSealTests {
43+
44+
private func targetDuration(_ playlist: String) -> Int? {
45+
playlist.split(separator: "\n")
46+
.first { $0.hasPrefix("#EXT-X-TARGETDURATION:") }
47+
.flatMap { Int($0.dropFirst("#EXT-X-TARGETDURATION:".count)) }
48+
}
49+
50+
@Test("the rendition serves the sealed value, not a rebuilt one")
51+
func renditionReadsTheSeal() {
52+
let provider = SentinelSealProvider()
53+
let subs = HLSLocalServer.buildSubtitleMediaPlaylistText(ordinal: 0, provider: provider)
54+
#expect(targetDuration(subs) == 7)
55+
#expect(targetDuration(subs) == targetDuration(HLSLocalServer.buildMediaPlaylistText(provider: provider)))
56+
}
57+
58+
@Test("a cadence floor that rises mid-session does not move the rendition")
59+
func renditionDoesNotDriftWithTheFloor() {
60+
let floor = ScriptedFloor()
61+
floor.value = 0.9
62+
let cache = SegmentCache(forwardWindow: 10, backwardWindow: 10)
63+
defer { cache.close() }
64+
let provider = VideoSegmentProvider(
65+
cache: cache,
66+
segments: [],
67+
codecsString: "avc1.64002A,mp4a.40.2",
68+
supplementalCodecs: nil,
69+
resolution: (1920, 1080),
70+
videoRange: .sdr,
71+
frameRate: 50,
72+
hdcpLevel: nil,
73+
sourceBitrate: 6_000_000,
74+
isLive: true,
75+
liveWindowSizing: LiveWindowSizing(targetSegmentDurationSeconds: 0.5, dvrWindowSeconds: nil),
76+
liveCadencePolicy: LiveCadencePolicy(
77+
observe: { floor.value },
78+
cutTargetSeconds: 0.5,
79+
observeSealEvidence: { LiveCadenceEvidence(closedCadenceSeconds: floor.value,
80+
servedSegmentDurationSeconds: nil) },
81+
clock: { 0 }
82+
)
83+
)
84+
for i in 0..<6 {
85+
provider.appendLiveSegment(index: i, startSeconds: Double(i) * 0.9, durationSeconds: 0.9)
86+
}
87+
88+
let videoBefore = targetDuration(HLSLocalServer.buildMediaPlaylistText(provider: provider))
89+
let subsBefore = targetDuration(HLSLocalServer.buildSubtitleMediaPlaylistText(ordinal: 0, provider: provider))
90+
#expect(videoBefore == subsBefore)
91+
92+
// The floor does what a floor does: a batch arrives late and it widens. The video playlist is
93+
// sealed against exactly this, and the rendition rode along on the raw value.
94+
floor.value = 30.0
95+
let videoAfter = targetDuration(HLSLocalServer.buildMediaPlaylistText(provider: provider))
96+
let subsAfter = targetDuration(HLSLocalServer.buildSubtitleMediaPlaylistText(ordinal: 0, provider: provider))
97+
#expect(videoAfter == videoBefore)
98+
#expect(subsAfter == subsBefore)
99+
}
100+
}

0 commit comments

Comments
 (0)