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.
Embedded ASS with
\poslands 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 declaresPlayResX: 718/PlayResY: 480.What reaches the host for
{\an1\pos(298,432)}isalign=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\nand then trims withCharacterSet.whitespaces, which contains space and tab but not\r. ASS headers are conventionally CRLF, and this track's CodecPrivate is:So the line is
"PlayResX: 718\r". ThehasPrefixcheck passes, and thenDouble("718\r")returns nil. Both lookups fail, the guard returns nil, and bothSubtitleDecoderandEmbeddedSubtitleDecoderkeepdefaultASSPlayRes.Running the function exactly as written:
Why the tests do not catch it
Every
PlayResXfixture inTests/is a Swift multi-line string literal, so LF only. The one CRLF fixture,ASSScriptBuilderTests.swift:54, exercisesASSScriptBuilderrather than this parser.Fix
.whitespacesAndNewlinesatSubtitleRectText.swift:79and:81. They are the only.whitespacestrims 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
\posnormalized 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:
insertCueSorteddroppedplacementon 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, butplayRes(fromASSHeader:)itself is unchanged.