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

## [Unreleased]

### Added

- **WebVTT cue settings reach the host after all: `line`, `position` and `align` arrive as `SubtitleCue.placement`.** 5.26.0 reported them as upstream-blocked, which was a conclusion about the wrong layer. libavcodec's WebVTT decoder really does drop them, and says so as a `@todo` in `webvttdec.c`'s file header, so nothing about the placement is in the ASS event line it synthesises. The demuxer keeps them: `libavformat/webvttdec.c` attaches the verbatim settings string to every packet as `AV_PKT_DATA_WEBVTT_SETTINGS`, and `matroskadec.c` propagates the same side data for WebVTT in Matroska. Both subtitle decoders now read it, and the packet store carries the string through a rebuild, since side data does not live in the payload and a stored packet would otherwise lose the placement a freshly demuxed one has. A percentage `line` becomes an anchor point plus the alignment row, anchored to the frame edge it is nearer (the spec's default line alignment would pin the box top at `line:90%` and hang a two-line cue off the frame); a `line` number keeps only the half of the frame it names, because line boxes need a rendered line height the engine does not have. An ASS `\an` or `\pos` still wins, since that came from the payload itself. `size` and `vertical` have no equivalent in the placement model and are ignored, and a `position` without a `line` keeps only the alignment column, because an anchor point needs both axes.

### Fixed

- **Teletext captions keep their vertical placement on pages libzvbi does not flag as subtitle pages.** `gen_sub_ass` derives the vertical anchor from the grid row itself and emits it as `{\anN}`, which the engine has read since 5.26.0, but that whole block sits behind `is_subtitle_page`. That flag comes from the row-0 page header (NEWSFLASH clear, SUBTITLE set, SUPPRESS_HEADER set), and when a broadcaster does not set all three, or the header has not been seen yet, the else path writes the whole page instead: one `" \N"` per grid row with the empty ones included, no `\an`, no per-row trim. The ordinal of the first non-blank row is then the only carrier of the position, and the edge trim was removing it before anything could read it. That ordinal now feeds libzvbi's own third-of-the-page formula rather than an invented scale, so a caption the broadcaster moved to the top of frame to clear a lower-third graphic stays there. Deliberately coarse: three bands is what the source encodes, and mapping each row to its own offset makes consecutive cues of different heights sit at different heights, which reads as a blink. It never overrides an `\an` that arrived, including `{\an2}`, which is explicitly bottom and produces exactly the empty derived output that "no information" would. Reported by tresby (#233).
- **A styled cue trims the same whitespace an unstyled one does.** The plain path trimmed with `.whitespacesAndNewlines`, the styled path tested for a literal space, tab or newline, so a Unicode space (U+00A0 among them) survived on a styled cue and not on an unstyled one carrying the same text. Teletext cannot produce one, since libzvbi maps U+00A0 to a space and writes it as `\h`, but SRT, WebVTT and ASS payloads can.
- **A blank teletext row that a colour change split into its own run folds like any other.** The #107 interior blank-line fold worked per run plus one run boundary, which misses the shape the source produces most easily: the padding of an otherwise empty row carries the spacing attribute that changes colour, so the blank row lands in a whitespace-only run of its own and breaks the chain between the two text rows. The fold now runs on the flattened sequence and re-splits along the original run boundaries, so run structure cannot hide a blank row from it.

## [5.26.0] - 2026-07-28

([release notes](https://github.com/superuser404notfound/AetherEngine/releases/tag/5.26.0))
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ let package = Package(
// No network stack, we use custom AVIO + URLSession for HTTP streams.
// Resolved over Git rather than a local path so consumers (and
// Xcode Cloud) can build without a sibling FFmpegBuild checkout.
.package(url: "https://github.com/superuser404notfound/FFmpegBuild", from: "2.2.0"), // 2.2.0: matroska TTS warn-only per RFC 9559 (#145 rework); 2.1.3: sup demuxer (raw PGS sidecars); 2.1.2: matroska TrackTimestampScale clamp (#145, dropped in 2.2.0); 2.1.1: pgssubdec Epoch-Continue retain (#142); 2.1.0: yadif_videotoolbox + hwupload (Metal GPU deinterlace); 2.0.0: dynamic frameworks (LGPL), zvbi GPL excision
.package(url: "https://github.com/superuser404notfound/FFmpegBuild", from: "2.3.0"), // 2.3.0: webvtt demuxer (standalone .vtt sidecars, plus the cue settings as packet side data); 2.2.0: matroska TTS warn-only per RFC 9559 (#145 rework); 2.1.3: sup demuxer (raw PGS sidecars); 2.1.2: matroska TrackTimestampScale clamp (#145, dropped in 2.2.0); 2.1.1: pgssubdec Epoch-Continue retain (#142); 2.1.0: yadif_videotoolbox + hwupload (Metal GPU deinterlace); 2.0.0: dynamic frameworks (LGPL), zvbi GPL excision
// Pure-Swift SMB2 client (MIT) that speaks the protocol over
// NWConnection. Replaces AMSMB2/libsmb2, which EPERMs on tvOS/iOS.
// Pinned to the 0.3.x minor: SMBClient is pre-1.0 with an actively
Expand Down
4 changes: 4 additions & 0 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,10 @@ extension AetherEngine {
pkt.pointee.dts = pkt.pointee.pts
pkt.pointee.duration = Int64((entry.durationSeconds * 1000).rounded())
pkt.pointee.flags = entry.flags
// #233: WebVTT placement lives in side data, not in the payload, so it has to be put back.
if let settings = entry.webvttSettings {
WebVTTCueSettings.attach(settings: settings, to: pkt)
}
return decoder.decode(packet: pkt, streamTimeBase: AVRational(num: 1, den: 1000))
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/AetherEngine/Decoder/EmbeddedSubtitleDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ final class EmbeddedSubtitleDecoder {
}
avsubtitle_free(&sub)

// #233: WebVTT cue settings never reach the ASS event line (the decoder drops them), but
// the demuxer keeps them on the packet, and the #112 store carries them through a rebuild.
// An ASS `\an` / `\pos` still wins, since that came from the payload itself.
placement = placement ?? WebVTTCueSettings.placement(onPacket: packet)

let merged = textLines
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
5 changes: 5 additions & 0 deletions Sources/AetherEngine/Decoder/SubtitleDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ enum SubtitleDecoder {
}
avsubtitle_free(&sub)

// #233: WebVTT cue settings never reach the ASS event line (the decoder drops
// them), but the demuxer keeps them on the packet. An ASS `\an` / `\pos` still
// wins, since that came from the payload itself.
placement = placement ?? WebVTTCueSettings.placement(onPacket: pkt)

let merged = lines
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
32 changes: 25 additions & 7 deletions Sources/AetherEngine/Subtitles/SubtitlePacketStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ struct StoredSubtitlePacket: Sendable {
/// decode packet (AV_PKT_FLAG_KEY matters for bitmap acquisition points).
let flags: Int32
let payload: Data
/// #233: `AV_PKT_DATA_WEBVTT_SETTINGS` as attached by the demuxer. WebVTT cue settings live
/// only in packet side data (the decoder never puts them in the ASS line), so a rebuilt packet
/// loses the cue's placement unless the string rides along with the payload.
let webvttSettings: String?

init(ptsSeconds: Double, durationSeconds: Double, flags: Int32, payload: Data,
webvttSettings: String? = nil) {
self.ptsSeconds = ptsSeconds
self.durationSeconds = durationSeconds
self.flags = flags
self.payload = payload
self.webvttSettings = webvttSettings
}
}

final class SubtitlePacketStore: @unchecked Sendable {
Expand Down Expand Up @@ -103,21 +116,23 @@ final class SubtitlePacketStore: @unchecked Sendable {
}

func append(streamIndex: Int32, ptsSeconds: Double, durationSeconds: Double,
flags: Int32 = 0, payload: Data) {
flags: Int32 = 0, payload: Data, webvttSettings: String? = nil) {
lock.lock(); defer { lock.unlock() }
appendLocked(streamIndex: streamIndex, ptsSeconds: ptsSeconds,
durationSeconds: durationSeconds, flags: flags, payload: payload)
durationSeconds: durationSeconds, flags: flags, payload: payload,
webvttSettings: webvttSettings)
}

private func appendLocked(streamIndex: Int32, ptsSeconds: Double, durationSeconds: Double,
flags: Int32, payload: Data) {
flags: Int32, payload: Data, webvttSettings: String? = nil) {
let before = bytesByStream[streamIndex] ?? 0
var entries = entriesByStream[streamIndex] ?? []
var bytes = before
let entry = StoredSubtitlePacket(ptsSeconds: ptsSeconds,
durationSeconds: durationSeconds,
flags: flags,
payload: payload)
payload: payload,
webvttSettings: webvttSettings)
// #235: several packets legitimately share a PTS. ASS/SSA authors overlapping lines on
// identical Start/End, and a karaoke or layered-style track puts a whole burst of distinct
// Dialogue events on one timestamp. Only a byte-identical payload is the pump and the
Expand Down Expand Up @@ -222,21 +237,24 @@ final class SubtitlePacketStore: @unchecked Sendable {
flags: packet.pointee.flags,
payload: Data(bytes: data, count: Int(packet.pointee.size)),
assembleSplitDisplaySets: assembleSplitDisplaySets,
writer: writer)
writer: writer,
webvttSettings: WebVTTCueSettings.settings(onPacket: packet))
}

/// Testable core of `harvest`. ptsSeconds nil = packet carried no PTS (AV_NOPTS_VALUE):
/// dropped on the per-packet path, folded into the pending set on the assembly path.
func harvestChunk(streamIndex: Int32, ptsSeconds: Double?, durationSeconds: Double,
flags: Int32, payload: Data, assembleSplitDisplaySets: Bool,
writer: Writer = .pump) {
writer: Writer = .pump, webvttSettings: String? = nil) {
lock.lock(); defer { lock.unlock() }
guard assembleSplitDisplaySets else {
guard let ptsSeconds else { return }
appendLocked(streamIndex: streamIndex, ptsSeconds: ptsSeconds,
durationSeconds: durationSeconds, flags: flags, payload: payload)
durationSeconds: durationSeconds, flags: flags, payload: payload,
webvttSettings: webvttSettings)
return
}
// The assembly path below is PGS display sets; those carry no WebVTT settings.
// Mirror the decoder's SUP-wrapper rule: strip a leading "PG" 10-byte header so
// concatenated chunks form one clean [type][len BE][body] segment run.
var chunk = payload
Expand Down
Loading
Loading