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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ the public-API contract.

## [Unreleased]

### Added

- **Text subtitle styling reaches the host instead of being stripped: bold, italic, underline, strikeout, colour, font face and size, plus the placement a cue asks for.** `SubtitleTextRun` gained `isBold`, `isItalic`, `isUnderlined`, `isStruckThrough`, `fontName` and `fontSize` alongside the colour it already carried, and `SubtitleCue` gained `placement` (`SubtitleTextPlacement`: an ASS numpad alignment and an optional anchor normalized to [0, 1] the same way `SubtitleImage.position` is). Both additions are source-compatible: the new initializer parameters are defaulted, so existing call sites are untouched. This needed no per-format parsing, because libavcodec converts every text subtitle format into an ASS event line before the engine sees it and the markup was arriving on `AVSubtitleRect.ass` the whole time. SRT goes through `ff_htmlmarkup_to_ass`, which turns `<b>/<i>/<u>/<s>` into `{\b1}/{\i1}/{\u1}/{\s1}`, `<font color=>` into `{\c&HBBGGRR&}`, `<font size=>` into `{\fs}` and `<font face=>` into `{\fn}`; WebVTT maps its own inline tags to the same overrides; dvb_teletext already decoded with `txt_format=ass`, which is why its colours alone survived. What discarded the rest was on this side: `cleanASSBody` stripped every `{...}` block with a regex, and the colour parser handled `\c`/`\1c` and documented that it ignored everything else. That parser is now a full override parser, so SRT, WebVTT, teletext and ASS all light up through one code path, and the teletext positioning that was arriving and being dropped comes with it. Lookalike tags are left alone rather than half-parsed (`\be` and `\bord` are not `\b`, `\iclip` is not `\i`, `\shad` is not `\s`, `\fscx` and `\fsp` are not `\fs`), `\r` resets the accumulated state, and `\an`/`\pos` are lifted out to the cue instead of splitting a run. `\pos` is normalized against the play resolution the line actually uses: a real ASS script's declared `PlayResX/Y` when its header provides one, otherwise libavcodec's 384x288 default, which is what the synthesised lines use. No option gates any of this. An unstyled cue still arrives as `.text` with the same string as before, so a host that only handles that case sees no change, and `.richText` was already a case every subtitle-rendering host had to handle for teletext.
- **The software-path PiP compositor renders that styling instead of flattening it.** It previously reduced a rich-text cue to `runs.map(\.text).joined()` and drew every cue in one white font stacked from the bottom, so styling would have stopped at the PiP window. It now builds a per-run attributed line (per-run colour, font face, ASS-relative size, real CoreText bold and italic traits, underline, and a manually drawn rule for strikeout, which CoreText has no attribute for) and honours a cue's placement, mapping the ASS numpad alignment to a corner inset by the layout margin, or anchoring the block on an explicit `\pos`. Cues without placement keep the existing bottom-up stack.

### Notes

- WebVTT cue settings (`line`, `position`, `align`, `size`, `vertical`) still do not arrive: libavcodec does not convert them, as `webvttdec.c` states in its own file header. WebVTT bold, italic and underline work; WebVTT positioning needs an upstream change. SRT positioning arrives only when the demuxer supplies `AV_PKT_DATA_SUBTITLE_POSITION` side data, which most `.srt` files do not carry, though a leading `{\anN}` in the text survives libavcodec's conversion.

## [5.25.2] - 2026-07-28

