Skip to content

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

Merged
Quick104 merged 7 commits into
Silo-Server:mainfrom
Rhainland:fix/remote-hls-subtitle-timestamp-map
Oct 13, 2026
Merged

Quick104 merged 7 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 keeps the plain body: it comes from a transcode restarted at the keyframe before the slot, whose media timestamps are still source time, and upstream AE#616 corrects that lead.
  • 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

External subtitles on remote HLS sources previously served cues at playlist-relative time 0, misaligning them with the origin's actual media timestamps. This adds a timestamp probe that reads bounded heads of the first segment opened by the load.

  • New RemoteHLSTimestampAnchor.swift selects a probe target from the media playlist (handling EXT-X-DEFINE variables and EXT-X-MAP), then reads the first MPEG-TS video PES PTS or fMP4 tfdt from a bounded head of the segment and its initialization segment, all within a 20-second deadline.
  • RemoteHLSSubtitleProxy.swift starts the probe after building the media playlist and passes the load's start position so probing picks the segment that playback opens.
  • WebVTTBuilder.body emits an X-TIMESTAMP-MAP header when a resolved anchor is available.
  • Measured offsets below 0.1 seconds, failed probes, or probe timeouts fall back to the previous unanchored subtitle body. Probes are skipped for encrypted or byte-range segments.
  • Behavioral Change: the subtitle VTT request now also waits for the timestamp probe (up to the existing VTT deadline); restarting subtitle fill no longer cancels an in-flight probe, while full provider teardown does.

Macroscope summarized ae677a5.

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: 39ae7aef-3c3c-4b7b-b4f5-20f8a5040428

  • Autofix · 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 ❌

Comment thread Sources/AetherEngine/Native/RemoteHLSTimestampAnchor.swift Outdated
Comment thread Sources/AetherEngine/Native/RemoteHLSTimestampAnchor.swift
Comment thread Sources/AetherEngine/Subtitles/WebVTTBuilder.swift Outdated
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 ❌

Resolve the subtitle proxy against main's credential scoping (NAT-105) and
duration bounds (NAT-1): the variant fetch takes the CredentialScope, the
program duration goes through sumSegmentDurations, and the timestamp probe
sends credentials per URL with credentials.headers(for:). Prepared keeps
main's renditionNames. The anchor tests serve their disk origin over HTTP,
since the bounded playlist fetch now refuses a non-HTTP answer.
… before its slot

A Jellyfin-style origin restarts its transcode at the keyframe before the
requested slot (-noaccurate_seek -copyts), so the segment the timestamp
probe reads starts up to a keyframe interval before its playlist start and
the probe measured a negative anchor. Its media timestamps are still source
time, so the mapped rendition showed every cue early by the keyframe gap,
and AE#616 then matched lines at a zero offset and left sourceTime ahead of
the picture.

Only a positive anchor is an offset of the media timestamps (a muxer
delay), so negative anchors now keep the plain body and the lead stays
AE#616's to measure. The +10 s MPEG-TS case is unchanged.
@silo-kody

silo-kody Bot commented Oct 9, 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 ❌

@Rhainland

Copy link
Copy Markdown
Author

@superuser404notfound, I checked how this PR interacts with your AE#616 clock correction (upstream superuser404notfound#617, merged here as 19a222f), and there was a conflict. I fixed it in ae677a5.

What was wrong. The timestamp probe reads the segment the load opens on and computes its first media timestamp minus the segment's playlist start. On a Jellyfin-style origin (-noaccurate_seek -copyts, transcode restarted at the keyframe before the slot), a resumed load gets a segment at slot 12 s whose media starts at 10.5 s. The probe measured -1.5 s, and the rendition was served with X-TIMESTAMP-MAP=MPEGTS:0,LOCAL:00:00:01.500. Those media timestamps are already source time, so every cue showed 1.5 s early. AE#616 then matched the line at an offset of 0 instead of 1.5 s, which left sourceTime ahead of the picture by the gap. The error also persisted after seeks, because the map is fixed for the session.

