mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): audition through a mute, and stop the audition leaking into the saved chain
Two reports, one shelf: Muted targets now audition. Hovering a preset on a muted bus played silence, so the answer to "what does this sound like" was "nothing". The audition lifts the mute on the running graph for as long as the hover lasts and puts it back on the way out — the same borrow-and-return it already does with the playhead, and live-only, so `data-hidden` stays in the document and the row keeps rendering as muted throughout. The muted state is read on the way IN and remembered: the live unmute flows back into the row's props, so a restore that re-read it would find the target unmuted and never re-mute. Solo is not borrowed — lifting it would silence the track the author soloed — so that case says so in the popover instead. Applying a preset no longer saves the one you were hovering. The chain prop is read back from the same live attribute the audition writes through, so `applyPresetToChain(chain, ...)` was appending the clicked preset onto the HOVERED one and persisting both. Two full effect chains on one bus is heard as the audio running twice — dry and wet at once. Apply now lands on `storedChain()`, the chain as the document has it. Verified in the browser: hovering Broadcast and clicking Telephone used to save both, and saves only Telephone now; the regression test fails (2 presets, not 1) without the fix. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD is clean.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import React, { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
@@ -68,6 +68,82 @@ describe("TimelineFxButton", () => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// A muted target auditions anyway — the mute is lifted on the running graph
|
||||
// for the hover and put back on the way out. The read has to happen on the
|
||||
// way IN: the live unmute flows back into this component's props, so a
|
||||
// restore that re-read `isMuted` would find it false and never re-mute.
|
||||
it("borrows a muted target's mute for the audition and returns it", () => {
|
||||
const onSetMutedLive = vi.fn();
|
||||
const host = mount(
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={undefined}
|
||||
isMuted
|
||||
onSetMutedLive={onSetMutedLive}
|
||||
onChainChange={vi.fn()}
|
||||
onChainPreview={vi.fn()}
|
||||
onOpenRack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => byTextButton(host, "FX")?.click());
|
||||
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
||||
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
||||
expect(onSetMutedLive).toHaveBeenLastCalledWith(false);
|
||||
const shelf = document.querySelector(".hf-fx-preset-menu");
|
||||
act(() => shelf?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })));
|
||||
expect(onSetMutedLive).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it("leaves an unmuted target's mute alone", () => {
|
||||
const onSetMutedLive = vi.fn();
|
||||
const host = mount(
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={undefined}
|
||||
onSetMutedLive={onSetMutedLive}
|
||||
onChainChange={vi.fn()}
|
||||
onChainPreview={vi.fn()}
|
||||
onOpenRack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => byTextButton(host, "FX")?.click());
|
||||
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
||||
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
||||
expect(onSetMutedLive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Hovering writes the preset through the preview channel, and the chain prop
|
||||
// is read back from that same live attribute. Applying while a DIFFERENT
|
||||
// preset is being auditioned used to save both — heard as the effect running
|
||||
// twice — so the apply has to land on the stored chain.
|
||||
it("applies onto the stored chain, not the one being auditioned", () => {
|
||||
const onChainChange = vi.fn();
|
||||
// The write-back the real timeline does: a preview patches the live
|
||||
// attribute, and the row re-reads it into `fxChainRaw`. Without this the
|
||||
// prop never moves and the bug cannot show.
|
||||
function Harness() {
|
||||
const [raw, setRaw] = React.useState<string | undefined>(undefined);
|
||||
return (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={raw}
|
||||
onChainChange={onChainChange}
|
||||
onChainPreview={(next) => setRaw(serializeAudioFxChain(next))}
|
||||
onOpenRack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const host = mount(<Harness />);
|
||||
act(() => byTextButton(host, "FX")?.click());
|
||||
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".hf-fx-preset-item"));
|
||||
const [hovered, clicked] = [items[0], items[1]];
|
||||
act(() => hovered?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
||||
act(() => clicked?.click());
|
||||
const saved = onChainChange.mock.calls.at(-1)?.[0];
|
||||
const presets = new Set(saved?.nodes.map((n: { fromPreset?: string }) => n.fromPreset));
|
||||
expect(presets.size).toBe(1);
|
||||
});
|
||||
|
||||
it("group-pointer variant offers Group instead of a popover", () => {
|
||||
const onGroupClips = vi.fn();
|
||||
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
|
||||
|
||||
Reference in New Issue
Block a user