([release notes](https://github.com/superuser404notfound/AetherEngine/releases/tag/5.25.2))
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,18 @@ player.selectAudioTrack(index: trackID)
player.subtitleTracks // [TrackInfo], text + bitmap + external, one list
player.selectSubtitleTrack(index: streamID)
player.clearSubtitle()
player.$subtitleCues // [SubtitleCue]: .text(String), .richText([SubtitleTextRun]) (coloured teletext), or .image(SubtitleImage)
player.$subtitleCues // [SubtitleCue]: .text(String), .richText([SubtitleTextRun]), or .image(SubtitleImage)

// #233: styled text arrives as .richText. A run carries colour, bold, italic, underline,
// strikeout, font face and an ASS-relative font size; the cue carries the placement it asks
// for (numpad alignment plus an optional [0, 1] anchor). This covers SRT, WebVTT, teletext
// and ASS alike, because libavcodec converts them all to ASS event lines before the engine
// sees them. A cue with no styling still arrives as .text, so handling only that case keeps
// working. WebVTT cue settings are the exception: libavcodec does not convert them, so VTT
// positioning does not arrive (its inline bold/italic/underline does).
for cue in player.subtitleCues {
if case .richText(let runs) = cue.body { render(runs, at: cue.placement) }
}

// External subtitle files are first-class tracks (#88): they appear in subtitleTracks
// (isExternal == true, synthetic id) and select through the same call as embedded streams.
Expand Down
51 changes: 44 additions & 7 deletions Sources/AetherEngine/Decoder/EmbeddedSubtitleDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ final class EmbeddedSubtitleDecoder {
/// #107: explicit teletext page override (nil = libzvbi `subtitle` auto-detect).
private let teletextPage: Int?

/// #233: coordinate space `\pos` is expressed in. A real ASS track declares its own PlayResX/Y
/// in the header libavcodec hands over as extradata; every event line libavcodec synthesises
/// for SRT, WebVTT or teletext uses the default 384x288 instead.
private let assPlayRes: CGSize

/// Open the subtitle decoder for `stream`. Returns `nil` if the codec couldn't be opened.
init?(stream: UnsafeMutablePointer<AVStream>, sourceVideoWidth: Int32, sourceVideoHeight: Int32, preserveASSMarkup: Bool = false, teletextPage: Int? = nil) {
guard let codecpar = stream.pointee.codecpar,
Expand Down Expand Up @@ -92,6 +97,14 @@ final class EmbeddedSubtitleDecoder {
&& (id == AV_CODEC_ID_ASS || id == AV_CODEC_ID_SSA)
self.teletextPage = teletextPage

var playRes = SubtitleRectText.defaultASSPlayRes
if let extra = codecpar.pointee.extradata, codecpar.pointee.extradata_size > 0 {
let header = String(decoding: UnsafeBufferPointer(
start: extra, count: Int(codecpar.pointee.extradata_size)), as: UTF8.self)
if let declared = SubtitleRectText.playRes(fromASSHeader: header) { playRes = declared }
}
self.assPlayRes = playRes

// Some demuxers default to AVDISCARD_DEFAULT and swallow packets; force NONE so everything reaches av_read_frame.
stream.pointee.discard = AVDISCARD_NONE
}
Expand Down Expand Up @@ -185,17 +198,33 @@ final class EmbeddedSubtitleDecoder {

var bodies: [SubtitleCue.Body] = []
var textLines: [String] = []
var placement: SubtitleTextPlacement?
if sub.num_rects > 0, let rects = sub.rects {
for i in 0..<Int(sub.num_rects) {
guard let rect = rects[i] else { continue }
if isTeletext, let assLine = SubtitleRectText.rawASSLine(for: rect) {
// Teletext decodes as ASS (txt_format=ass); parse colour runs (#107). Falls back
// to plain text inside teletextBody when the page carries no colour.
if let body = SubtitleRectText.teletextBody(fromASSEventLine: assLine) {
bodies.append(body)
// Teletext decodes as ASS (txt_format=ass); parse styled runs (#107 colour,
// #233 the rest). Falls back to plain text inside teletextBody when the page
// carries no styling.
if let parsed = SubtitleRectText.teletextBody(fromASSEventLine: assLine,
playRes: assPlayRes) {
bodies.append(parsed.body)
placement = placement ?? parsed.placement
}
} else if preserveASSMarkup, let raw = SubtitleRectText.rawASSLine(for: rect) {
textLines.append(raw)
} else if let assLine = SubtitleRectText.rawASSLine(for: rect),
let parsed = SubtitleRectText.styledBody(fromASSEventLine: assLine,
playRes: assPlayRes) {
// #233: libavcodec converts every text format to an ASS event line, so SRT,
// WebVTT and ASS all keep their styling here. An unstyled cue still comes back
// as .text and merges with its siblings below, exactly as the plain path did.
placement = placement ?? parsed.placement
if case .text(let t) = parsed.body {
textLines.append(t)
} else {
bodies.append(parsed.body)
}
} else if let text = SubtitleRectText.plainText(for: rect) {
textLines.append(text)
} else if let image = Self.imageForSubtitleRect(
Expand Down Expand Up @@ -240,7 +269,10 @@ final class EmbeddedSubtitleDecoder {
case .richText(let runs):
return "t:" + runs.map { run in
let c = run.color.map { "\($0.r),\($0.g),\($0.b)" } ?? "-"
return "\(run.text)#\(c)"
// #233: two cues differing only in styling are distinct events.
let s = "\(run.isBold)\(run.isItalic)\(run.isUnderlined)"
+ "\(run.isStruckThrough)\(run.fontName ?? "-")\(run.fontSize ?? -1)"
return "\(run.text)#\(c)#\(s)"
}.joined(separator: "\u{1E}")
case .image(let img):
// Dimensions + position discriminate cheaply; identical-rect repeats are the target case.
Expand All @@ -256,11 +288,16 @@ final class EmbeddedSubtitleDecoder {
nextCueID += bodies.count

let cues: [SubtitleCue] = bodies.enumerated().map { (offset, body) in
SubtitleCue(
// Placement is the text equivalent of a bitmap rect's geometry, so an image body keeps
// carrying its own and never takes the cue-level one.
var cuePlacement = placement
if case .image = body { cuePlacement = nil }
return SubtitleCue(
id: cueIDStart + offset,
startTime: startTime,
endTime: endTime,
body: body
body: body,
placement: cuePlacement
)
}

Expand Down
44 changes: 39 additions & 5 deletions Sources/AetherEngine/Decoder/SubtitleDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,18 @@ enum SubtitleDecoder {
let isASS = codecID == AV_CODEC_ID_ASS || codecID == AV_CODEC_ID_SSA
let keepMarkup = preserveASSMarkup && isASS
var assHeader: String? = nil
if keepMarkup,
let extradata = codecpar.pointee.extradata,
codecpar.pointee.extradata_size > 0 {
var assPlayRes = SubtitleRectText.defaultASSPlayRes
if let extradata = codecpar.pointee.extradata, codecpar.pointee.extradata_size > 0 {
let bytes = Data(bytes: extradata, count: Int(codecpar.pointee.extradata_size))
// Strip NUL bytes: extradata is often NUL-terminated; libass parses C-string-style and a NUL hides everything after it.
assHeader = String(data: bytes, encoding: .utf8)?
let header = String(data: bytes, encoding: .utf8)?
.replacingOccurrences(of: "\0", with: "")
if keepMarkup { assHeader = header }
// #233: a real ASS script declares the space its \pos coordinates live in; without one
// the line came from libavcodec's own conversion and uses the 384x288 default.
if let header, let declared = SubtitleRectText.playRes(fromASSHeader: header) {
assPlayRes = declared
}
}

guard let codec = avcodec_find_decoder(codecpar.pointee.codec_id) else {
Expand Down Expand Up @@ -244,6 +249,8 @@ enum SubtitleDecoder {

var lines: [String] = []
var images: [SubtitleImage] = []
var styledBodies: [SubtitleCue.Body] = []
var placement: SubtitleTextPlacement?
if sub.num_rects > 0, let rects = sub.rects {
for i in 0..<Int(sub.num_rects) {
guard let rect = rects[i] else { continue }
Expand All @@ -256,6 +263,20 @@ enum SubtitleDecoder {
) {
images.append(image)
}
} else if keepMarkup {
if let raw = lineForRect(rect) { lines.append(raw) }
} else if let assLine = SubtitleRectText.rawASSLine(for: rect),
let parsed = SubtitleRectText.styledBody(fromASSEventLine: assLine,
playRes: assPlayRes) {
// #233: SRT and WebVTT sidecars reach libavcodec's ASS conversion too,
// so their markup survives here. Unstyled cues stay .text and merge
// with their siblings below, exactly as the plain path did.
placement = placement ?? parsed.placement
if case .text(let t) = parsed.body {
lines.append(t)
} else {
styledBodies.append(parsed.body)
}
} else if let text = lineForRect(rect) {
lines.append(text)
}
Expand All @@ -271,10 +292,23 @@ enum SubtitleDecoder {
id: nextID,
startTime: startTime,
endTime: endTime,
body: .text(merged)
body: .text(merged),
placement: placement
))
nextID += 1
}
if endTime > startTime {
for body in styledBodies {
cues.append(SubtitleCue(
id: nextID,
startTime: startTime,
endTime: endTime,
body: body,
placement: placement
))
nextID += 1
}
}
if endTime > startTime {
for image in images {
pendingImageCueIndices.append(cues.count)
Expand Down
63 changes: 60 additions & 3 deletions Sources/AetherEngine/PlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -621,11 +621,62 @@ public struct SubtitleColor: Sendable, Equatable {
public init(r: UInt8, g: UInt8, b: UInt8) { self.r = r; self.g = g; self.b = b }
}

/// One contiguous same-colour span of a rich-text cue.
/// One contiguous same-styling span of a rich-text cue.
///
/// #233: libavcodec converts every text subtitle format to an ASS event line before the engine
/// sees it, so SRT (`<b>`, `<font color/size/face>`), WebVTT (`<i>/<b>/<u>`), teletext and ASS
/// itself all arrive carrying the same override tags and populate the same fields here. A run
/// with no attribute set is plain text; those cues stay `.text` rather than becoming `.richText`.
public struct SubtitleTextRun: Sendable, Equatable {
public let text: String
public let color: SubtitleColor?
public init(text: String, color: SubtitleColor?) { self.text = text; self.color = color }
public let isBold: Bool
public let isItalic: Bool
public let isUnderlined: Bool
public let isStruckThrough: Bool
/// Face requested by `\fn` (SRT `<font face=>`); nil means the host's default.
public let fontName: String?
/// Size requested by `\fs` (SRT `<font size=>`), in ASS play-resolution points, so it is a
/// relative hint rather than a pixel size; nil means the host's default.
public let fontSize: Int?

public init(text: String, color: SubtitleColor?,
isBold: Bool = false, isItalic: Bool = false,
isUnderlined: Bool = false, isStruckThrough: Bool = false,
fontName: String? = nil, fontSize: Int? = nil) {
self.text = text
self.color = color
self.isBold = isBold
self.isItalic = isItalic
self.isUnderlined = isUnderlined
self.isStruckThrough = isStruckThrough
self.fontName = fontName
self.fontSize = fontSize
}

/// True when the run asks for anything beyond plain text. Drives the `.text` / `.richText`
/// choice, so an unstyled track keeps the body it has always had.
public var isStyled: Bool {
color != nil || isBold || isItalic || isUnderlined || isStruckThrough
|| fontName != nil || fontSize != nil
}
}

/// Where a text cue asks to be drawn, from the ASS `\an` / `\pos` overrides (#233).
///
/// Bitmap cues carry their own geometry on `SubtitleImage`; this is the text equivalent and is nil
/// for the overwhelming majority of cues, which simply want the host's default placement.
public struct SubtitleTextPlacement: Sendable, Equatable {
/// ASS numpad alignment from `\an`: 1 bottom-left through 9 top-right, 5 centred.
public let alignment: Int?
/// Anchor from `\pos`, normalized to [0, 1] against the source video frame the same way
/// `SubtitleImage.position` is, with y measured from the top.
public let position: CGPoint?

public init(alignment: Int?, position: CGPoint?) {
self.alignment = alignment
self.position = position
}
}

/// Decoded subtitle cue (start/end in container seconds). Payload is plain text (SubRip / ASS / SSA / WebVTT / mov_text), coloured rich text (teletext / ASS colour tags), or a rendered bitmap (PGS / DVB / HDMV) with position normalized against the source video frame.
Expand All @@ -636,18 +687,24 @@ public struct SubtitleCue: Identifiable, Sendable {
public let startTime: Double
public let endTime: Double
public let body: Body
/// #233: placement the source asked for, from ASS `\an` / `\pos`. nil means the host places the
/// cue itself, which is the case for nearly every cue. Text cues only; a bitmap cue carries its
/// geometry on `SubtitleImage`.
public let placement: SubtitleTextPlacement?

public enum Body: Sendable {
case text(String)
case image(SubtitleImage)
case richText([SubtitleTextRun])
}

public init(id: Int, startTime: Double, endTime: Double, body: Body) {
public init(id: Int, startTime: Double, endTime: Double, body: Body,
placement: SubtitleTextPlacement? = nil) {
self.id = id
self.startTime = startTime
self.endTime = endTime
self.body = body
self.placement = placement
}

/// Plain text for text and rich-text cues (rich runs concatenated); nil for bitmap cues.
Expand Down
Loading
Loading