@@ -21,6 +21,19 @@ import type { RuntimeJson } from "./types";
2121 * a pure classifier, so the same verdict can be reached at media-discovery time
2222 * (to emit a diagnostic) and at schedule time (to actually withhold the node)
2323 * without those two ever drifting apart.
24+ *
25+ * That guarantee holds for every caller that routes through this classifier —
26+ * it is NOT a runtime-wide interception of `createMediaElementSource`. The
27+ * timeline transport (`webAudioTransport.ts`, via `init.ts`) always goes
28+ * through it; a UI surface that builds its own throwaway `AudioContext` for
29+ * an unrelated purpose (e.g. the asset sidebar's preview player,
30+ * `AudioRow.tsx`) has to call it too, and is expected to. Known gap: an
31+ * element playing a `MediaStream` via `srcObject` instead of `src`/`<source>`
32+ * has no origin for this module to judge — `routeCandidates` only reads
33+ * `src`-shaped attributes, so a `srcObject` element always reads as
34+ * `web-audio` here, correctly or not. Nothing in this codebase feeds
35+ * `createMediaElementSource` from a `srcObject` element today, so this is
36+ * recorded as a boundary rather than fixed.
2437 */
2538export type WebAudioMediaRoute =
2639 /** Same-origin, CORS-opted-in, or a scheme the check doesn't apply to. */
@@ -50,11 +63,23 @@ function hasAttr(el: HTMLMediaElement, name: string): boolean {
5063 * `anonymous`, so PRESENCE is the opt-in — `crossorigin=""` and even
5164 * `crossorigin="garbage"` both make the fetch a CORS request. Comparing the
5265 * value against `"anonymous"` would wrongly block those.
66+ *
67+ * Two independent reads, because a spec-faithful host and a permissive one
68+ * disagree about where the truth lives:
69+ * - `getAttribute` is the primary read and covers every real browser: the
70+ * markup is unambiguous regardless of what the IDL getter does with it.
71+ * - `el.crossOrigin` is a secondary read for a host that sets the IDL
72+ * property without reflecting it back to the attribute — some
73+ * jsdom-style test/preview hosts do this. The check is `!= null`
74+ * (covers both `null` and `undefined`), not a truthiness check, ON
75+ * PURPOSE: `crossorigin=""` is a valid, common opt-in (see above), and
76+ * its IDL fallback value is the empty string — a falsy value that
77+ * `Boolean(el.crossOrigin)` would silently misread as "not opted in",
78+ * reintroducing the exact silent-audio bug this module exists to close.
5379 */
5480function hasCorsOptIn ( el : HTMLMediaElement ) : boolean {
5581 if ( hasAttr ( el , "crossorigin" ) ) return true ;
56- // Secondary read for a host that set the IDL property without reflecting it.
57- return typeof el . crossOrigin === "string" ;
82+ return el . crossOrigin != null ;
5883}
5984
6085function baseUri ( el : HTMLMediaElement ) : string {
@@ -108,6 +133,26 @@ function isCorsSilenced(rawUrl: string, el: HTMLMediaElement): boolean {
108133 return ! hasCorsOptIn ( el ) ;
109134}
110135
136+ /**
137+ * Whether resource selection has settled enough for a verdict to be a FACT
138+ * rather than a guess. `currentSrc`/`src` are both definitive per the HTML
139+ * resource-selection algorithm (see `routeCandidates` above); before either
140+ * is set, a verdict can only be built from `<source>` children, any of which
141+ * the browser may still pass over before committing.
142+ *
143+ * `classifyWebAudioMediaRoute` itself stays unsettled-tolerant on purpose —
144+ * the schedule path needs *a* verdict even before selection settles, and
145+ * conservatively withholding the node there costs nothing but a decode-only
146+ * fallback. This predicate exists for the one caller that must NOT act on a
147+ * guess: the discovery-time diagnostic, which drops a message in a human's
148+ * lap and only gets to say it once (see `reportWebAudioMediaRoute`'s latch).
149+ */
150+ export function isRouteSelectionSettled ( el : HTMLMediaElement ) : boolean {
151+ const current = typeof el . currentSrc === "string" ? el . currentSrc : "" ;
152+ if ( current ) return true ;
153+ return hasAttr ( el , "src" ) ;
154+ }
155+
111156/**
112157 * Pure — no node creation, no diagnostics, no element mutation. Called from
113158 * both the schedule path (where it withholds the node) and the discovery path
@@ -163,7 +208,17 @@ export function nativeUnexpressibleProcessing(el: HTMLMediaElement): string[] {
163208 * only `<audio>` ever reaches this module.
164209 */
165210function isRenderMode ( ) : boolean {
166- return typeof window !== "undefined" && ! ! window . __HF_EXPORT_RENDER_SEEK_CONFIG ;
211+ // Read through an inline cast rather than the ambient `Window` augmentation
212+ // in `window.d.ts`: that augmentation is only in scope for programs that
213+ // include it (core's own tsconfig does), and this module is also exported
214+ // as `./runtime/web-audio-route` for non-runtime consumers (e.g. the studio
215+ // asset sidebar's preview player, `AudioRow.tsx`) whose tsconfig doesn't
216+ // pull it in. This is a plain existence check, so the cast costs nothing.
217+ return (
218+ typeof window !== "undefined" &&
219+ ! ! ( window as unknown as { __HF_EXPORT_RENDER_SEEK_CONFIG ?: unknown } )
220+ . __HF_EXPORT_RENDER_SEEK_CONFIG
221+ ) ;
167222}
168223
169224// One diagnostic per element. Latched only when something is actually emitted,
0 commit comments