Skip to content

feat(mediaplayer): video resolver registry + sanitized URLs + https URL normalisation #889

Open
towneh wants to merge 13 commits into
BasisVR:developerfrom
towneh:feat/mediaplayer-resolver-registry
Open

feat(mediaplayer): video resolver registry + sanitized URLs + https URL normalisation #889
towneh wants to merge 13 commits into
BasisVR:developerfrom
towneh:feat/mediaplayer-resolver-registry

Conversation

@towneh

@towneh towneh commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • URL resolvers have a standard method to register themselves The media player previously took a single (ytdlp) resolver; this turns that behaviour into a registry where a resolver can register itself using documented processes and are tried in priority order until one claims the load, otherwise the URL loads directly. It's the seam the optional yt-dlp integration plugs into, and stays completely inert with nothing registered — every URL loads exactly as it does today.
  • Bare and protocol-relative web addresses now route correctly. A URL with no scheme (e.g. www.youtube.com/watch?v=…) gets https:// prepended, and a protocol-relative //host/… gets https:, so each is treated as an absolute URL.
  • Whitespace-only input is rejected instead of slipping through as a bogus URL.

No behaviour change for anyone not using the dlp-native resolver package. With no resolver registered the router is inert — every URL loads exactly as it does on developer today. This PR only reshapes the optional resolver seam and tightens URL parsing; the core player (transports, demux, decode, networked sync) is untouched.

See Notes for how the registry, normalisation, and threading contract work.

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 in the editor on Windows: a page URL resolves through a registered resolver and the resolved stream loads and plays. Direct stream URLs continue to load unchanged.

How it works

  • BasisMediaUrlRouter registry — multiple IBasisVideoResolvers can register; the router tries them in descending Priority (registration order breaks ties) until one takes ownership of a load, otherwise the URL loads directly. The router routes only; host trust stays with BasisMediaPlayerSecurity. Authoring a resolver is documented in the player README.
  • https normalisationNormalizeUrl prepends https:// to a bare web URL and https: to a protocol-relative //host/… so each routes and loads as an absolute URL; anything that already has a scheme, or is a local path, is returned trimmed but unchanged. Since //host/… reads as web, network paths use the backslash UNC form (\\server\share) or file:// — documented on NormalizeUrl.
  • Whitespace-only input rejectedLoadUrl, LoadLocalPath, and the Media Players panel's load button now guard with IsNullOrWhiteSpace, so a blank-but-non-empty string no longer slips past the empty check and through normalisation as a bogus URL.
  • Main-thread contract — the resolver list is unsynchronised, so register/resolve on the main thread (registration via RuntimeInitializeOnLoadMethod, resolution from the player's load path, both main-thread).

Loading a page URL with no resolver installed

A YouTube/Twitch (or any page) URL loaded with no resolver registered degrades gracefully — it never throws, crashes, or tries to demux an HTML page. LoadUrl sees it isn't directly playable and stops before attaching a backend, leaving a plain-English reason:

This looks like a page URL (e.g. YouTube/Twitch). Playing it needs a media URL resolver package, and none is installed.

That surfaces two ways: the player's Status becomes Error with the text on LastErrorMessage, which the in-game Media Players panel displays, and the same is logged on each such load as a BasisDebug warning (warning, not error — a missing optional package is expected, not a fault). The message names no specific resolver — the core player stays agnostic about which integration fills the role. Direct stream URLs (rtsp/rtmp/rist, .mp4/.ts/.m3u8, …) are unaffected and keep playing.

Also in this PR

  • Single resolver API — the older single-delegate Resolver slot is gone; integrations register through Register(IBasisVideoResolver) only, and the in-tree yt-dlp resolver uses it directly.
  • yt-dlp is the lowest-priority fallback — the bundled resolver registers at the bottom of the priority order, so any more-specific resolver added later gets first claim and yt-dlp only handles what nothing else took.
  • Resolver-agnostic core — the missing-resolver message (panel + log) refers to "a media URL resolver package" rather than naming yt-dlp, keeping the core player free of any specific integration's name. The agnostic surface (interface, registry, message) lives in the player; the concrete package stays named for what it is (com.basis.integration.ytdlp).
  • Resolver authoring documented — a "Writing a resolver" section in the player README covers the interface, registration, priority, and the main-thread / routing-only contract, with the yt-dlp package as the worked example.

Checklist context

Everything else is ticked as N/A or compliant — this is URL-routing / string-handling only:

  • No transform access, Addressables loads, GetComponent/AddComponent, camera lookups, or per-frame work, so the hot-path / BasisEventDriver / jobification / collection-access checks don't apply.
  • Logging goes through BasisDebug with LogTag.Video.
  • The only properties added are IBasisVideoResolver interface members (Priority + the resolve methods) — interface contract, not noop accessors walling off a field.
  • Resolver registration is via RuntimeInitializeOnLoadMethod, not scene scanning.

@towneh towneh force-pushed the feat/mediaplayer-resolver-registry branch 7 times, most recently from 612d55c to 9f16c30 Compare June 27, 2026 17:36
@towneh towneh requested a review from dooly123 June 27, 2026 17:55
@towneh towneh added the enhancement New feature or request label Jun 27, 2026
@towneh towneh marked this pull request as ready for review June 27, 2026 17:55
@towneh towneh changed the title feat(mediaplayer): prioritised video resolver registry + https URL normalisation feat(mediaplayer): video resolver registry + sanitized URLs + https URL normalisation Jun 27, 2026
towneh added 13 commits June 28, 2026 12:39
Replace the single resolver delegate on BasisMediaUrlRouter with a priority
registry of IBasisVideoResolver, so multiple resolvers can coexist and are
tried in order; the legacy delegate is kept as a back-compatible adapter. The
yt-dlp integration registers a resolver instead of setting the delegate.

Default a missing URL scheme to https at the LoadUrl chokepoint, so a bare page
URL ("www.youtube.com/watch?v=...") routes through the resolver instead of being
read as a direct source and rejected as non-absolute. The media player panel
writes the normalised URL back into the field so the added scheme is visible
rather than silent.
…hread contract

LoadUrl guarded with IsNullOrEmpty but then called NormalizeUrl, which returns
whitespace-only input unchanged, so a blank-but-non-empty string slipped through
as a bogus URL. Switch LoadUrl, LoadLocalPath and the Media Players panel's load
button to IsNullOrWhiteSpace so blank input is rejected at the entry point.

Also document that BasisMediaUrlRouter's resolver list is unsynchronised and that
Register/Unregister/TryResolveAndLoad must run on the main thread.
The router's single-delegate Resolver property predates the IBasisVideoResolver
registry and existed only for back-compat. The sole in-tree consumer (the yt-dlp
installer) now registers via Register(IBasisVideoResolver), so the delegate slot,
its backing field, and the LegacyResolverAdapter are unused. Removing them leaves
the registry's core (Register/Unregister/TryResolveAndLoad/IsDirectlyPlayable/
NormalizeUrl) and drops the only stateful, swap-on-assign code path.
The resolver registry's authoring contract lived only in the IBasisVideoResolver
XML docs and the yt-dlp package. Add a "Writing a resolver" subsection to the
player README covering the interface, RuntimeInitializeOnLoadMethod registration,
the CanResolve/TryResolve/Priority contract, and the main-thread and
routing-only-never-trust rules, pointing at the yt-dlp integration as the worked
example.
A scheme-relative URL ("//host/path") was returned untouched by the rooted-path
branch, after which IsDirectlyPlayable treats it as non-HTTP and skips resolver
routing, mis-loading it as a direct/local source. Prepend "https:" to a "//"
prefix before the rooted-path check so it routes and loads as an absolute URL.
Backslash UNC paths are unaffected.
…lver