What changed. Only a positive anchor is now applied. A muxer delay only moves timestamps later, so that covers the +10 s MPEG-TS case this PR targets. A negative anchor keeps the plain body, which is the behaviour before this PR, and the lead is left for AE#616 to measure. The new test keyframeRestartLeavesLeadToCueClock serves that origin through the proxy, checks the plain body, and feeds the resulting presentation time into RemoteHLSCueClock. It failed before the fix (offset 0 vs. 1.5 s) and passes now. The existing proxy, authorization, WebVTT and cue clock suites pass too (75 tests).

Please confirm one thing. An origin can have both a positive muxer offset and a keyframe restart, for example MPEG-TS with -copyts and the default 1.4 s delay, resumed mid-file. In that case the probe reads the offset minus the gap, which is still positive. The map then absorbs the gap, and AE#616 would measure about 0 on the first load. Does that match your understanding of the clock correction? Is that case worth handling here, for example by probing a segment whose slot is a keyframe, or should it stay a follow-up?

@superuser404notfound

Copy link
Copy Markdown

Thanks for checking this against AE#616 and for the regression test.

I don't think the mixed case resolves itself. The probe observes one number, D - G (muxer delay minus keyframe-restart gap), but the map has to be D alone. AE#616 is only correct when the rendition sits on true source time, because it measures item time against those same cues.

With the map at D - G (the mixed case): a cue at t is shown at PTS t + D - G, which is the frame at source time t - G. AVPlayer anchors the first segment (PTS S - G + D) at slot S, so that frame is at item time t. The result is that every cue appears G early on screen, and AE#616 measures an offset of 0, so sourceTime also leads by G. Both are off by the same G, and AE#616 cannot see it, because its reference moved with the map.

The negative branch has the mirror case. If D > 0 and G > D, the plain body shows cues D early, AE#616 measures G - D, and sourceTime ends up off by D.

So "positive only" is exact when D = 0 or G = 0. A TS origin with -max_delay 5000000 that resumes by restarting at the previous keyframe is the mixed case on every resumed load, so I would check whether Silo's own server does that before calling it an edge case.

One way to separate the two: D is a muxer constant for the session, and G only affects the first segment of a (re)started job. Probing the segment after the start segment (same job, and AVPlayer requests it next anyway) would read D directly, provided the origin forces keyframes on slot boundaries. That does not hold for stream copy, so it needs a fallback there. Fine as a follow-up from my side if the limitation is written down next to renditionAnchor(measured:).

The probe reads the muxer delay minus the keyframe-restart gap as one
number, so keeping only a positive measurement is exact when either is
zero. Document the mixed case next to renditionAnchor(measured:), what
it does to AE#616, why Silo's own HLS output does not reach it, and the
follow-up that would read the delay directly.
@Rhainland

Copy link
Copy Markdown
Author

