Skip to content

fix(mediaplayer): re-anchor HLS-TS VOD seek pacing (+ Android AAC-config / crop-rect hardening)#956

Merged
dooly123 merged 14 commits into
BasisVR:developerfrom
towneh:fix/mediaplayer-hls-vod-seek-reanchor
Jul 14, 2026
Merged

fix(mediaplayer): re-anchor HLS-TS VOD seek pacing (+ Android AAC-config / crop-rect hardening)#956
dooly123 merged 14 commits into
BasisVR:developerfrom
towneh:fix/mediaplayer-hls-vod-seek-reanchor

Conversation

@towneh

@towneh towneh commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

A batch of mediaplayer native fixes on top of #953, one commit per concern. The headline is the HLS-TS VOD seek fix; the rest are Android AAC-config and crop-rect hardening that came up alongside it.

HLS-TS VOD seek re-anchor (the main fix). On the HLS-TS VOD path, seeking the on-demand slider misbehaved: a forward seek froze for roughly the jump distance before resuming, and a backward seek fast-forwarded unpaced through the intervening segments back to the pre-seek position instead of honouring the seek. Root cause is the delivery pace clock: it has to re-anchor at the flushed boundary on a seek, and while that re-anchor existed it lost a race — a stale pre-seek access unit could win it and pin the pace anchor to the old timestamp. The fix routes the HLS seek through the same flush-and-re-anchor handshake the MP4 byte-source path already uses: basis_hls_read raises a BASIS_READ_REPOSITION sentinel at the flushed boundary (gated on the seek generation), basis_ts_run drops the ring and PES accumulators and takes the seek to re-anchor on the demux thread, the seek publishes its sequence before arming the producer, and the whole handshake is serialised through the ring lock. A seek that races end-of-stream on a VOD no longer hangs.

Android AAC-config hardening (follow-up to #953). Only publish a fully-parsed AudioSpecificConfig rather than announcing a partial one, bound the esds descriptor walk against its declared lengths, and fix the AAC-LC core trim while synchronising the Android video dimensions.

Android crop-rect hardening (follow-up to #953). Read and apply the display-crop rect as a unit with endpoint validation, and close a data race on the crop rect between the decode and present paths.

Binaries. Win x64 and Android arm64 native plugins rebuilt (RIST-enabled) to carry all of the above.

Docs: README refreshed, and TESTING.md gained an HLS-TS VOD seek row (seek both directions on the Mux master, confirm playback resumes paced at 1x from the target rather than freezing forward or flooding backward).

Verified on Windows (Unity editor, D3D11) and Android (Quest Pro) — full test matrix under Notes.

Required checks

All boxes below must be ticked before this PR can merge. If a check is genuinely N/A, tick it anyway and explain under Notes.

  • Tested — I built and ran this locally. The change works in the editor and (where relevant) in a built player.
  • Transform access is combined and limited — In hot paths, transform reads/writes go through TransformAccessArray or are otherwise batched. I have not added per-frame transform.position / transform.rotation / transform.localPosition calls inside loops. Whenever I need both position and rotation, I use the combined APIs — SetPositionAndRotation / SetLocalPositionAndRotation for writes, GetPositionAndRotation / GetLocalPositionAndRotation for reads — instead of two separate property accesses; the combined call does one local-to-world matrix traversal instead of two.
  • Addressables used for asset/memory loading — Any new asset loads go through Addressables. No new Resources.Load, no direct asset references that pull large content into memory on scene load.
  • No new GetComponent / AddComponent where avoidable — Where unavoidable, the result is cached on a field, and any GetComponent<T> is replaced with TryGetComponent<T>(out var x) — bare GetComponent will be denied. TryGetComponent is the modern API (Unity 2019.2+) and skips the Editor-only GC allocation GetComponent causes when a component is missing: Unity wraps the null return in a managed "fake null" object so its overloaded == operator can still detect destroyed C++ objects, and constructing that wrapper allocates; TryGetComponent returns a bool plus out parameter and never builds the wrapper. None of these calls run inside Update, LateUpdate, FixedUpdate, jobs, or other per-frame code paths.
  • Per-frame work is scheduled through BasisEventDriver — Any new per-frame work hooks into BasisEventDriver rather than adding standalone Update / LateUpdate / FixedUpdate callbacks on a MonoBehaviour.
  • Anything added to BasisEventDriver is bulletproof, or guarded by try/catchBasisEventDriver runs the single per-frame tick that drives the whole framework (network apply, local player sim, blendshapes, JigglePhysics, nameplates, and more) as one sequential chain. An unhandled exception anywhere in that chain aborts the rest of the tick, so every step after the throwing one is silently skipped for that frame. New work added to the driver must either be guaranteed not to throw, or be wrapped in a try/catch that contains the failure and surfaces it through BasisDebug — logged once / rate-limited, never every frame (see the existing HVRBasisBuiltInAddresses.Simulate() guard for the pattern). Expect this to be scrutinized closely in review.
  • Considered jobification — I asked whether this work can be moved to a Unity Job (Burst-compiled where possible). If it can, it is. If it cannot, the reason is in Notes.
  • No needless { get; set; } properties or access lockdowns — Public fields are fine; Basis is meant to be read and modified freely, so don't wall things off private/internal without a real reason. Don't wrap a field in { get; set; } when the accessors do nothing — property accessors have a real performance cost vs direct field access, and the lead maintainer prefers plain fields (or a method / setter-only property when only the setter needs logic) over a noop-getter pair. For .Instance singletons, callers reassigning Type.Instance is allowed; if that would break your code, log a warning or throw — don't block the assignment. Locking down access is not your call.
  • Camera access goes through BasisLocalCameraDriver — Code that needs the local camera (transform, projection, rig data, etc.) pulls it from BasisLocalCameraDriver rather than looking one up itself. Don't roll a separate camera discovery path.
  • Logging uses BasisDebug — All new logging calls go through BasisDebug.Log / BasisDebug.LogWarning / BasisDebug.LogError (with an appropriate LogTag) instead of UnityEngine.Debug.Log / Debug.LogWarning / Debug.LogError. BasisDebug routes through Basis's tagged, color-coded logger and respects the project-wide LoggingDisabled toggle so logging can be killed at runtime; bare Debug.Log calls bypass that and will be denied.
  • No scene-wide discovery for dependencies — New code is architected so it does not need FindObjectOfType / FindObjectsOfType / GameObject.Find / FindGameObjectsWithTag to locate what it depends on. References are wired in — registered through an existing manager/driver, injected at init, or passed in by the caller — rather than discovered by scanning the scene at runtime. If a scene scan is genuinely unavoidable, justify it under Notes.
  • No allocations in hot paths — Per-frame code (Update / LateUpdate / FixedUpdate, simulation loops, jobs, anything called once per frame or more) does not allocate. No new on reference types, no LINQ, no string concatenation/interpolation, no boxing, no foreach over interface-typed collections. Allocate once at init and reuse the buffer.
  • No debugging in hot paths — No log calls of any kind on per-frame paths, including BasisDebug. Hot-path logging floods the console and incurs cost on every frame regardless of whether the message is filtered out downstream. If a hot-path log is needed while iterating, gate it behind #if UNITY_EDITOR and remove (or leave gated) before merge.
  • Hot-path collection access is optimized — Cache .Count (lists) / .Length (arrays) into a local int before the loop instead of re-reading the property each iteration. Prefer T[] (with a separate length int when the array is over-sized) over List<T> where the data is hot — Unity's mono BCL doesn't expose CollectionsMarshal.AsSpan(List<T>), so a list can't be fed into Span<T> / unsafe paths cleanly. Where the perf justifies it, drop into Span<T> / ref locals / Unsafe.As / unsafe pointer code to skip bounds checks and copies, and call out the invariants you're relying on under Notes so reviewers can sanity-check them.

Testing details

Tick the platforms you actually tested on. Leave the rest unticked — these are informational and do not block merge.

  • Windows
  • Linux
  • Android
  • iOS
  • macOS

Input / control mode coverage:

  • Tested in VR (note headset under Notes)
  • Tested in desktop / non-VR mode
  • Tested with phone controls (mobile touch input)
  • N/A — change does not touch player/XR/input code

Where applicable, confirm these flows still work after your changes:

  • Hot-switching (desktop ↔ VR mode swap at runtime)
  • Avatar swapping
  • Server swapping (joining / leaving / changing servers)
  • N/A — change does not touch any of the above

Notes

  • Tested — Windows (Unity editor, D3D11) + Android (Quest Pro): both green across HLS-TS VOD seek both directions (the fix — no forward stall / backward flood), integrated-fMP4 seek (regression check for MediaPlayer: Seek for integrated fMP4 file #952 — unaffected), progressive + trailing-moov MP4 seek, AAC 5.1 in a progressive MP4 on Android (discrete, not silence), the Android coded-height crop (clean edge on device), RTSP UDP/TCP negotiation, HTTP-TS + HLS live, LPCM 7.1, HE-AAC 5.1, WAV, CEA-608 captions, both RIST modes, and the SSRF / localhost / file:// security gates.
  • Native-only change. C sources under com.basis.mediaplayer/Native~ plus the prebuilt Win x64 / Android arm64 binaries, and README/TESTING docs. No C#, Unity gameplay, scene, transform, camera, per-frame, or input code, so the Unity/C#-specific required checks are ticked N/A on that basis.
  • Base. Sits on fix(mediaplayer): multichannel AAC in progressive MP4 + coded-pad crop (Android) #953 (merge-base), a few commits behind developer; no mediaplayer source has changed on developer since fix(mediaplayer): multichannel AAC in progressive MP4 + coded-pad crop (Android) #953, so it rebases clean and the committed Win x64 / Android arm64 binaries are current.

towneh added 12 commits July 13, 2026 19:38
…ndary

An HLS-TS VOD seek repositioned the segment source but left delivery pacing
anchored to the pre-seek timeline, so a forward seek froze for the jump
distance and a backward seek flooded unpaced back to the pre-seek position.

The pace re-anchor (pace_started = 0) was set on the main thread in
basis_media_seek_us while the ring flush ran on the producer thread and AUs
were produced by the demux thread. With no synchronisation, a stale pre-seek
AU (still buffered in the TS demuxer, or read from the ring before the flush)
won the re-anchor and pinned the pace base to the old PTS: post-seek AUs then
read as far-future (forward: pace_gate sleeps for the jump) or as late
(backward: pace_gate never sleeps, flood). The byte-source (MP4) seek avoids
this by doing the reposition, sample drop and re-anchor atomically on the
demux thread; HLS had no equivalent.

Make HLS match: signal the reposition through the read path so the flush and
the re-anchor happen atomically at the exact byte boundary, on the demux
thread.

- basis_hls: a seek generation pair (seek_gen bumped on request, flush_gen on
  the producer after it clears the ring and requeues at the target). While they
  differ basis_hls_read withholds the pre-seek ring (and ring_write stops
  filling so the producer can't wedge against a withholding consumer); once
  they match it returns BASIS_READ_REPOSITION once at the boundary.
- basis_ts_run: on BASIS_READ_REPOSITION, drop the partial-packet buffer and
  both PES accumulations and take the seek, re-anchoring the pace clock on the
  demux thread before the target segment's bytes flow.
- basis_media_seek_us: publish seek_seq before arming the producer and drop the
  racy main-thread re-anchor.

TS-HLS VOD seek shipped with this defect (never a working case), so this is a
correctness fix, not a regression. Shared clock and demux path, so it applies
to Windows and Android alike. TESTING.md gains an HLS-TS VOD seek row.
The seek-generation handshake between the seek caller, the segment producer
and the byte reader used only volatile fields, which orders nothing across
threads under the C11 memory model. Publish {seek_target, generation, pending}
under the ring lock in basis_hls_request_seek, snapshot them under the same
lock in the producer's flush, and read the generations under the lock in
basis_hls_read alongside the ring state they gate. The byte path was already
lock-guarded; this closes the visibility gap on the generation counters, which
matters on weakly-ordered targets (Quest arm64).
The MediaFormat display-crop rect (fcValid/fcL/fcT/fcR/fcB) is written on the
decode thread in drain_video_output and read every frame on the AImageReader
listener thread in on_image, as plain ints with no synchronisation — a data
race, undefined behaviour on arm64, and a torn/stale crop rect at worst. The
neighbouring dispW/dispH already use atomics; extend the same pattern to the
crop rect: store the coordinates then publish fcValid with release, and load
fcValid with acquire before snapshotting the coordinates into locals so the
reader sees one consistent rect.
The seek-withhold branch in basis_hls_read spun forever if a seek was armed in
the narrow window after the producer's top-of-loop seek check but before it
broke out on endlist: seek_gen advanced, the producer set producer_done without
ever flushing, and the withhold branch (seek_gen != flush_gen) never reached the
producer_done check below it. Seeking near the end of a VOD hung the demux
thread.

Two-part fix: the producer loops back to honour a pending seek before breaking
on endlist (so a late seek still repositions rather than being dropped), and the
withhold branch returns 0 when producer_done is set as a backstop, ending
cleanly instead of spinning.
README lists finished TS-segment HLS VOD playlists among the seekable sources
alongside progressive MP4 and WAV. TESTING.md's on-demand multiplayer note no
longer claims the backend "exposes no absolute seek" (it does): on-demand
clients drift-correct by seeking to the owner's playhead when the source is
seekable, matching the README's networked-sync section.
…gths

The esds parser read each descriptor's declared length but discarded it, so the
DecoderSpecificInfo (0x05) was bounded only by the whole esds payload — a
malformed file could place a stray 0x05 after the real DecoderConfig and have
it accepted as the AudioSpecificConfig. esds_desc_len now reports validity
(rejecting truncated and unterminated 4-byte lengths), and the walk bounds the
ES and DecoderConfig children, optional fields, fixed header and ASC copy
against their owning descriptor's end.

The ASC field parse also handles the escape forms via a bounded bit reader:
audioObjectType 31 (AOT = 32 + 6 bits) and samplingFrequencyIndex 15 (24-bit
explicit rate) shift the channelConfiguration that follows, which the old fixed
two-byte offsets misread. No memory-safety change (the ASC copy was already
bounds-checked); this rejects malformed structure and reads exotic AAC configs
correctly.
…endpoints

Two crop-rect refinements on the Android decode path:

- The per-field release/acquire scheme was safe only for the first publication.
  On a later format change fcValid stays 1, so the listener could combine old
  and newly written coordinates, and a format that drops DISPLAY_CROP left the
  previous rectangle active. Publish and snapshot the whole rect under d->vm and
  clear validity on every format change, so the rect is always consistent and
  never stale. (d->vm is free on both the decode and listener paths.)
- The bounds check validated crop dimensions but not endpoints: a non-zero
  offset could pass while left+width or top+height ran past the buffer. Require
  the full rectangle inside the buffer on both the MediaFormat and AImage paths.
Bring every side of the seek/terminal-state protocol under the ring lock so
volatile is no longer relied on for cross-thread ordering. seek_pending is now
read under the lock in ring_write and taken as a locked snapshot at the top of
the producer loop; producer_done is written under the lock on every exit; and
basis_hls_request_seek accepts a request under the same lock that the endlist
path uses to set producer_done. That arbitration closes the lost-seek window:
a request landing just as the producer exits on end-of-stream is either honoured
or cleanly rejected, never left pending while the reader withholds forever.
…trim

- Coded dims (vw/vh) were read on the listener thread but written on the decode
  thread without synchronisation; snapshot and publish them under d->vm with the
  crop rect.
- The display size was two separate relaxed atomics, so a reader could latch a
  mixed (new width, old height) pair across a format change and size the Unity
  render texture wrong; publish it as one packed atomic.
- The inert-SBR trim kept (p+7)/8 bytes, which for a set dependsOnCoreCoder
  (p=30) retained two bits of the sync extension in a malformed csd-0; only trim
  the byte-aligned two-byte AAC-LC core.
t->asc_len was committed before the ASC bit parse was checked, so a truncated
escaped config (AOT 31 / explicit rate) was still handed to the decoder despite
field decoding rejecting it. Publish asc_len only inside the bit>=0 guard.
- BASIS_READ_REPOSITION: state that this exact value is the reposition signal
  and any other negative return is an error (it is itself < 0).
- README: a reported duration is necessary but not on its own a guarantee of
  seekability.
- TESTING: live/unindexed clients converge independently to the live edge rather
  than staying start-together, matching the README's networked-sync section.
Rebuilt off developer with the full HLS seek handshake synchronisation, the
Android video-dims/crop synchronisation, the AAC-LC core-trim fix, and the
fully-parsed-AAC-config guard, on top of the seek re-anchor, EOS-hang fix,
crop-rect race/endpoint fixes and esds hardening. The Windows DLL also carries
BasisVR#953's cross-platform esds multichannel-AAC parse.
@towneh towneh self-assigned this Jul 13, 2026
@towneh towneh added the bug Something isn't working label Jul 13, 2026
@towneh towneh marked this pull request as ready for review July 13, 2026 23:29
@towneh towneh requested a review from dooly123 July 13, 2026 23:29
Both streaming prefabs still referenced the script GUID that now belongs
to the deprecated BasisObjectSyncNetworking compatibility shim, left over
from when the shim took over the legacy GUID so old content bundles keep
loading. The serialized fields already match BasisPickupSyncNetworking,
so only the m_Script reference changes.
@dooly123 dooly123 merged commit 2acadaf into BasisVR:developer Jul 14, 2026
12 checks passed
@towneh towneh deleted the fix/mediaplayer-hls-vod-seek-reanchor branch July 14, 2026 23:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants