diff --git a/packages/core/src/editing/affordances.test.ts b/packages/core/src/editing/affordances.test.ts index b975dd64bf..475e89d07a 100644 --- a/packages/core/src/editing/affordances.test.ts +++ b/packages/core/src/editing/affordances.test.ts @@ -156,6 +156,17 @@ describe("resolveEditingAffordances — sections", () => { } }); + it("audio: vstFx applies", () => { + const s = resolveEditingAffordances(baseFacts({ tag: "audio" })).sections; + expect(s.vstFx).toBe(true); + }); + + it("vstFx does not apply to video, img, or plain elements", () => { + expect(resolveEditingAffordances(baseFacts({ tag: "video" })).sections.vstFx).toBe(false); + expect(resolveEditingAffordances(baseFacts({ tag: "img" })).sections.vstFx).toBe(false); + expect(resolveEditingAffordances(baseFacts({ tag: "div" })).sections.vstFx).toBe(false); + }); + it("img: media + colorGrading", () => { const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections; expect(s).toMatchObject({ media: true, colorGrading: true }); diff --git a/packages/core/src/editing/affordances.ts b/packages/core/src/editing/affordances.ts index 272fb028fb..eaf12e5a10 100644 --- a/packages/core/src/editing/affordances.ts +++ b/packages/core/src/editing/affordances.ts @@ -38,6 +38,8 @@ export interface EditingSectionApplicability { * as `layout`, kept separate since a future tag could need one without * the other. */ style: boolean; + /** VST FX chain editing — audio elements only (narrower than `media`, which also covers video/img). */ + vstFx: boolean; } export interface EditingAffordances { @@ -212,6 +214,7 @@ export function resolveEditingSections(facts: EditableElementFacts): EditingSect animation: facts.animationCount > 0, layout: hasVisualBox, style: hasVisualBox, + vstFx: facts.tag === "audio", }; } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..dfbfae12e9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -175,6 +175,8 @@ export function initSandboxRuntimeModular(): void { void webAudio.init().then((ok) => { webAudioReady = ok; }); + // TEMP DEBUG — remove after diagnosis + (window as unknown as { __hfWebAudioDbg?: unknown }).__hfWebAudioDbg = webAudio; // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the // parser reads back), NOT an animatable property. Register it as a no-op GSAP // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — @@ -3028,7 +3030,15 @@ export function initSandboxRuntimeModular(): void { const scheduleWebAudioForActiveClips = () => { if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return; const gen = webAudio.startGeneration(); - const audioEls = document.querySelectorAll("audio[data-start]"); + // `data-vst-chain` elements are claimed by the VST preview hook + // (packages/studio/src/player/hooks/useVstPreview.ts) — it routes their + // audio through its own AudioContext/AudioWorklet fed by the sidecar and + // permanently mutes the element. Scheduling one here too would double- + // claim it: this transport's own mute/restore lifecycle (see + // webAudioTransport.ts's `schedulePlayback`/`stopAll`) would unmute it + // again once its native source ends, silently swapping the processed + // stream back for the untreated one. + const audioEls = document.querySelectorAll("audio[data-start]:not([data-vst-chain])"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); @@ -3135,6 +3145,11 @@ export function initSandboxRuntimeModular(): void { const mediaEls = document.querySelectorAll("video, audio"); for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; + // A VST-chain element is permanently muted by the VST preview hook — + // its actual audio plays through a separate AudioWorklet fed by the + // sidecar, not this element. Overwriting `.muted` here would + // silently swap the processed stream back for the untreated one. + if (el.hasAttribute("data-vst-chain")) continue; el.muted = effective || el.defaultMuted; } }, @@ -3144,6 +3159,7 @@ export function initSandboxRuntimeModular(): void { const mediaEls = document.querySelectorAll("video, audio"); for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; + if (el.hasAttribute("data-vst-chain")) continue; const parsed = parseFloat(el.dataset.volume ?? ""); const clipVolume = Number.isFinite(parsed) ? parsed : 1; el.volume = clipVolume * volume; @@ -3156,6 +3172,7 @@ export function initSandboxRuntimeModular(): void { const mediaEls = document.querySelectorAll("video, audio"); for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; + if (el.hasAttribute("data-vst-chain")) continue; el.muted = effective || el.defaultMuted; } }, diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index f065f09786..f1a6b60626 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -26,6 +26,13 @@ function createForeignFrameMediaDocument(): { constructor(tagName: string) { this.tagName = tagName; } + + // Real DOM elements always have this — matches the actual foreign-realm + // elements this class simulates. No attributes are ever set on these + // mocks, so it's always false. + hasAttribute(): boolean { + return false; + } } class FrameMedia extends FrameElement { @@ -268,6 +275,36 @@ describe("HyperframesPlayer parent-frame media", () => { return video; } + it("does not mute a data-vst-chain iframe element when syncing the muted attribute", () => { + // A VST-chain element is permanently muted by the studio's VST preview + // hook (packages/studio/src/player/hooks/useVstPreview.ts) — its audio + // plays through a separate AudioWorklet fed by the sidecar, not this + // element. `_setIframeMediaMuted` (triggered here via the public `muted` + // attribute) must skip it, or every mute/unmute sync silently swaps the + // processed stream back for the untreated one. + player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); + document.body.appendChild(player); + + const iframe = player.shadowRoot?.querySelector("iframe"); + if (!(iframe instanceof HTMLIFrameElement)) throw new Error("expected player iframe"); + const iframeDoc = iframe.contentDocument; + if (!iframeDoc) throw new Error("expected player iframe document"); + + const vstAudio = iframeDoc.createElement("audio"); + vstAudio.setAttribute("data-vst-chain", "fx/music.vstchain.json"); + vstAudio.muted = false; + iframeDoc.body.appendChild(vstAudio); + + const plainVideo = iframeDoc.createElement("video"); + plainVideo.muted = false; + iframeDoc.body.appendChild(plainVideo); + + player.setAttribute("muted", ""); + + expect(vstAudio.muted).toBe(false); + expect(plainVideo.muted).toBe(true); + }); + it("does not mute iframe media on autoplay fallback inside presenter slideshow", () => { const slideshow = document.createElement("hyperframes-slideshow"); slideshow.appendChild(player); @@ -716,13 +753,15 @@ describe("HyperframesPlayer media MutationObserver scoping", () => { // Subtree is still required — sub-composition media can be deeply nested // inside the host (e.g. wrapper div around the `