In the priority registry, yt-dlp at priority 0 could claim a page URL ahead of a
more-specific resolver. yt-dlp is the generic last-resort handler, so register it
at int.MinValue: specialised resolvers always get first claim, and yt-dlp only
sees URLs none of them took. CanResolve stays broad by design — there's no cheap,
non-blocking way to know which of yt-dlp's many sites a URL belongs to without
running (async) extraction, so the fallback takes any non-directly-playable URL
and reports failures through the async resolve path.
Expand the "Without it" note so it matches how a page URL actually degrades with
no resolver installed: the player enters an error state with a short message,
shown in the Media Players panel and logged once as a warning, rather than
throwing or trying to demux the HTML page.
"//host/…" is read as a protocol-relative web URL, which is indistinguishable by
form from a forward-slash UNC path. Spell out the contract in the NormalizeUrl
doc: network paths must use the backslash UNC form (\server\share) or a file://
URL, not forward slashes.
The two native-build command fences had no language tag (MD040). Tag them sh.
The "needs the yt-dlp package" error and log strings lived in the core player,
which has no dependency on any resolver — naming one implementation there
contradicted the agnostic registry. Reword the LastErrorMessage and log warning
to refer to a media URL resolver package generally, and frame the yt-dlp resolver
in the README as the one Basis ships rather than the only option.
The router called external IBasisVideoResolver code (CanResolve/TryResolve)
directly, so one throwing resolver unwound the whole load and starved every
lower-priority resolver and the direct-load fallback. Wrap each resolver
attempt, log via BasisDebug, and continue routing.

A resolver that takes ownership resolves asynchronously, so the player could
not observe a failure: an unsupported or failed page URL left it silently at
NoMedia. Add BasisMediaPlayer.ReportLoadError so a resolver can surface the
reason (LastErrorMessage + OnError); the yt-dlp installer passes it, gated on
LoadGeneration so a stale resolve can't clobber a newer load. Player failure
paths (including the deferred start-position seek) report through one helper
so LastErrorMessage always tracks the latest error.

Redact URLs before logging (BasisMediaUrlRouter.Redact drops the query and
fragment and strips userinfo) on the no-resolver, router-exception and
resolve-failure paths, and log the failure's exception type rather than its
message, since page URLs and yt-dlp/extractor messages can carry signed
tokens or private identifiers.

Restrict NormalizeUrl's Windows-drive shortcut to a letter-prefixed scheme so
an IPv6 literal ("[::1]/stream") is not mistaken for a local path. Map yt-dlp
live_status "post_live" (an ended broadcast still processing into a VOD) to
the OnDemand clock, alongside "was_live".

README: the no-resolver page-URL warning is logged on each load (not once),
and document the LoadGeneration stale-load guard for async resolvers.
The first-load note read as "first call slow, later calls fast", but the
one-time win is only the Python-runtime extraction — every page-URL load still
spends a few seconds resolving in-process (network round-trips plus YouTube's
JS signature challenge). Spell that out, and note nothing is surfaced on the
player during the gap so a consuming UI should show its own loading state.
@towneh towneh force-pushed the feat/mediaplayer-resolver-registry branch from ae046bc to d35c5c6 Compare June 28, 2026 11:40
@towneh

towneh commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on top of the dx12 video texture fix

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant