Skip to content

fix(hls): anchor injected subtitle renditions to the origin's timestamps - #14

Open
Rhainland wants to merge 2 commits into
Silo-Server:mainfrom
Rhainland:fix/remote-hls-subtitle-timestamp-map
Open

Rhainland wants to merge 2 commits into
Silo-Server:mainfrom
Rhainland:fix/remote-hls-subtitle-timestamp-map

Conversation

@Rhainland

@Rhainland Rhainland commented Oct 5, 2026 •

Copy link
Copy Markdown

Summary

External subtitles that the engine injects as WebVTT renditions into a remote HLS master (superuser404notfound#316) showed every cue 10 s early on Silo's MPEG-TS transcodes; direct play was in sync. The served .vtt had no X-TIMESTAMP-MAP, so AVPlayer tied cue time 0 to MPEG-TS timestamp 0, while ffmpeg's -copyts -max_delay 5000000 output starts at source time plus 10 s. The proxy now reads the origin's starting timestamp and serves the renditions with a matching map.

No issue filed; found during Silo's iOS Series validation (Silo-Server/silo-apple#337, case C10).

What changed

  • After building the rewritten master, the proxy reads the head of the segment the load opens on. That is the segment AVPlayer requests first, so an on-demand transcoder is not sent to another position. It takes the first video PES PTS for MPEG-TS, or tfdt over the track's mdhd timescale for fMP4.
  • The anchor is that timestamp minus the segment's playlist start. Renditions are served with X-TIMESTAMP-MAP=MPEGTS:<anchor>,LOCAL:00:00:00.000 and unchanged cue times; a negative anchor moves to the LOCAL side.
  • The probe runs in the background under one deadline covering all of its reads (the segment head and, for fMP4, the init segment), through the authorizing relay when the load has one. The .vtt handler waits for it inside its existing wait.
  • An anchor near zero, a byte-range or encrypted playlist, an unresolvable EXT-X-DEFINE reference, or a failed probe keeps the plain WebVTT body, as before.
  • CHANGELOG.md, docs/architecture.md and docs/formats.md describe the behaviour.

Test plan

  • Device / OS: iOS 26.5 Simulator (iPhone 17 Pro), silo-apple built against this branch.
  • Source media: H.264 / AAC MKV with an embedded SRT track, played through a Silo server's HLS transcode at 480p (H.264 MPEG-TS segments, -copyts). The subtitle comes from the server as WebVTT and is injected by the proxy.
  • Result: before, every cue showed about 10 s early. After, cues match a burned-in counter (256 s ↔ cue at 4:15, 270 s ↔ cue at 4:30), and the engine logged a 10.000 s anchor.
  • Unit tests: the remote-HLS subtitle proxy, refreshable HLS authorization and WebVTT suites pass (56 Swift Testing tests, 10 XCTest tests). They cover a +10 s MPEG-TS origin (MPEGTS:900000 with unchanged cues), a zero-offset origin and a failed probe (plain body), start-segment selection with EXT-X-DEFINE substitution, fMP4 tfdt parsing, the negative-anchor map, the authorized probe's headers and range, and an fMP4 probe whose segment arrives late and whose init segment stalls, returning within the budget.

Checklist

  • CHANGELOG.md updated
  • Commit messages follow Conventional Commits (feat(...), fix(...), chore(...))
  • The fix lives in the engine, not in a host-side workaround
  • Public API changes are intentional and documented (no public API changes)

Notes

A draft silo-apple PR moves the app's engine pin to this commit once it merges: Silo-Server/silo-apple#642.

  • I read and can explain the complete diff.

AI Disclosure

  • Harness: Claude Code, running in T3 Code
  • Tool(s): Claude Code subagents, Swift toolchain, xcodebuild, Xcode Simulator, GitHub CLI
  • Model(s): claude-opus-5-5
  • Involvement: Fully AI-generated, human verified
  • Adversarial review: A separate Claude Code reviewer agent (claude-opus-5-5) read the diff at medium effort, checking concurrency, the MPEG-TS and fMP4 parsers, how the anchor composes with host timeline offsets, and fallback behaviour for other origins. It found that on the path without authorization the 20 s budget applied per request, so an fMP4 probe could run about 40 s. The probe now has one overall deadline, with a test for a late segment and a stalled init segment.

Note

Anchor injected HLS subtitle renditions to origin media timestamps

  • Adds a timestamp probe in RemoteHLSTimestampAnchor.swift that reads a bounded prefix of the media segment at the load's start position and computes a media-to-playlist offset from the first MPEG-TS PTS or fMP4 fragment decode time, preferring the video track.
  • The proxy starts this probe during remote-HLS preparation without awaiting it; the provider waits for it when serving the WebVTT rendition and emits an X-TIMESTAMP-MAP header via WebVTTBuilder.swift when the offset is non-negligible. Zero offsets, probe failures, and timeouts fall back to the plain body.
  • HLSOriginRelay gains a ranged fetchHead, and starting subtitle decoding no longer cancels a running probe.
  • Risk: RemoteHLSSubtitleProvider.startFill no longer tears down the whole provider on decode restart — check that cancelFill still covers probe and decode cleanup; probes over relays use authorizer requests with a byte range, verified in RefreshableHLSAuthorizationTests.swift.

Macroscope summarized d57205a.

External subtitles injected as WebVTT renditions on a remote HLS source (superuser404notfound#316) appeared early when the origin's media timestamps do not start at the playlist's time 0. An ffmpeg MPEG-TS transcode cut with -copyts -max_delay 5000000 puts the video 10 s after source time, and every cue showed 10 s early; direct play was in sync.

The served .vtt was whole-program cues in source time with no X-TIMESTAMP-MAP, so AVPlayer pinned cue time 0 to MPEG-TS timestamp 0 while it placed the A/V on the playlist timeline from the segments' own timestamps.

After building the rewritten master, the proxy now reads the head of the segment the load opens on (the one AVPlayer fetches first, so an on-demand transcoder is not sent to another position): the first video PES PTS for MPEG-TS, tfdt over the track's mdhd timescale for fMP4. The anchor is that timestamp minus the segment's playlist start, and the renditions are served with X-TIMESTAMP-MAP=MPEGTS:<anchor>,LOCAL:00:00:00.000 and unchanged cue times. The probe runs in the background under one deadline that covers all of its reads (the segment head and, for fMP4, the init segment), through the authorizing relay when the load has one, and the .vtt handler waits for it inside its existing wait. An anchor near zero, a byte-range or encrypted playlist, an unresolvable EXT-X-DEFINE reference, or a failed probe keeps the plain WebVTT.

Tests cover a +10 s MPEG-TS origin (MPEGTS:900000 with unchanged cues), a zero-offset origin and a failed probe (plain body), start-segment selection with EXT-X-DEFINE substitution, fMP4 tfdt parsing, the negative-anchor map, the authorized probe's headers and range, and an fMP4 probe whose segment arrives late and whose init segment stalls returning within the budget.
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 47532a89-bd76-4715-a911-777d10a55c24
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@silo-kody

silo-kody Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

} else if size == 0 {
size = range.upperBound - offset
}
guard size >= header, offset + size <= range.upperBound else { break }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Security critical

Integer overflow in offset + size traps when an origin supplies an extended-size MP4 box with size Int.max after any complete preceding box, despite passing the large <= UInt64(Int.max) check. Reject sizes larger than range.upperBound - offset before addition to prevent a malformed segment or init response from terminating the player process.

guard size >= header, size <= range.upperBound - offset else { break }
Prompt for LLM

File Sources/AetherEngine/Native/RemoteHLSTimestampAnchor.swift:

Line 328:

Integer overflow in `offset + size` traps when an origin supplies an extended-size MP4 box with size `Int.max` after any complete preceding box, despite passing the `large <= UInt64(Int.max)` check. Reject sizes larger than `range.upperBound - offset` before addition to prevent a malformed segment or init response from terminating the player process.

Suggested Code:

guard size >= header, size <= range.upperBound - offset else { break }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d57205a: the box walk now checks size <= range.upperBound - offset instead of adding, so an extended size of Int.max ends the walk and the rendition keeps the plain WebVTT. Covered by oversizedExtendedBoxEndsTheWalk (it trapped before the fix).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks—this addresses the reported overflow. The size <= range.upperBound - offset check rejects an oversized box before evaluating offset + size, preventing the trap.

The reported oversizedExtendedBoxEndsTheWalk regression test covers the original failure case. With the fix in d57205a6, this finding is addressed; no further changes are needed for this suggestion.

Comment on lines +305 to +306
let seconds = Double(decodeTime) / Double(track.timescale)
if track.isVideo { return seconds }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Timestamp misalignment occurs because the fMP4 anchor uses tfdt decode time as presentation time without applying the first video sample's trun composition offset, shifting B-frame fragments' timestamp maps by their presentation-minus-decode offset and desynchronizing every injected cue. Derive the presentation timestamp before subtracting the playlist start, consistently with the MPEG-TS PTS path.

Prompt for LLM

File Sources/AetherEngine/Native/RemoteHLSTimestampAnchor.swift:

Line 305 to 306:

Timestamp misalignment occurs because the fMP4 anchor uses `tfdt` decode time as presentation time without applying the first video sample's `trun` composition offset, shifting B-frame fragments' timestamp maps by their presentation-minus-decode offset and desynchronizing every injected cue. Derive the presentation timestamp before subtracting the playlist start, consistently with the MPEG-TS PTS path.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing. The error is bounded by the encoder's reorder delay, which sits under the 0.1 s threshold this anchor already uses to absorb it. An ffmpeg fMP4 HLS encode with -bf 3 at 24 fps gives a first-sample trun composition offset of 1024/12288 (83 ms) on every fragment, so the map is at most 83 ms off. That is not visible on a subtitle. Adding only the trun offset would not be correct either: presentation time also depends on the edit list in the init segment (that same file has an 83 ms empty edit and media_time 1024), and on trun version 1 signed offsets. Getting it exactly right means parsing more untrusted structure for a correction below the threshold.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The high-severity recommendation was too strong for the evidence here. Your measured 83 ms offset supports treating this as an acceptable approximation for that ffmpeg output, rather than requiring additional parsing for subtitle synchronization.

You’re also right that adding the first trun composition offset alone is not a complete presentation-timeline correction: edit-list mapping and signed version-1 offsets need consideration.

One distinction matters: negligibleSeconds suppresses anchors near zero; it does not bound the error of a nonzero anchor. Likewise, the measured 83 ms reorder delay is not a universal bound for all fMP4 encoders. The defensible conclusion is that this approximation is acceptable for the tested output—not that every possible origin stays below 100 ms.

Given that tradeoff, I would not treat this finding as a high-severity blocker or insist on the proposed change. No code or issue status has been changed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both corrections: negligibleSeconds only drops anchors near zero, and 83 ms is what one encoder produced, not a bound for every origin. The error does stay within the first frame's reorder delay, a few frames for common B-frame settings. Leaving the tfdt anchor as is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Leaving the tfdt anchor unchanged is a reasonable tradeoff for this fix: it accepts a reorder-delay-scale approximation rather than adding presentation-timeline parsing for subtitle precision.

I would not treat the original suggestion as a high-severity blocker. No code or issue status has been changed.

guard anchorSeconds >= 0 else {
return "X-TIMESTAMP-MAP=MPEGTS:0,LOCAL:\(timestamp(-anchorSeconds))"
}
let ticks = Int64((anchorSeconds * 90_000).rounded()) & ((Int64(1) << 33) - 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Security critical

Integer conversion overflow in timestampMap crashes the process because it converts origin-controlled anchorSeconds * 90,000 to Int64 before wrapping to 33 bits; a version-1 tfdt of UInt64.max with an mdhd timescale of 1 passes the probe's parsing checks but traps during subtitle response construction. Reject nonfinite values and reduce finite tick values modulo 2^33 before converting to Int64.

Prompt for LLM

File Sources/AetherEngine/Subtitles/WebVTTBuilder.swift:

Line 60:

Integer conversion overflow in `timestampMap` crashes the process because it converts origin-controlled `anchorSeconds * 90,000` to `Int64` before wrapping to 33 bits; a version-1 `tfdt` of `UInt64.max` with an `mdhd` timescale of 1 passes the probe's parsing checks but traps during subtitle response construction. Reject nonfinite values and reduce finite tick values modulo 2^33 before converting to `Int64`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d57205a: timestampMap wraps the tick count to 33 bits while it is still a Double, so a tfdt of UInt64.max over timescale 1 yields a valid map. A non-finite anchor is dropped in anchorSeconds (plain body), and timestampMap answers one with the identity map. Covered by hugeAnchorWrapsWithoutTrapping.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks—based on your description, d57205a6 addresses the reported crash:

  • Wrapping the tick count to 33 bits before converting to Int64 avoids the overflow for tfdt = UInt64.max with timescale 1.
  • Dropping non-finite anchors preserves the plain-WebVTT fallback, while the identity map protects direct calls to timestampMap.
  • hugeAnchorWrapsWithoutTrapping covers the reported extreme-value case.

That resolves the original finding as described; I haven’t independently inspected the updated diff.

Two inputs from a broken or malicious HLS origin could crash the player
while the subtitle timestamp anchor was read or written.

The MP4 box walk added a 64-bit extended box size to the current offset.
A size of Int.max after any complete box passed the Int.max check and then
overflowed on the addition. The walk now compares the size with the bytes
left instead, so such a box ends the walk and the rendition keeps the plain
WebVTT body.

X-TIMESTAMP-MAP converted the anchor's 90 kHz tick count to Int64 before
wrapping it to 33 bits. A version-1 tfdt of UInt64.max over a timescale of
1 is finite but far past Int64, and the conversion trapped. The ticks are
now wrapped while still a Double. An anchor that is not finite (a playlist
whose EXTINFs sum to NaN) is dropped by the probe, and timestampMap answers
one with the identity map.
@silo-kody

silo-kody Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant