|
| 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