feat(mediaplayer): video resolver registry + sanitized URLs + https URL normalisation #889
Open
towneh wants to merge 13 commits into
Open
feat(mediaplayer): video resolver registry + sanitized URLs + https URL normalisation #889towneh wants to merge 13 commits into
towneh wants to merge 13 commits into
Conversation
612d55c to
9f16c30
Compare
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.
ae046bc to
d35c5c6
Compare
Collaborator
Author
|
Rebased on top of the dx12 video texture fix |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
www.youtube.com/watch?v=…) getshttps://prepended, and a protocol-relative//host/…getshttps:, so each is treated as an absolute 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
developertoday. 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.
TransformAccessArrayor are otherwise batched. I have not added per-frametransform.position/transform.rotation/transform.localPositioncalls inside loops. Whenever I need both position and rotation, I use the combined APIs —SetPositionAndRotation/SetLocalPositionAndRotationfor writes,GetPositionAndRotation/GetLocalPositionAndRotationfor reads — instead of two separate property accesses; the combined call does one local-to-world matrix traversal instead of two.Resources.Load, no direct asset references that pull large content into memory on scene load.GetComponent/AddComponentwhere avoidable — Where unavoidable, the result is cached on a field, and anyGetComponent<T>is replaced withTryGetComponent<T>(out var x)— bareGetComponentwill be denied.TryGetComponentis the modern API (Unity 2019.2+) and skips the Editor-only GC allocationGetComponentcauses when a component is missing: Unity wraps thenullreturn in a managed "fake null" object so its overloaded==operator can still detect destroyed C++ objects, and constructing that wrapper allocates;TryGetComponentreturns aboolplusoutparameter and never builds the wrapper. None of these calls run insideUpdate,LateUpdate,FixedUpdate, jobs, or other per-frame code paths.BasisEventDriver— Any new per-frame work hooks intoBasisEventDriverrather than adding standaloneUpdate/LateUpdate/FixedUpdatecallbacks on a MonoBehaviour.BasisEventDriveris bulletproof, or guarded bytry/catch—BasisEventDriverruns 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 atry/catchthat contains the failure and surfaces it throughBasisDebug— logged once / rate-limited, never every frame (see the existingHVRBasisBuiltInAddresses.Simulate()guard for the pattern). Expect this to be scrutinized closely in review.{ get; set; }properties or access lockdowns — Public fields are fine; Basis is meant to be read and modified freely, so don't wall things offprivate/internalwithout 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.Instancesingletons, callers reassigningType.Instanceis allowed; if that would break your code, log a warning or throw — don't block the assignment. Locking down access is not your call.BasisLocalCameraDriver— Code that needs the local camera (transform, projection, rig data, etc.) pulls it fromBasisLocalCameraDriverrather than looking one up itself. Don't roll a separate camera discovery path.BasisDebug— All new logging calls go throughBasisDebug.Log/BasisDebug.LogWarning/BasisDebug.LogError(with an appropriateLogTag) instead ofUnityEngine.Debug.Log/Debug.LogWarning/Debug.LogError.BasisDebugroutes through Basis's tagged, color-coded logger and respects the project-wideLoggingDisabledtoggle so logging can be killed at runtime; bareDebug.Logcalls bypass that and will be denied.FindObjectOfType/FindObjectsOfType/GameObject.Find/FindGameObjectsWithTagto 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.newon reference types, no LINQ, nostringconcatenation/interpolation, no boxing, noforeachover interface-typed collections. Allocate once at init and reuse the buffer.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_EDITORand remove (or leave gated) before merge..Count(lists) /.Length(arrays) into a localintbefore the loop instead of re-reading the property each iteration. PreferT[](with a separate length int when the array is over-sized) overList<T>where the data is hot — Unity's mono BCL doesn't exposeCollectionsMarshal.AsSpan(List<T>), so a list can't be fed intoSpan<T>/ unsafe paths cleanly. Where the perf justifies it, drop intoSpan<T>/reflocals /Unsafe.As/unsafepointer 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.
Input / control mode coverage:
Where applicable, confirm these flows still work after your changes:
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
BasisMediaUrlRouterregistry — multipleIBasisVideoResolvers can register; the router tries them in descendingPriority(registration order breaks ties) until one takes ownership of a load, otherwise the URL loads directly. The router routes only; host trust stays withBasisMediaPlayerSecurity. Authoring a resolver is documented in the player README.NormalizeUrlprependshttps://to a bare web URL andhttps: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) orfile://— documented onNormalizeUrl.LoadUrl,LoadLocalPath, and the Media Players panel's load button now guard withIsNullOrWhiteSpace, so a blank-but-non-empty string no longer slips past the empty check and through normalisation as a bogus URL.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.
LoadUrlsees it isn't directly playable and stops before attaching a backend, leaving a plain-English reason:That surfaces two ways: the player's
StatusbecomesErrorwith the text onLastErrorMessage, which the in-game Media Players panel displays, and the same is logged on each such load as aBasisDebugwarning (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
Resolverslot is gone; integrations register throughRegister(IBasisVideoResolver)only, and the in-tree yt-dlp resolver uses it directly.com.basis.integration.ytdlp).Checklist context
Everything else is ticked as N/A or compliant — this is URL-routing / string-handling only:
GetComponent/AddComponent, camera lookups, or per-frame work, so the hot-path /BasisEventDriver/ jobification / collection-access checks don't apply.BasisDebugwithLogTag.Video.IBasisVideoResolverinterface members (Priority+ the resolve methods) — interface contract, not noop accessors walling off a field.RuntimeInitializeOnLoadMethod, not scene scanning.