Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/core/src/editing/affordances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/editing/affordances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -212,6 +214,7 @@ export function resolveEditingSections(facts: EditableElementFacts): EditingSect
animation: facts.animationCount > 0,
layout: hasVisualBox,
style: hasVisualBox,
vstFx: facts.tag === "audio",
};
}

Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -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;
}
},
Expand All @@ -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;
Expand All @@ -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;
}
},
Expand Down
43 changes: 41 additions & 2 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 `<audio>`).
// Attribute observation on "preload" is required so the player creates
// parent proxies just-in-time when the preloader promotes a clip.
// parent proxies just-in-time when the preloader promotes a clip;
// "data-vst-chain" so it drops/restores the dry proxy when a track's VST
// ownership flips (see parent-media's _adoptIframeMedia).
for (const call of observeSpy.mock.calls) {
expect(call[1]).toEqual({
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
attributeFilter: ["preload", "data-vst-chain"],
});
}
});
Expand Down
10 changes: 9 additions & 1 deletion packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,15 @@ class HyperframesPlayer extends HTMLElement {
const iframeDoc = this._getSameOriginIframeDocument();
if (!iframeDoc) return;
for (const el of iframeDoc.querySelectorAll("video, audio")) {
if (isRealmHtmlMediaElement(el)) el.muted = muted || el.defaultMuted;
if (!isRealmHtmlMediaElement(el)) continue;
// A VST-chain element is permanently muted by the studio's VST preview
// hook — its audio plays through a separate AudioWorklet fed by the
// sidecar, not this element (see packages/core/src/runtime/init.ts's
// onSetMuted, which carries the same exclusion for the in-iframe
// runtime's own mute-sync path). Overwriting `.muted` here would
// silently swap the processed stream back for the untreated one.
if (el.hasAttribute("data-vst-chain")) continue;
el.muted = muted || el.defaultMuted;
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/player/src/parent-media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,30 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
expect(mgr.entries[0]).toBe(adopted);
});
});

describe("ParentMediaManager VST-chain tracks", () => {
it("does not create a dry parent proxy for a data-vst-chain element", () => {
const mgr = makeManager();
const plain = document.createElement("audio");
plain.setAttribute("data-start", "0");
plain.setAttribute("src", "https://example.test/plain.mp3");
const vst = document.createElement("audio");
vst.setAttribute("data-start", "0");
vst.setAttribute("data-vst-chain", "fx/x.vstchain.json");
vst.setAttribute("src", "https://example.test/vst-dry.mp3");
document.body.append(plain, vst);

// setupFromIframe adopts every audio[data-start] EXCEPT the VST-chained
// one — its audio is produced by the VST pipeline's AudioContext, so a dry
// proxy here would play the unprocessed file over the effect ("all dry").
mgr.setupFromIframe(document);

const srcs = mgr.entries.map((e) => e.el.src);
expect(srcs.some((s) => s.includes("plain.mp3"))).toBe(true);
expect(srcs.some((s) => s.includes("vst-dry.mp3"))).toBe(false);

mgr.teardownObserver();
plain.remove();
vst.remove();
});
});
24 changes: 23 additions & 1 deletion packages/player/src/parent-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,13 @@ export class ParentMediaManager {

// fallow-ignore-next-line complexity
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
// A VST-chain track's audio is produced entirely by the studio's VST
// pipeline (useVstPreview) through its own AudioContext — the wet,
// processed stream. A dry parent proxy here would play the UNPROCESSED
// file on top of it, so the effect sounds absent ("all dry"). Leave this
// element's audio to the VST pipeline; the observer re-adopts it if the
// chain is later removed.
if (iframeEl.hasAttribute("data-vst-chain")) return;
// Skip elements the preloader has demoted — the observer will re-trigger
// when the preload attribute is promoted to "auto".
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
Expand Down Expand Up @@ -428,6 +435,21 @@ export class ParentMediaManager {
continue;
}

// A track becoming (or ceasing to be) VST-chained flips who owns its
// audio: adding `data-vst-chain` hands playback to the VST pipeline, so
// drop the dry parent proxy; removing it hands playback back here.
if (m.type === "attributes" && m.attributeName === "data-vst-chain") {
const target = m.target;
if (
isRealmHtmlMediaElement(target) &&
target.matches("audio[data-start], video[data-start]")
) {
if (target.hasAttribute("data-vst-chain")) this._detachIframeMedia(target);
else this._adoptIframeMedia(target);
}
continue;
}

for (const added of m.addedNodes) {
if (!isRealmElement(added)) continue;
const candidates: HTMLMediaElement[] = [];
Expand Down Expand Up @@ -466,7 +488,7 @@ export class ParentMediaManager {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
attributeFilter: ["preload", "data-vst-chain"],
};

const targets = selectMediaObserverTargets(doc);
Expand Down
10 changes: 10 additions & 0 deletions packages/studio/src/components/editor/manualEditingAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,14 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
true,
);

// VST FX chain editing (the property panel's "Make room for voiceover" carve
// control + per-plugin chain UI) and its live-preview PCM streaming through
// the VST host sidecar — new, unreleased surface, off by default pending
// broader rollout. Default false; enable via VITE_STUDIO_ENABLE_VST=true.
export const STUDIO_VST_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_VST", "VITE_STUDIO_VST_ENABLED"],
false,
);

import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
Loading
Loading