Skip to content

Commit acc6898

Browse files
miguel-heygenmiga-heygen
authored andcommitted
fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps
1 parent cce17da commit acc6898

6 files changed

Lines changed: 168 additions & 10 deletions

File tree

packages/core/package-subpaths.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,12 @@
236236
"types": "./dist/runtime/stackingContext.d.ts",
237237
"environments": ["browser", "bun", "node"]
238238
},
239+
"./runtime/web-audio-route": {
240+
"source": "./src/runtime/webAudioRoute.ts",
241+
"runtime": "./dist/runtime/webAudioRoute.js",
242+
"types": "./dist/runtime/webAudioRoute.d.ts",
243+
"environments": ["browser", "bun", "node"]
244+
},
239245
"./compiler/html-document": {
240246
"source": "./src/compiler/htmlDocument.ts",
241247
"runtime": "./dist/compiler/htmlDocument.js",

packages/core/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@
245245
"import": "./src/runtime/stackingContext.ts",
246246
"types": "./src/runtime/stackingContext.ts"
247247
},
248+
"./runtime/web-audio-route": {
249+
"bun": "./src/runtime/webAudioRoute.ts",
250+
"node": "./dist/runtime/webAudioRoute.js",
251+
"import": "./src/runtime/webAudioRoute.ts",
252+
"types": "./src/runtime/webAudioRoute.ts"
253+
},
248254
"./compiler/html-document": {
249255
"bun": "./src/compiler/htmlDocument.ts",
250256
"node": "./dist/compiler/htmlDocument.js",
@@ -539,6 +545,10 @@
539545
"import": "./dist/runtime/stackingContext.js",
540546
"types": "./dist/runtime/stackingContext.d.ts"
541547
},
548+
"./runtime/web-audio-route": {
549+
"import": "./dist/runtime/webAudioRoute.js",
550+
"types": "./dist/runtime/webAudioRoute.d.ts"
551+
},
542552
"./compiler/html-document": {
543553
"import": "./dist/compiler/htmlDocument.js",
544554
"types": "./dist/compiler/htmlDocument.d.ts"

packages/core/src/runtime/init.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ import { applyVariableBindings } from "./applyVariableBindings";
4242
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
4343
import { TransportClock } from "./clock";
4444
import { WebAudioTransport } from "./webAudioTransport";
45-
import { classifyWebAudioMediaRoute, reportWebAudioMediaRoute } from "./webAudioRoute.js";
45+
import {
46+
classifyWebAudioMediaRoute,
47+
isRouteSelectionSettled,
48+
reportWebAudioMediaRoute,
49+
} from "./webAudioRoute.js";
4650
import {
4751
ensureAudioGroupInertStyle,
4852
HF_AUDIO_GROUP_TAG,
@@ -1840,6 +1844,13 @@ export function initSandboxRuntimeModular(): void {
18401844
// were.
18411845
const reportWebAudioRoute = (mediaEl: HTMLMediaElement) => {
18421846
if (!(mediaEl instanceof HTMLAudioElement)) return;
1847+
// Before resource selection settles, the verdict is built from `<source>`
1848+
// children the browser might still pass over — good enough for the
1849+
// schedule path's conservative withhold, not good enough to put in front
1850+
// of a human as a diagnostic. Skip; the `loadedmetadata` call to this same
1851+
// function (see below) always has a settled `currentSrc` and will report
1852+
// for real once the guess would no longer be one.
1853+
if (!isRouteSelectionSettled(mediaEl)) return;
18431854
reportWebAudioMediaRoute(mediaEl, classifyWebAudioMediaRoute(mediaEl));
18441855
};
18451856

@@ -1875,10 +1886,15 @@ export function initSandboxRuntimeModular(): void {
18751886
// schedule time. `hyperframes check` seeks, it never calls play(), so a
18761887
// diagnostic raised from the transport would be invisible to the one
18771888
// gate whose job is to surface exactly this class of silent failure.
1878-
// Bound twice on purpose: now, so a composition that never plays still
1879-
// reports, and again at `loadedmetadata`, when `currentSrc` is finally
1880-
// authoritative and the early read may have been guessing from
1881-
// `<source>` children. `reportWebAudioMediaRoute` latches per element.
1889+
// Bound twice on purpose: now, for a `src`/committed-`currentSrc`
1890+
// element so a composition that never plays still reports promptly, and
1891+
// again at `loadedmetadata`, when `currentSrc` is unconditionally
1892+
// authoritative. `reportWebAudioRoute` itself skips the "now" call when
1893+
// selection hasn't settled (see `isRouteSelectionSettled`) — with only
1894+
// `<source>` children to go on, the browser could still pick a
1895+
// different one than the classifier just judged, and a diagnostic is a
1896+
// claim of fact, not a guess. `reportWebAudioMediaRoute` latches per
1897+
// element, so the deferred-to-`loadedmetadata` case still reports once.
18821898
mediaEl.addEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
18831899
reportWebAudioRoute(mediaEl);
18841900
// Reactive (zero-videoWidth) + tertiary (error event) proxy-fallback

packages/core/src/runtime/webAudioRoute.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
classifyWebAudioMediaRoute,
4+
isRouteSelectionSettled,
45
nativeUnexpressibleProcessing,
56
reportWebAudioMediaRoute,
67
} from "./webAudioRoute";
@@ -21,6 +22,18 @@ function withCurrentSrc(el: HTMLAudioElement, currentSrc: string): HTMLAudioElem
2122
return el;
2223
}
2324

25+
/**
26+
* Shadows the IDL `crossOrigin` accessor with a plain data property, so the
27+
* value is visible ONLY via the property — unlike `el.crossOrigin = value`
28+
* (which jsdom, like real browsers, reflects straight back to the
29+
* `crossorigin` attribute), this simulates a host whose IDL property is
30+
* genuinely decoupled from the attribute, per `hasCorsOptIn`'s secondary read.
31+
*/
32+
function withUnreflectedCrossOrigin(el: HTMLAudioElement, value: string): HTMLAudioElement {
33+
Object.defineProperty(el, "crossOrigin", { value, configurable: true });
34+
return el;
35+
}
36+
2437
describe("classifyWebAudioMediaRoute", () => {
2538
it("routes same-origin media through Web Audio", () => {
2639
expect(classifyWebAudioMediaRoute(audio({ src: "/assets/vo.mp3" }))).toEqual({
@@ -97,6 +110,53 @@ describe("classifyWebAudioMediaRoute", () => {
97110
it("routes an element with no resolvable source through Web Audio", () => {
98111
expect(classifyWebAudioMediaRoute(audio())).toEqual({ kind: "web-audio" });
99112
});
113+
114+
it("treats an unreflected empty-string crossOrigin IDL property as opt-in", () => {
115+
// The IDL fallback for `crossorigin=""` / bare `crossorigin` is the empty
116+
// string. A host whose property setter doesn't reflect to the attribute
117+
// (unlike jsdom's own accessor, which does) must not have that empty
118+
// string misread as "no opt-in" — `Boolean("")` is false, which is
119+
// exactly the fail-open this test guards against.
120+
const el = withUnreflectedCrossOrigin(audio({ src: `${CROSS_ORIGIN}/track.mp3` }), "");
121+
expect(el.getAttribute("crossorigin")).toBeNull(); // confirms it's genuinely unreflected
122+
123+
expect(classifyWebAudioMediaRoute(el)).toEqual({ kind: "web-audio" });
124+
});
125+
126+
it("does not treat an untouched crossOrigin IDL property as opt-in", () => {
127+
// The other direction of the same risk: a host must not default
128+
// `crossOrigin` to a truthy/string value for elements that never opted
129+
// in, or the cross-origin check would be permanently disabled.
130+
const el = audio({ src: `${CROSS_ORIGIN}/track.mp3` });
131+
expect(el.crossOrigin).toBeNull();
132+
133+
expect(classifyWebAudioMediaRoute(el)).toEqual({
134+
kind: "decode-only",
135+
reason: "cross_origin_no_cors",
136+
asset: `${CROSS_ORIGIN}/track.mp3`,
137+
});
138+
});
139+
});
140+
141+
describe("isRouteSelectionSettled", () => {
142+
it("is unsettled for an element with only <source> children", () => {
143+
const el = audio();
144+
el.innerHTML = `<source src="/assets/first.mp3">`;
145+
expect(isRouteSelectionSettled(el)).toBe(false);
146+
});
147+
148+
it("is settled once a src attribute is definitive, even before load", () => {
149+
expect(isRouteSelectionSettled(audio({ src: "/assets/vo.mp3" }))).toBe(true);
150+
});
151+
152+
it("is settled once currentSrc has committed", () => {
153+
const el = withCurrentSrc(audio(), `${SAME_ORIGIN}/assets/vo.mp3`);
154+
expect(isRouteSelectionSettled(el)).toBe(true);
155+
});
156+
157+
it("is unsettled for an element with no source at all", () => {
158+
expect(isRouteSelectionSettled(audio())).toBe(false);
159+
});
100160
});
101161

102162
describe("nativeUnexpressibleProcessing", () => {

packages/core/src/runtime/webAudioRoute.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
2538
export 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
*/
5480
function 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

6085
function 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
*/
165210
function 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,

packages/studio/src/components/sidebar/AudioRow.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useRef, useEffect, useCallback } from "react";
2+
import { classifyWebAudioMediaRoute } from "@hyperframes/core/runtime/web-audio-route";
23
import { ContextMenu } from "./AssetContextMenu";
34
import { basename, getAudioSubtype, type CopyFeedback } from "./assetHelpers";
45
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
@@ -167,14 +168,24 @@ export function AudioRow({
167168
setPlaying(false);
168169
cancelAnimationFrame(animRef.current);
169170
};
171+
// `src` must be set BEFORE classifying: the check reads `currentSrc`/
172+
// `src`, and a same-origin `serveUrl` mustn't be judged from a blank
173+
// element.
174+
el.src = serveUrl;
170175
audioRef.current = el;
171176
const analyser = analyserRef.current;
172-
if (analyser) {
177+
// Same hazard `webAudioRoute.ts` documents for the timeline runtime
178+
// (#3458): `createMediaElementSource` on a cross-origin element without
179+
// a `crossorigin` opt-in permanently reroutes it to a node that outputs
180+
// SILENCE per the Web Audio spec, without throwing. Classify first so
181+
// this preview player can't reintroduce that bug — skipping the Web
182+
// Audio graph here only costs the frequency-bar visualizer; native
183+
// `<audio>` playback below stays audible either way.
184+
if (analyser && classifyWebAudioMediaRoute(el).kind === "web-audio") {
173185
sourceRef.current = actxRef.current.createMediaElementSource(el);
174186
sourceRef.current.connect(analyser);
175187
analyser.connect(actxRef.current.destination);
176188
}
177-
el.src = serveUrl;
178189
}
179190

180191
if (actxRef.current.state === "suspended") await actxRef.current.resume();

0 commit comments

Comments
 (0)