@superuser404notfound, thanks, that is right: the probe sees D - G, so "positive only" is exact only when D = 0 or G = 0. I wrote the limitation down in 87ddcdb, in the doc comment on renditionAnchor(measured:) and in the matching paragraph of docs/formats.md. The comment covers both mixed cases (0 < G < D: cues G early and AE#616 reads about 0; G > D: cues D early and AE#616 reads G - D), why AE#616 cannot see either, and the next-segment probe as the follow-up, including its stream-copy caveat.

Silo's own server does not hit the mixed case. Every HLS job uses -copyts -max_delay 5000000, so MPEG-TS output has D = 10 s (fMP4 has 0), but G is 0 on both of its paths:

  • Re-encode: the input seek is accurate (-noaccurate_seek is added only when video is copied), restarts and resumes seek to a segment boundary, and -force_key_frames expr:gte(t,n_forced*<segment duration>) puts a keyframe on each boundary. A resumed job's first segment starts exactly on its slot.
  • Stream copy: the job does begin at the keyframe before the requested time, but the server probes that keyframe first and aligns the playlist to it (EXT-X-GAP padding up to the keyframe, EXT-X-START at the requested time). The segment's slot starts on the keyframe, so the probe again reads D.

So on Silo the probe reads D (10 s on the MPEG-TS transcode route this PR targets) on fresh and resumed loads. The mixed case belongs to third-party MPEG-TS origins that resume at the previous keyframe with a muxer delay, as you described, and stays for the follow-up you offered.

I also merged main to clear the CHANGELOG.md conflict (head c63015d). The focused proxy, cue clock, WebVTT and authorization suites pass (65 tests).

AI disclosure: written with Claude Code (claude-opus-5-5).

@silo-kody

silo-kody Bot commented Oct 11, 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 ❌

Comment thread Sources/AetherEngine/Native/RemoteHLSTimestampAnchor.swift
@superuser404notfound

Copy link
Copy Markdown

Thanks, the doc comment on renditionAnchor(measured:) captures both mixed cases correctly, and the explanation of Silo's two HLS paths settles the G = 0 question for your origin. No further concerns from my side on this one. The next-segment probe for third-party TS origins that restart at the previous keyframe is tracked upstream as superuser404notfound#763.

@superuser404notfound

Copy link
Copy Markdown

@Rhainland, one more question: once this lands in the Silo fork, would you open it as a PR against superuser404notfound/AetherEngine as well? The anchor fixes a real gap in the superuser404notfound#316 renditions for any origin with a muxer delay, not only Silo's, and upstream has no equivalent yet. If you do, that PR would also be the natural place for superuser404notfound#763 (reading D from the segment after the start segment), either in the same PR or as a follow-up on top of it. I'd rather not build superuser404notfound#763 against code that only exists on this branch.

@Rhainland

Copy link
Copy Markdown
Author

@Rhainland, one more question: once this lands in the Silo fork, would you open it as a PR against superuser404notfound/AetherEngine as well? The anchor fixes a real gap in the superuser404notfound#316 renditions for any origin with a muxer delay, not only Silo's, and upstream has no equivalent yet. If you do, that PR would also be the natural place for superuser404notfound#763 (reading D from the segment after the start segment), either in the same PR or as a follow-up on top of it. I'd rather not build superuser404notfound#763 against code that only exists on this branch.

Yes, I'll open it upstream once #14 merges here. It won't apply as-is: the probe goes through the fork's refreshable-authorization relay, which isn't upstream, so I'll port it onto upstream main without that dependency. I'd suggest keeping superuser404notfound#763 as a separate PR on top so the anchor and the next-segment probe can be reviewed on their own.

@Quick104
Quick104 merged commit 4c57d95 into Silo-Server:main Oct 13, 2026
3 checks passed
Quick104 added a commit to Rhainland/silo-apple that referenced this pull request Oct 13, 2026
…e#14

Move the pin from the PR branch head 38140f88 to its merge commit 4c57d95a
on AetherEngine main. Both commits have the same source tree (63e3ec86),
so the engine code that ships does not change. Refresh the reviewed
build-input and selector snapshots for the new project.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quick104 added a commit to Silo-Server/silo-apple that referenced this pull request Oct 13, 2026
* chore(deps): pin AetherEngine with the HLS subtitle timestamp fix

Moves the AetherEngine pin to the revision that serves injected WebVTT subtitle renditions with an X-TIMESTAMP-MAP matching the origin's media timestamps. On Silo's MPEG-TS transcodes, external subtitles on the remote-HLS route showed every cue 10 s early.

The revision also brings the engine commits merged since the previous pin: dropping credential headers from HTTP hops after an HTTPS redirect, and a 16-thread cap for software decoding.

Found during the Series validation, silo-apple#337 C10.

* chore(deps): pin AetherEngine to the keyframe-restart anchor fix

* ci: refresh the reviewed build-input snapshot for the AetherEngine pin

The platform selector seals project.yml. This branch changes only the
AetherEngine revision line, which owns no source path, so the reviewed
snapshot and the selector seal are refreshed for it.

* chore(deps): pin AetherEngine to the merge of Silo-Server/AetherEngine#14

Move the pin from the PR branch head 38140f88 to its merge commit 4c57d95a
on AetherEngine main. Both commits have the same source tree (63e3ec86),
so the engine code that ships does not change. Refresh the reviewed
build-input and selector snapshots for the new project.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

3 participants