Skip to content

feat: VOD seek bar with multiplayer position sync and late-join catch-up#6

Merged
dooly123 merged 13 commits into
BasisVR:mainfrom
towneh:feat/vod-seek-sync
Jul 8, 2026
Merged

feat: VOD seek bar with multiplayer position sync and late-join catch-up#6
dooly123 merged 13 commits into
BasisVR:mainfrom
towneh:feat/vod-seek-sync

Conversation

@towneh

@towneh towneh commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Stacked on #5 — the first six commits are that PR; review the last six here (duration → seek → managed wiring → networking → panel → binaries).

Summary

VOD seeking, end to end: a native seekable timeline, a seek bar on the Media Players panel, and multiplayer position sync — drift correction, owner seeks reaching every client, and late joiners landing at the owner's position instead of zero.

The networking layer needed almost nothing: FullState already carried positionTicks, the Seek message and drift correction already existed, and the late-join apply already set StartPosition — all dormant because native Duration was permanently zero and Seek threw. The substance is in the native engine.

Duration (basis_media_get_duration_us): progressive MP4 reports the mvhd duration (falling back to the longest track's stts total); TS-segment HLS VOD reports the summed EXTINF durations. 0 means unknown/live, and doubles as the "this source can seek" signal — sources that can't actually reposition (fMP4-segment HLS VOD, progressive files on servers without Accept-Ranges) deliberately stay at 0 rather than advertising a timeline they can't honour.

Absolute seek (basis_media_seek_us, asynchronous, lands on the preceding keyframe/segment boundary):

  • Progressive MP4: the byte source gains a reposition hook — a ranged GET on the same connection (206 required; a server that ignores Range fails the call instead of silently restarting at byte 0). The engine posts requests through a new optional take_seek sink callback, one per demux leg so a split source repositions both; the demuxer maps the target through its sample tables (video backs up to the preceding stss sync sample, edit-list-mapped timelines included), refetches at the earliest needed byte, and drops parked samples from before the jump. The paced read-ahead ring parks its reader around the refetch and stays alive at EOF, so a backward seek after the file fully buffered still works.
  • HLS VOD (TS segments): the segment list is retained at open; a seek rebuilds the fetch queue from the segment containing the target and flushes the stitched ring — the TS demuxer resyncs on the 0x47 sync byte.
  • The decoder isn't explicitly flushed: the paced clock's existing >1 s hard-resync (which names seek as its intended trigger) re-anchors on the jumped timestamps, and the managed audio path re-anchors via ResetSyncAnchor. If device testing surfaces an audible blip at the jump, a decoder flush export is the follow-up.

Managed: Duration/Seek/StartPosition route to the native engine (old native libraries read as duration 0 — exactly the pre-feature behaviour). Seek still throws NotSupportedException on timeline-less sources.

Networking (all on the existing component, no new sync surfaces beyond one message): an owner position heartbeat — a 9-byte latest-wins ping (Sequenced, like the framework's other position streams) every 5 s while playing seekable media, feeding the existing drift correction — and the owner's position/pause snapshot now stashes across a remote page-URL resolve and applies on the resolved source's ready, aged by the resolve time, so late joiners to a YouTube VOD land near the owner.

Panel: a timeline scrubber under the transport buttons (visible only when the selected player has a timeline; drag-debounced, seeks through the networking component when present), and the Status box now shows elapsed/total time plus the current title and uploader from the player's metadata.

Verification

Closed-loop demux harness against generated and real fixtures:

  • Duration: 12.000 s reported for the 12 s progressive fixture; 14.000 s for the edit-list fixture (content + 2 s empty edit — the mvhd duration covers the edited timeline).
  • Forward seek to 8 s mid-stream: first delivered video AU is the keyframe at exactly 8 s (key=1), audio resumes at 8 s, both tracks interleave immediately; the skipped middle shows in the AU counts.
  • Backward seek: correctly backs up to the preceding sync sample.
  • Seek on the edit-list fixture: target mapped through the edited timeline, landing on the right keyframe.
  • fMP4/CMAF regression: byte-identical delivery counts and interleave vs the fix: post-merge follow-ups — demux hardening, edit lists, paced timing, load identity #5 baseline.

Windows DLL (MSVC 19.51) and Android arm64 .so (NDK clang 18) rebuilt from this branch.

Editor-validated (Windows desktop, Unity 6000.5.3f1, single client) against a ~3 min real progressive recording served over HTTP:

2026-07-08_23-08-35_10mb.mp4
  • Duration and elapsed/total readout correct; the seek bar appears only for seekable media and tracks the playhead.
  • Forward and backward scrubs: a brief hold for the ranged refetch, then playback resumes at the target — video and audio together, no audio artefacts at the jump (so the deferred decoder-flush stayed unnecessary; the presenter's hard-resync plus the managed audio re-anchor cover it).
  • Stopping/pausing freezes the readout with the picture (found in testing: position used to report the last decoded frame, and the demuxer deliberately keeps feeding while presentation is frozen — it now reports the last presented frame, which is also what the heartbeat should broadcast).

Two other fixes came out of the editor pass, folded into their commits: the pacing anchor now re-anchors when a seek is taken (against the old anchor, a forward jump read as far-future and stalled delivery for the jump distance; a backward jump read as late and fast-forwarded back), and seekability is now proven by a Range: bytes=0- probe on the initial GET rather than trusted from Accept-Ranges — a server that advertises ranges without implementing them (Python's SimpleHTTPRequestHandler, for one) gets no seek bar instead of a dead stream on the first seek.

Not yet validated: the multiplayer leg (owner-seek propagation, heartbeat drift correction, late-join catch-up) — needs a second client; I'll report back here after that pass.

Notes and limitations

  • Seek granularity is keyframe/segment level — right for social viewing, not frame-accurate scrubbing.
  • fMP4-segment HLS VOD and TS-over-HTTP VOD don't seek (no retained index / no way to resync a mid-box flush); they play exactly as before and report no timeline.
  • Android's HTTP source has no reseek yet, so progressive VOD on Android stays timeline-less until the backend grows one (same per-backend staging as every other feature).
  • A seek posted in the instant the source finishes draining can be lost (the producer/reader has already retired); the UI's position refresh simply shows no jump, and the heartbeat/drift correction never engages on an ended source.

towneh added 7 commits July 8, 2026 20:46
Remote MP4 reaches this parser (HLS/CMAF segments, VOD URLs), so treat
every stream-supplied field as hostile:

- parse_traf: validate tfhd/tfdt/trun field reads against the box body,
  and reject a trun whose declared per-sample table does not fit in the
  box (or exceeds a sane per-fragment sample cap). Unchecked counts
  could previously drive reads past the buffered moof.
- Box walkers: rewrite child-size checks in subtraction form
  (sz > len - off) so a crafted size near INT_MAX cannot overflow the
  signed addition and bypass the bound.
- consume_mdat / consume_progressive: widen run positions to int64 and
  make the sample fit checks overflow-safe; cap progressive sample
  sizes at the box cap so a crafted stsz entry cannot drive a
  multi-gigabyte allocation.
- Timestamps: convert ticks to microseconds via quotient/remainder
  (ticks_to_us) instead of value * 1000000 / timescale, which overflows
  at stream-controlled timescales.
- ctts: honour the FullBox version - version 0 offsets are unsigned,
  version 1 signed. Previously all offsets were cast signed.
- stz2: detect the compact sample-size table and surface a clear
  unsupported-format error instead of starting playback with the
  affected track silently missing.
- stsd: bound the mp4a fixed-header reads to the sample entry size.
- Annex B sizing: move the worst-case AVCC-to-Annex-B output size into
  a shared helper (basis_avcc_annexb_cap) and use it from both MP4
  paths and RTMP. RTMP previously allocated dlen + 64, which undersizes
  streams using 1-3 byte NAL length prefixes and silently dropped the
  access unit.
Remove the never-read next_dts track field, update the fMP4-only header
comment (the demuxer has handled progressive MP4 since the classic-table
work), refresh the top-of-file assumptions to match, and break the
nested '/*' in basis_bitstream.h that warned on every translation unit.
Progressive playback previously ignored edts/elst, so every track started
at presentation time zero regardless of its edit list. A track delayed by
an initial empty edit (common muxer output for A/V alignment) played that
much early, permanently desynced from the other track.

Parse mvhd for the movie timescale and each track's elst, then map media
time onto the movie timeline at emission: initial empty edits become a
presentation-time delay, and the first normal edit's media_time becomes
the media-time origin (encoder priming / initial trim). Samples ahead of
the origin keep a negative presentation time rather than being dropped,
since video there can still carry reference frames; multi-segment edits
beyond the first normal entry are out of scope for a linear walk.

Fragmented MP4 is unaffected - its timeline comes from tfdt.
Delivery pacing gated on the presentation timestamp of whatever the demux
thread submitted last. A video AU whose composition offset puts its pts
past the pacing lead therefore slept the demux thread out beyond its own
decode turn - and any earlier samples of the other track queued behind it
on the same thread arrived late enough for the audio queue to trim them
as stale. Streams reordering less than the ~400 ms lead masked this.

Carry the decode timestamp through the video sink alongside pts and gate
on it, while the decoder and caption store keep receiving pts. Demuxers
without decode timestamps (TS, RTSP) pass pts for both, which is the old
behaviour; RTMP forwards the FLV tag timestamp it already had.

The progressive MP4 walk had the file-order variant of the same stall: a
muxer that interleaves chunks coarsely (a chunk of video ahead of the
matching audio) blocked the forward reader on paced video before it could
reach the audio bytes at all. Samples whose file order runs ahead of
their delivery turn now park in bounded per-track queues and deliver in
decode-time order across tracks; past the 32 MB bound (or once the mdat
has nothing more to read) the earliest parked sample delivers regardless,
degrading to the previous behaviour instead of buffering without limit.

The fragmented path keeps its decode-time merge and hands the merge key
to the sink as the decode timestamp.
basis_media_native.dll (win-x64, MSVC 19.51) and libbasis_media_native.so
(android arm64-v8a, NDK clang 18) built from this branch.
The load-identity plumbing around metadata had three gaps, all variants
of 'which load does this async continuation belong to':

- LoadGeneration advanced only in LoadUrl, so a stale resolver
  continuation could pass its staleness guard after a LoadLocalPath,
  a direct LoadSource or a CPU Source assignment replaced the source.
  It now advances on every source replacement, whichever entry point.
- The LoadUrl metadata seed was a bool that stayed armed when routing
  ended without a load (no resolver installed, or a resolver that
  errored before LoadSource), so the next unrelated load inherited the
  abandoned URL as its metadata origin. The seed is now keyed to the
  generation that armed it and is retired on the no-resolver path and
  by every non-continuation entry point.
- LoadSource wrote the resolved metadata origin back into the caller's
  BasisMediaSource, where it went stale if the caller mutated the
  descriptor's Uri and loaded it again. The origin is now retained
  player-side and only trusted while the same instance reloads with an
  unchanged Uri; the caller's descriptor is no longer touched.

Also: Metadata now returns a snapshot (and OnMetadataChanged delivers
one), so external code can't mutate the player's live instance past the
change event - ApplyMetadata is the mutation path. Assigning a CPU
Source clears the previous media's metadata (event fires with null)
instead of leaving it reported as current.
Adds basis_media_get_duration_us: 0 while unknown and for live sources,
so a non-zero duration doubles as the 'this source has a seekable
timeline' signal the managed layer and networking gate on.

Demuxers report through a new optional on_duration sink callback:

- Progressive MP4: the mvhd duration (now parsed alongside the movie
  timescale; the all-ones unknown sentinel is ignored), falling back to
  the longest track's stts total when mvhd carries none. Reported only
  when the classic tables are walkable - fragmented streams stay at 0
  and get their duration from whatever layer knows the timeline.
- HLS VOD (EXT-X-ENDLIST): the summed segment EXTINF durations, exposed
  as basis_hls_duration_ms. A playlist beyond the internal segment cap
  reports the truncated total, matching what actually plays.
@towneh towneh force-pushed the feat/vod-seek-sync branch 5 times, most recently from 22ae313 to 5038129 Compare July 8, 2026 21:49
towneh added 6 commits July 8, 2026 22:58
…LS VOD

basis_media_seek_us(engine, target_us): accepted on sources with a
seekable timeline (duration > 0), asynchronous, lands on the preceding
keyframe / segment boundary.

Progressive MP4 over HTTP: the byte source gains a reposition hook -
basis_win_http_reseek swaps the response for a ranged GET on the same
connection (206 required; a server that ignores Range fails the call
instead of silently restarting at byte 0). The engine posts seek
requests through a new optional take_seek sink callback, one per demux
leg so a split source repositions both; the demuxer maps the target to
each track's table cursor (video backs up to the preceding stss sync
sample), refetches at the earliest needed byte, and drops parked
samples from before the jump. The paced read-ahead ring parks its
reader around the refetch and stays alive at EOF so a backward seek
after the file has fully buffered still works.

HLS VOD with TS segments: the segment list (uri + duration) is retained
at open; a seek rebuilds the fetch queue from the segment containing
the target and flushes the stitched ring - the TS demuxer resyncs on
the 0x47 sync byte, and the decoder's existing >1s hard-resync
re-anchors the paced clock. fMP4-segment VOD is excluded (a mid-box
ring flush can't be resynchronised) and therefore reports no duration:
a non-zero duration is the managed layer's seekability signal, so the
progressive path likewise only reports one when its source can
actually reposition.

Live sources are untouched: no pacing, no reseek hook, seek requests
rejected at the ABI.
… native engine

Duration consults the native engine's timeline when no CPU seekable
source is active (0 stays TimeSpan.Zero, preserving the Duration-gated
behaviour everywhere). Seek dispatches to the native asynchronous seek
instead of throwing on the native path - it still throws
NotSupportedException when the source has no seekable timeline - and
resets the audio sync anchor so the scheduled-audio ring re-anchors on
the post-seek timeline; OnSeekCompleted reports the requested target
(the native landing position is the preceding keyframe). A deferred
BasisMediaSource.StartPosition now applies on the native path at ready,
best-effort: a live source skips silently, since a late joiner may sync
a position into a stream that turns out to have no timeline.

Old native libraries without the new exports read as duration 0, which
is exactly the pre-feature behaviour.
The networking layer already carried everything needed - FullState
positionTicks, the Seek message, drift correction, StartPosition on the
late-join apply - all gated on Duration > 0, which was permanently zero
for native sources. With the engine now reporting a timeline and
honouring absolute seeks, two gaps remained:

- Owner position heartbeat: a 9-byte latest-wins ping (Sequenced, like
  the framework's other position streams) every 5s (inspector-tunable,
  0 disables) while playing seekable media, so passive clients
  re-converge between state events through the existing drift
  correction. Drift-only: it never starts or pauses playback.
- Resolved page URLs skipped the owner's position/pause snapshot
  entirely (resolution is async, and the old backend couldn't seek
  anyway). The snapshot is now stashed with the load and applied on the
  resolved source's OnReady, aged by the local resolve time when the
  owner was playing - so a late joiner to a YouTube VOD lands near the
  owner's position instead of at zero, and the heartbeat refines the
  residual.
…ers panel

Playback group gains a timeline scrubber beneath the transport buttons,
shown only when the selected player reports a seekable timeline
(Duration > 0). The slider API has no drag events, so a seek fires once
the handle rests for 0.35s; the per-tick position refresh leaves the
knob alone while a drag is pending. Seeks route through the networking
component when present (ownership + broadcast), else straight to the
player.

The Status group now shows elapsed/total time next to the state word
(ticking the repaint gate once per second, only while a timeline is
showing) and the current metadata - title and uploader - refreshed via
OnMetadataChanged. Player-supplied text is <noparse>-wrapped like the
existing error strings.
basis_media_get_position_us returned the PTS of the most recently
DECODED frame. The demuxer keeps feeding the decoder while presentation
is paused or stopped (delivery pacing runs on its own wall anchor), so
a stopped player's position kept counting up - visible now that the
panel surfaces a time readout and a seek bar. Track the last presented
PTS in a field that survives the presenter's resync sentinel resets and
report that instead, falling back to the decode-side value before the
first frame shows (start-up, audio-only) so early consumers still see
the clock move. This also makes the networking position heartbeat
broadcast what viewers actually see rather than the decode lead.
basis_media_native.dll (win-x64, MSVC 19.51) and libbasis_media_native.so
(android arm64-v8a, NDK clang 18) built from this branch.
@towneh towneh force-pushed the feat/vod-seek-sync branch from 5038129 to 1fb0de2 Compare July 8, 2026 21:58
@towneh towneh marked this pull request as ready for review July 8, 2026 22:07
@dooly123 dooly123 merged commit 2982ecc into BasisVR:main Jul 8, 2026
@towneh towneh deleted the feat/vod-seek-sync branch July 9, 2026 00:58
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.

2 participants