Skip to content

ASS header PlayRes is dropped when the script uses CRLF, so every \pos normalizes against the 384x288 default #261

Description

@rrgomes

Embedded ASS with \pos lands off-picture. Text and timing are correct; the placement is not. An anchor the script puts at 90% of frame height arrives at the host as 1.5.

The source: a 706x480 H.264 title in Matroska with one embedded ASS track, 2040 dialogue lines, every one carrying {\an1\pos(x,432)}. The header declares PlayResX: 718 / PlayResY: 480.

What reaches the host for {\an1\pos(298,432)} is align=1 pos=(0.7760, 1.5000). Expected (298/718, 432/480) = (0.4151, 0.9000). Observed is (298/384, 432/288), the spec default PlayRes, so the declared one was never read.

Cause

SubtitleRectText.playRes(fromASSHeader:) splits the header on \n and then trims with CharacterSet.whitespaces, which contains space and tab but not \r. ASS headers are conventionally CRLF, and this track's CodecPrivate is:

00000030: 6f72 6d61 6c0d 0a50 6c61 7952 6573 583a  ormal..PlayResX:
00000040: 2037 3138 0d0a 506c 6179 5265 7359 3a20   718..PlayResY: 
00000050: 3438 300d 0a54 696d 6572 3a20 3130 302e  480..Timer: 100.

So the line is "PlayResX: 718\r". The hasPrefix check passes, and then Double("718\r") returns nil. Both lookups fail, the guard returns nil, and both SubtitleDecoder and EmbeddedSubtitleDecoder keep defaultASSPlayRes.

Running the function exactly as written:

CRLF header -> nil            (falls back to 384x288)
LF header   -> 718.0x480.0

Why the tests do not catch it

Every PlayResX fixture in Tests/ is a Swift multi-line string literal, so LF only. The one CRLF fixture, ASSScriptBuilderTests.swift:54, exercises ASSScriptBuilder rather than this parser.

Fix

.whitespacesAndNewlines at SubtitleRectText.swift:79 and :81. They are the only .whitespaces trims in that file's header path; lines 21 and 66 already use the newline-inclusive form.

Blast radius

Any ASS or SSA track whose header is CRLF, which is the common case for muxed subtitles. It fails silently: a \pos normalized against 384x288 instead of a declared 1920x1080 lands at 5x the intended coordinates, and anything past 1.0 leaves the picture entirely, which looks exactly like a cue that never arrived rather than like a misplaced one. Tracks that declare no PlayRes are unaffected, since 384x288 is then the correct basis, as are tracks whose declared PlayRes happens to be 384x288.

This is also why the bug was invisible before 5.28.1: insertCueSorted dropped placement on everything entering the retained store, so on the drained embedded path the wrong coordinates never reached a host at all and the cue rendered unplaced. Retaining placement is correct, and it is what surfaced this.

On our side

We now ignore an anchor outside [0, 1] and fall back to alignment-only placement, on the reasoning that an out-of-range anchor means the normalization basis was wrong rather than that the source asked for an off-picture caption. That is worth having regardless of this fix, because a cue drawn off-picture is indistinguishable from one that never arrived, which is an expensive thing to debug. It is a fallback, not a substitute: with the basis wrong the cue is still in the wrong place, just a visible one.

Version

Observed on 6.1.0 (fa2a5132). Confirmed still present at 6.1.3: the surrounding file changed substantially for the teletext work, but playRes(fromASSHeader:) itself is unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions