Groesse in vier Stufen, Schrift und Hintergrund als fuenf feste Paare — Weiss, Weiss auf Schwarz, Schwarz auf Weiss, Gelb, Gelb auf Schwarz. Feste Paare statt zweier Regler, weil unabhaengige Achsen unlesbare Mischungen zulassen: Schwarz auf durchsichtig ueber einer Nachtszene ist eine gueltige Einstellung und eine wertlose. Die Uebersetzung nach AVFoundation ist eine Tabelle aus Schluesseln, bei der jeder Fehler lautlos ist — ein falscher Schluessel laesst den Initialisierer `nil` zurueckgeben, und dann gibt es gar keine Regel. Auf dem Bildschirm sieht „keine Regel\" genauso aus wie „Regel ohne Wirkung\". Zehn Tests decken das ab, darunter die drei Fallen: Alpha zuerst in der Farbe, Prozentwert statt Punktgroesse, und der Hintergrund auch fuer „durchsichtig\" ausdruecklich gesetzt statt weggelassen — weggelassen gilt sonst, was Jellyfins WebVTT selbst mitbringt, und das ist oft ein schwarzer Kasten. Als Protokollmethode, nicht als Feld an `PlaybackSource`: die wird einmal beim Oeffnen gelesen, und eine Einstellung, die erst beim naechsten Film wirkt, haelt jeder fuer kaputt. Angewandt wird an zwei Stellen — am laufenden Gegenstand und am frisch angelegten. Nur das erste zu tun hiesse, dass die naechste Folge wieder in der Vorgabe erscheint. VLC erbt die leere Vorgabe. Die Einstellungsseite sagt beides: dass es fuer den Standardplayer gilt, und dass eingebrannte Bilduntertitel von Blu-rays sich nicht gestalten lassen, weil es dort keinen Text gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.7 KiB
Swift
144 lines
5.7 KiB
Swift
import AVFoundation
|
|
import CoreMedia
|
|
import Foundation
|
|
import Testing
|
|
@testable import FlixyPlayback
|
|
|
|
@Suite("Aussehen der Untertitel")
|
|
struct SubtitleStyleTests {
|
|
|
|
private func attributes(_ style: SubtitleStyle) -> [String: Any]? {
|
|
style.textStyleRules.first?.textMarkupAttributes
|
|
}
|
|
|
|
private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? {
|
|
(attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue)
|
|
}
|
|
|
|
// MARK: - Dass überhaupt etwas herauskommt
|
|
|
|
/// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von
|
|
/// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt
|
|
/// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht
|
|
/// „keine Regel" genauso aus wie „Regel ohne Wirkung".
|
|
@Test("Jedes Paar ergibt genau eine Regel")
|
|
func everyPaletteProducesOneRule() {
|
|
for palette in SubtitleStyle.Palette.allCases {
|
|
for size in SubtitleStyle.Size.allCases {
|
|
let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules
|
|
#expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Die Farben
|
|
|
|
/// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an
|
|
/// dieser Stelle, und einer ohne Fehlermeldung.
|
|
@Test("Die Farbe steht als A, R, G, B da")
|
|
func colourOrderIsARGB() {
|
|
let gelb = SubtitleStyle(palette: .yellowOnBlack)
|
|
let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB)
|
|
|
|
#expect(werte?.count == 4)
|
|
#expect(werte?[0] == 1) // Alpha
|
|
#expect(werte?[1] == 1) // Rot
|
|
#expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb
|
|
}
|
|
|
|
@Test("Alle Anteile liegen zwischen null und eins")
|
|
func coloursAreInRange() {
|
|
for palette in SubtitleStyle.Palette.allCases {
|
|
let stil = SubtitleStyle(palette: palette)
|
|
for schlüssel in [
|
|
kCMTextMarkupAttribute_ForegroundColorARGB,
|
|
kCMTextMarkupAttribute_BackgroundColorARGB,
|
|
] {
|
|
let werte = colour(stil, schlüssel) ?? []
|
|
#expect(werte.count == 4, "\(palette): \(werte.count) Anteile")
|
|
#expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("Weiss auf Schwarz ist weiss auf schwarz")
|
|
func whiteOnBlack() {
|
|
let stil = SubtitleStyle(palette: .whiteOnBlack)
|
|
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1])
|
|
|
|
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? []
|
|
#expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten
|
|
#expect(grund.dropFirst().allSatisfy { $0 == 0 })
|
|
}
|
|
|
|
@Test("Schwarz auf Weiss ist die Umkehrung")
|
|
func blackOnWhite() {
|
|
let stil = SubtitleStyle(palette: .blackOnWhite)
|
|
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0])
|
|
#expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5)
|
|
}
|
|
|
|
/// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel
|
|
/// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT
|
|
/// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne
|
|
/// Kasten" wirkungslos, und niemand wüsste warum.
|
|
@Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen")
|
|
func transparentIsStated() {
|
|
for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] {
|
|
let stil = SubtitleStyle(palette: palette)
|
|
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)
|
|
|
|
#expect(grund != nil, "\(palette): der Hintergrund fehlt ganz")
|
|
#expect(grund?.first == 0, "\(palette): Alpha ist nicht null")
|
|
}
|
|
}
|
|
|
|
// MARK: - Die Grösse
|
|
|
|
/// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und
|
|
/// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was
|
|
/// der Untertitel mitbringt.
|
|
@Test("Die Grösse ist ein Prozentwert, auch bei hundert")
|
|
func sizeIsAPercentage() {
|
|
for size in SubtitleStyle.Size.allCases {
|
|
let wert = attributes(SubtitleStyle(size: size))?[
|
|
kCMTextMarkupAttribute_RelativeFontSize as String
|
|
] as? Double
|
|
#expect(wert == Double(size.rawValue))
|
|
}
|
|
}
|
|
|
|
/// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu
|
|
/// setzen liesse sie sich streiten.
|
|
@Test("Die andere Grössenangabe bleibt weg")
|
|
func onlyOneSizeKey() {
|
|
let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:]
|
|
#expect(gesetzt[
|
|
kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String
|
|
] == nil)
|
|
}
|
|
|
|
// MARK: - Der Rand
|
|
|
|
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
|
|
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe.
|
|
@Test("Den Schatten gibt es nur ohne Kasten")
|
|
func edgeShadowOnlyWithoutBox() {
|
|
for palette in SubtitleStyle.Palette.allCases {
|
|
let stil = SubtitleStyle(palette: palette)
|
|
let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String]
|
|
#expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Die Vorgabe
|
|
|
|
@Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse")
|
|
func defaults() {
|
|
let stil = SubtitleStyle()
|
|
#expect(stil.size == .normal)
|
|
#expect(stil.palette == .whiteOnClear)
|
|
#expect(stil.wantsEdgeShadow)
|
|
}
|
|
}
|