fix(studio): reconnect property-panel audio controls (#3453)

* fix(studio): reconnect property-panel audio controls

* fix(studio): unify property panel audio detection

* fix(studio): satisfy panel and deletion gates
This commit is contained in:
Vance Ingalls
2026-08-23 19:04:22 -07:00
committed by GitHub
parent f575bdadcb
commit 0c274f7e57
19 changed files with 781 additions and 170 deletions
-2
View File
@@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast";
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
import {
buildStudioContextValue,
useGlobalFileDrop,
@@ -82,7 +81,6 @@ export function StudioApp() {
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
useAudioSoloBridge(previewIframeRef);
const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath;
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import type { StudioRightPanelProps } from "./StudioRightPanel.types";
export type { StudioRightPanelProps };
@@ -20,15 +20,15 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { usePlayerStore } from "../player";
import { waitForMediaJob } from "./studioMediaJobs";
import {
applyColorGradingScopeUpdate,
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
import { timelineKeysForSelections } from "../utils/studioHelpers";
import { canHideSelections } from "../utils/timelineInspector";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
import { useRemoveBackground } from "../hooks/useRemoveBackground";
// fallow-ignore-next-line complexity
export function StudioRightPanel({
@@ -164,14 +164,6 @@ export function StudioRightPanel({
handleInspectorSplitResizeMove,
handleInspectorSplitResizeEnd,
} = useInspectorSplitResize();
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
useEffect(
() => () => {
backgroundRemovalAbortRef.current?.abort();
},
[],
);
const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -238,52 +230,7 @@ export function StudioRightPanel({
],
);
const handleRemoveBackground = useCallback(
// fallow-ignore-next-line complexity
async (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputPath,
createBackgroundPlate: options.createBackgroundPlate === true,
quality: options.quality ?? "balanced",
}),
},
);
const data = (await response.json().catch(() => ({}))) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
throw new Error(data.error || `Background removal failed (${response.status})`);
}
showToast("Removing background...", "info");
backgroundRemovalAbortRef.current?.abort();
const controller = new AbortController();
backgroundRemovalAbortRef.current = controller;
try {
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
await refreshFileTree();
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
return result;
} finally {
if (backgroundRemovalAbortRef.current === controller) {
backgroundRemovalAbortRef.current = null;
}
}
},
[projectId, refreshFileTree, showToast],
);
const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
/**
* A dial being dragged writes to the preview and stops there.
@@ -300,6 +247,17 @@ export function StudioRightPanel({
[handleDomAttributeLiveCommit],
);
const handleHideAllSelected = () => {
// Audio has no visual to hide, and `data-hidden` on an audio element is what
// MUTES it — preview silences it and the render drops it from the mix. The
// timeline withholds the eye on an audio track for that reason
// (`visible={!isAudioTrack}`), and the single-selection panel gates the same
// write on `audioSelection`; this multi-selection path was the way back to
// it. Checked here as well as in the panel because the button is not the
// only caller.
if (!canHideSelections(domEditGroupSelections)) {
showToast("Audio can't be hidden — use the group's own controls", "info");
return;
}
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
@@ -22,7 +22,7 @@ afterEach(() => {
vi.resetModules();
});
function baseElement() {
function baseElement(): NonNullable<PropertyPanelProps["element"]> {
return {
element: document.createElement("div"),
id: "mono-label",
@@ -81,7 +81,7 @@ function nonTextElement() {
// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
// must not double-render the "Text" heading (FlatGroup's own heading; this
// component never renders one of its own).
function multiFieldTextElement() {
function multiFieldTextElement(): NonNullable<PropertyPanelProps["element"]> {
const base = baseElement();
return {
...base,
@@ -177,6 +177,43 @@ function sixGroupElement() {
};
}
/** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
function audioClipElement() {
const element = document.createElement("audio");
return {
...baseElement(),
element,
id: "vo-1",
selector: "#vo-1",
label: "Vo 1",
tagName: "audio",
textFields: [],
dataAttributes: { start: "1", duration: "3" },
};
}
/**
* A mixer bus: no clip range at all, and no box either.
*
* Carries a `data-start` on purpose. A real bus has none — its automation clock
* is composition time — but the timing gate has to refuse the TAG rather than
* merely fall out of a missing attribute, or something writing one would put
* Start/Duration back on a thing that has no range.
*/
function audioBusElement() {
const element = document.createElement("hf-audio-group");
return {
...baseElement(),
element,
id: "voiceover",
selector: "#voiceover",
label: "Voiceover",
tagName: "hf-audio-group",
textFields: [],
dataAttributes: { start: "0", duration: "8" },
};
}
const INFERRED_TIMING_ANIMATION = {
id: "a1",
targetSelector: "#inferred-anim",
@@ -196,7 +233,7 @@ const INFERRED_TIMING_ANIMATION = {
async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
propsOverride: Partial<PropertyPanelProps> = {},
currentTime?: number,
) {
@@ -952,3 +989,64 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — Motion is for things that move", () => {
it.each([
["a custom music tag", () => document.createElement("music")],
[
"an element with an audio source",
() => {
const element = document.createElement("div");
element.setAttribute("src", "voiceover.mp3");
return element;
},
],
])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
const fixture = {
...audioClipElement(),
element: makeElement(),
tagName: "div",
};
const { host, root } = await renderPanel(true, fixture);
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((node) => node.textContent ?? "");
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
act(() => root.unmount());
});
it(
"calls the section Timing on an audio clip, and offers no tween editor",
async () => {
const { host, root } = await renderPanel(true, audioClipElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
// The clip's placement survives — it is still a clip on a track.
expect(titles.some((t) => t.includes("Timing"))).toBe(true);
// "Motion" named the tween editor, which an <audio> element has no
// transform, opacity or box for. Showing it was the panel gating on
// handler presence rather than on the element.
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"offers a bus neither — it has no clip range to edit",
async () => {
const { host, root } = await renderPanel(true, audioBusElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
expect(titles.some((t) => t.includes("Timing"))).toBe(false);
// It is still a mixer bus: the reason to select one at all.
expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -36,6 +36,7 @@ import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { GestureRecordPanelButton } from "./GestureRecordControl";
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { isAudioDomElement } from "../../utils/timelineInspector";
// Re-export helpers that external consumers import from this module
export {
@@ -119,6 +120,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
/**
* An audio element gets no hide control here.
*
* On an audio track "hidden" and "muted" are not similar operations, they are
* the SAME operation with two names (groups doc §2.1) — which is why the
* timeline's eye became the mute rather than growing a sibling. A second copy
* in the panel, still called "Hide element", is precisely the thing that step
* removed: "Two controls that silence a track, sitting next to each other,
* differing only in a distinction the author cannot see." An
* `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
* own row.
*/
const audioSelection = isAudioDomElement(element?.element);
// Live during playback, the store's when paused — see the hook. Shared with the
// audio FX panel, which follows the playhead for the same reason: a value the
// timeline drives has to be shown moving, not frozen at what the attribute says.
@@ -309,7 +323,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
selectedElementId={selectedElementId}
selectedElementHidden={selectedElementHidden}
visibilityLabel={visibilityToggleLabel}
onToggleHidden={onToggleElementHidden}
onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
/>
</div>
</div>
@@ -71,4 +71,78 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
// A layout group is a positioned wrapper around a bounding box; an <audio>
// clip has none (offsetWidth/Height are 0), so grouping audio produced a 0x0
// div with inline left/top on elements that are never laid out. Withheld
// rather than offered-then-refused.
const audioElements = (tags: string[]) =>
tags.map((tag, i) => ({
id: `el-${i}`,
selector: `#el-${i}`,
label: `El ${i}`,
tagName: tag,
element: document.createElement(tag),
})) as unknown as DomEditSelection[];
it("withholds both actions when the selection includes audio", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["audio", "audio"])}
onGroupSelection={vi.fn()}
onHideAllSelected={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
// Hiding is visibility, and `data-hidden` on audio is what MUTES it — the
// timeline withholds the eye on an audio track for that reason, and this
// panel was the way back to the same write.
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
// The list still names what is selected; only the actions go.
expect(host.textContent).toContain("2 elements selected");
act(() => root.unmount());
});
it("withholds it for a mixed selection too, since the wrapper would still take audio in", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "audio"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
act(() => root.unmount());
});
it("counts an <hf-audio-group> bus as audio too", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["hf-audio-group", "div"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
act(() => root.unmount());
});
it("still offers both for a selection of layout elements", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "span"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull();
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
act(() => root.unmount());
});
});
@@ -1,5 +1,6 @@
import { Eye, Layers } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
import { canHideSelections } from "../../utils/timelineInspector";
function FlatEmptyState() {
return (
@@ -67,6 +68,9 @@ function FlatMultiSelectState({
onHideAllSelected?: () => void;
onClearSelection?: () => void;
}) {
// One predicate for both actions and for the handler's own refusal, so the
// button and the refusal cannot disagree about what audio is.
const hasAudio = !canHideSelections(multiSelectedElements);
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
@@ -123,26 +127,37 @@ function FlatMultiSelectState({
);
})}
</div>
<div className="flex gap-2">
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
Group selection
</button>
<button
type="button"
data-flat-multiselect-hide-all="true"
onClick={onHideAllSelected}
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
>
<Eye size={13} />
Hide all
</button>
</div>
{/* Neither action applies to audio, so the row goes rather than showing
an empty frame. Grouping is the LAYOUT grouper a positioned wrapper
around a bounding box, and an <audio> clip has none (grouping two
produced a 0x0 div with inline left/top on elements that are never
laid out). Hiding is visibility, which for audio doubles as mute; the
timeline already withholds the eye on an audio track
(`visible={!isAudioTrack}`) and this panel was the way back to the
same write. Both handlers refuse it too they own keyboard paths no
hidden button can gate. */}
{!hasAudio && (
<div className="flex gap-2">
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
Group selection
</button>
<button
type="button"
data-flat-multiselect-hide-all="true"
onClick={onHideAllSelected}
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
>
<Eye size={13} />
Hide all
</button>
</div>
)}
<span className="text-center text-[10px] text-panel-text-5">
Select a single element to edit its properties
</span>
@@ -1,5 +1,5 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { slugifyDesignInput } from "../../utils/designInputTracking";
@@ -7,15 +7,18 @@ import { isTextEditableSelection } from "./domEditing";
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { audioFxSummary } from "./audioFxSummary";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { closedGroupHeader, isSelectionHidden } from "./propertyPanelFlatClosedGroup";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
import { FlatMotionSection, motionSectionLabel } from "./propertyPanelFlatMotionSection";
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
import { useVolumeAutomation } from "./useVolumeAutomation";
import { useAudioFxRevealSection } from "./useAudioFxRevealSection";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { createGsapLivePreview } from "./gsapLivePreview";
@@ -40,6 +43,7 @@ import {
EMPTY_GSAP_EFFECT_HANDLERS,
type FlatGroupDescriptor,
} from "./propertyPanelFlatDescriptors";
import { isAudioDomElement } from "../../utils/timelineInspector";
/** The flat inspector shell with one shared open-group state. */
// fallow-ignore-next-line complexity
@@ -135,7 +139,13 @@ export function PropertyPanelFlat({
? "style"
: sections.media
? "media"
: "layout",
: // An `<hf-audio-group>` has no style, no layout and no media — its
// chain is the only reason to select one. Without this the fallback
// landed on "layout", a section a bus does not render, so opening the
// rack on a group produced a panel with everything collapsed.
sections.audioFx
? "audio-fx"
: "layout",
);
// Tracks which group(s) are actively transitioning this toggle cycle, so
@@ -161,6 +171,7 @@ export function PropertyPanelFlat({
timelineSessionEpoch: state.timelineSessionEpoch,
})),
);
const storeElements = usePlayerStore((state) => state.elements);
// Identity of the element THIS panel actually renders (not the store's
// selectedElementId, which flips synchronously on selection while the panel
// still renders the previous element during async DOM-selection resolution):
@@ -187,6 +198,25 @@ export function PropertyPanelFlat({
if (focusesThisPanel) setOpenGroupId("motion");
}
/**
* A lane's reveal request opens the Audio FX section, the same way a focused
* ease segment opens Motion.
*
* Without this the request reached a collapsed section: the rack and the
* module the request names is not mounted while it is closed, so the click
* selected the clip and then appeared to do nothing.
*/
const hiddenNow = isSelectionHidden(selectedElementHidden, element);
const reveal = useAudioFxRevealSection({
elementId: element?.id,
hasAudioFxSection: Boolean(sections.audioFx),
});
if (reveal.revealNonce !== null) {
reveal.consume(reveal.revealNonce);
setOpenGroupId("audio-fx");
}
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -254,11 +284,30 @@ export function PropertyPanelFlat({
onSetAllKeyframeEases,
}
: null;
const showMotionEffects = gsapEffectHandlers !== null;
const audioSelection = isAudioDomElement(element.element);
// Handlers being wired is necessary but not sufficient: App.tsx always passes
// them, so this alone showed the tween editor for every selection — including
// an `<audio>` clip and an `<hf-audio-group>` bus, neither of which has a
// transform, an opacity or a box for a tween to move. Gated on the TAG, not on
// `sections.animation` (`animationCount > 0`): a div with no tweens yet must
// still offer "+ Add", so "has none" and "can have none" are different
// questions and only the second one belongs here.
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
const showMotionGroup = showMotionTiming || showMotionEffects;
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
// The group this clip belongs to, if any — the Audio FX summary reads
// "in Voiceover" for a member (see `audioFxSummary`). Membership lives on the
// members, so resolve the owning label from the live document.
const audioGroupLabel = useMemo((): string | undefined => {
const doc = element.element?.ownerDocument;
const id = element.id;
if (!doc || !id) return undefined;
return resolveAudioGroups(doc).find((group) => group.memberIds.includes(id))?.label;
// eslint-disable-next-line react-hooks/exhaustive-deps -- store replacement signals live group membership changed
}, [element, storeElements]);
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
@@ -346,8 +395,12 @@ export function PropertyPanelFlat({
if (showMotionGroup) {
groups.push({
id: "motion",
title: "Motion",
summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`,
...motionSectionLabel({
timingOnly: audioSelection,
start: elStart,
duration: elDuration,
effectCount: gsapAnimations.length,
}),
content: (
<FlatMotionSection
element={element}
@@ -431,7 +484,7 @@ export function PropertyPanelFlat({
groups.push({
id: "audio-fx",
title: "Audio FX",
summary: audioFxSummary(element),
summary: audioFxSummary(element, audioGroupLabel),
content: (
<AudioFxGroup
element={element}
@@ -466,17 +519,8 @@ export function PropertyPanelFlat({
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : groups[openIndex];
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
const renderClosedGroup = (group: FlatGroupDescriptor) => (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
const renderClosedGroup = (group: FlatGroupDescriptor) =>
closedGroupHeader(group, toggleOpen, justToggledIds);
return (
<DesignPanelInputProvider ui="flat">
@@ -486,10 +530,27 @@ export function PropertyPanelFlat({
name={element.label}
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
hidden={hiddenNow}
// Audio gets no hide control here. On an audio track "hidden" and
// "muted" are not similar operations, they are the SAME operation
// with two names (groups doc §2.1) — which is why the timeline's eye
// BECAME the mute rather than growing a sibling. A second copy in
// the panel, still called "Hide element", is exactly what that step
// set out to remove: "Two controls that silence a track, sitting
// next to each other, differing only in a distinction the author
// cannot see." An `<hf-audio-group>` has no visual to hide at all.
//
// EXCEPT while it is already hidden — the same door-from-the-inside
// the timeline's eye keeps for an audio track
// (`TimelineTrackPlainHeader`). Withholding it unconditionally
// withheld the only way back: a `data-hidden` group is silent in
// preview (the bus's mute gain) and absent from the render (every
// member dropped), and the group header carries no visibility
// control of its own now that mute and solo are gone. Only
// hand-editing the HTML brought the audio back.
onToggleHidden={
selectedElementId && onToggleElementHidden
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
selectedElementId && onToggleElementHidden && (!audioSelection || hiddenNow)
? () => void onToggleElementHidden(selectedElementId, !hiddenNow)
: undefined
}
copied={clipboardCopied}
@@ -76,4 +76,25 @@ describe("PropertyPanelFlatHeader", () => {
const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() });
expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
});
// The panel's hide control is withheld for audio by its caller — on an audio
// track "hidden" and "muted" are the same operation with two names (groups
// doc §2.1), and the timeline already carries it, correctly labelled. This
// pins the header's half of that contract: no handler, no button.
it("renders no visibility control when its caller withholds the handler", () => {
const { host } = renderHeader({ onToggleHidden: undefined });
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels).not.toContain("Hide element");
expect(labels).not.toContain("Show element");
});
it("renders it when the handler is supplied", () => {
const { host } = renderHeader({ onToggleHidden: vi.fn() });
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels).toContain("Hide element");
});
});
@@ -11,9 +11,11 @@
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
import { useFxAudition } from "./useFxAudition.js";
import { trackPresetAuditioned } from "./audioFxTelemetry.js";
const POPOVER_WIDTH = 260;
const VIEWPORT_MARGIN = 8;
@@ -80,6 +82,13 @@ export interface TimelineFxPopoverProps {
onOpenRack: () => void;
}
/** A preset applied for AUDITION only: no telemetry, since nothing was chosen.
* `applyPresetToChain` is the tracked path and belongs to `onPick`. */
function auditionPresetChain(base: HfAudioFxChain, presetId: string): HfAudioFxChain {
const preset = getAudioFxPreset(presetId);
return preset ? applyAudioFxPreset(base, preset) : base;
}
export function TimelineFxPopover({
anchorRect,
chain,
@@ -108,6 +117,7 @@ export function TimelineFxPopover({
}, [onClose]);
const applyPreset = (id: string) => {
// The stored chain, not the auditioned one — see `storedChain`.
const next = applyPresetToChain(storedChain(), id, trackKind);
if (!next) return;
clearAudition();
@@ -141,12 +151,18 @@ export function TimelineFxPopover({
<FxPresetMenu
trackKind={trackKind}
onPick={applyPreset}
// The RAW apply, not `applyPresetToChain` — that helper fires
// `trackPresetApplied` on every call, so auditioning a 12-preset shelf
// by hover or arrow key emitted 12 `preset_applied` events and the
// numbers could not tell an audition from a decision. `FxSection`
// makes exactly this split, with `onAuditionTracked` carrying the
// honest event.
onAudition={
onChainPreview
? (id) =>
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
? (id) => audition(id ? (base) => auditionPresetChain(base, id) : null)
: undefined
}
onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
/>
</div>
<div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
@@ -101,6 +101,37 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
return { host, onSetAttributeQuiet, onSetAttributeLive };
}
function mountGroup(memberStart: number) {
const bus = document.createElement("hf-audio-group");
bus.id = "voiceover";
document.body.append(bus);
const member = document.createElement("audio");
member.id = "vo-1";
member.setAttribute("data-audio-group", "voiceover");
member.setAttribute("data-start", String(memberStart));
member.setAttribute("data-duration", "5");
document.body.append(member);
const host = document.createElement("div");
document.body.append(host);
const selection = {
dataAttributes: { "fx-chain": CHAIN },
id: "voiceover",
element: bus,
tagName: "hf-audio-group",
} as unknown as DomEditSelection;
act(() => {
createRoot(host).render(
<AudioFxGroup
element={selection}
onSetAttributeQuiet={vi.fn()}
onSetAttributeLive={vi.fn()}
/>,
);
});
return host;
}
const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
for (const row of Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-row"))) {
if (row.querySelector(".hf-fx-label")?.textContent === label) return row;
@@ -565,6 +596,20 @@ describe("AudioFxGroup dynamic carve", () => {
expect(store().playbackRequest?.returnTo).toBe(42);
});
it("seeks a group audition to the next member span", () => {
act(() =>
usePlayerStore.setState({
isPlaying: false,
currentTime: 2,
requestedSeekTime: null,
}),
);
const host = mountGroup(10);
hoverPreset(host);
expect(store().requestedSeekTime).toBe(10);
leaveShelf(host);
});
it("leaves a transport the author started alone", () => {
// Stopping their playback because they passed over a preset would be the
// panel taking a decision nobody offered it.
@@ -1341,6 +1386,62 @@ describe("AudioFxGroup carve by default", () => {
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
});
/**
* Mount a track of the test's own naming as the selection, with siblings.
*
* `mount` always calls the selected track `bed`, which is what let the near-end
* hole go unnoticed: nothing ever selected a track whose NAME said voice.
*/
const mountNamed = (id: string, siblings: string[]) => {
const onSetAttributeQuiet = vi.fn();
const selected = document.createElement("audio");
selected.id = id;
document.body.append(selected);
for (const other of siblings) {
const el = document.createElement("audio");
el.id = other;
document.body.append(el);
}
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(
<AudioFxGroup
element={
{
dataAttributes: { "fx-chain": "" },
id,
element: selected,
} as unknown as DomEditSelection
}
onSetAttributeQuiet={onSetAttributeQuiet}
onSetAttributeLive={vi.fn()}
/>,
);
});
return { host, onSetAttributeQuiet };
};
// The reported bug. A carve makes room in a bed for a voice, so a voice track
// is the one thing that can never be the bed — `couldBeCarveSource` has said
// as much since it was written, and nothing called it. Selecting a narration
// clip offered it the module, found one candidate, and applied a carve nobody
// asked for.
it("never offers the carve on a track whose name says voice", () => {
const { host, onSetAttributeQuiet } = mountNamed("vo-2", ["music-bed"]);
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
expect(host.querySelector(".hf-fx-carve-module")).toBeNull();
});
// Offering is a suggestion, applying is a decision. An unnamed track keeps the
// module — the author may know better than the name does — but nothing is
// written until they say so.
it("offers but does not apply on a track whose name says nothing", () => {
const { host, onSetAttributeQuiet } = mountNamed("a1", ["vo-1"]);
expect(host.querySelector(".hf-fx-carve-module")).not.toBeNull();
expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-carve")).toBe(false);
});
});
describe("AudioFxGroup carve source list", () => {
@@ -7,7 +7,7 @@
* budget, and self-contained enough to test on its own.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import {
HF_AUDIO_FX_ATTR,
HF_AUDIO_FX_DATA_KEY,
@@ -40,12 +40,47 @@ import {
} from "./propertyPanelAutomation";
import type { DomEditSelection } from "./domEditingTypes";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
import { usePlayerStore } from "../../player/store/playerStore";
import { isRevealedAudioFxRequestCurrent } from "../../player/store/keyframeSlice";
import { FxSection } from "./propertyPanelFxSection.js";
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
import { useFxChainObserved } from "./useFxChainObserved.js";
import { useFxCarve } from "./useFxCarve.js";
import { audioFxSignalPath } from "./audioFxSignalPath.js";
import type { AuditionSpan } from "./useAuditionTransport.js";
import {
HF_AUDIO_GROUP_ATTR,
HF_AUDIO_GROUP_TAG,
resolveAudioGroups,
} from "@hyperframes/core/audio-groups";
import { useFxLevelling } from "./useFxLevelling.js";
function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
const start = Number.parseFloat(startRaw ?? "");
const duration = Number.parseFloat(durationRaw ?? "");
return Number.isFinite(start) && Number.isFinite(duration) && duration > 0
? { start, duration }
: null;
}
/** The selected clip, or every current member when the selected rack is a bus. */
function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
const own = auditionSpan(element.dataAttributes?.["start"], element.dataAttributes?.["duration"]);
if (own) return [own];
if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
const doc = element.element?.ownerDocument;
if (!doc) return [];
return [...doc.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)]
.filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
.flatMap((member) => {
const span = auditionSpan(
member.getAttribute("data-start") ?? undefined,
member.getAttribute("data-duration") ?? undefined,
);
return span ? [span] : [];
});
}
/**
* Bridges the FX panel to the element/attribute world. Chain and carve are
* serialised onto the element the way colour grading carries its config, so
@@ -112,6 +147,9 @@ export function AudioFxGroup({
* came under the playhead.
*/
const playhead = useLivePlayheadTime();
const revealRequest = usePlayerStore((s) => s.revealedAudioFxTarget);
const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
const localTime = playhead - clipStart(element.dataAttributes?.["start"]);
const liveAutomationValues = ((): Map<string, number> => {
const values = new Map<string, number>();
@@ -226,6 +264,31 @@ export function AudioFxGroup({
const [analysing, setAnalysing] = useState(false);
// The rack's In/Out lines. Resolved from the live document because a group's
// membership lives on the members, so neither end of the routing can be read
// off the selected element alone.
// Keyed on the store's element array as well as the selection: membership is
// held by the MEMBERS, so a clip joining or leaving this group changes neither
// `element` nor its attributes, and the path went stale — "OUT to mix" on a
// clip that had just been grouped. `syncStoredGroupAttribute` and
// `updateElement` both replace the array, so its identity is the cheap signal
// that membership may have moved.
const storeElements = usePlayerStore((s) => s.elements);
const signalPath = useMemo(() => {
const doc = element.element?.ownerDocument;
return audioFxSignalPath(
element.tagName?.toLowerCase(),
element.id ?? undefined,
doc ? resolveAudioGroups(doc) : [],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element, storeElements]);
// A bus has no span of its own, so resolve the live members. The
// `storeElements` subscription above rerenders this panel when membership
// changes without changing the selection.
const auditionSpans = auditionSpansFor(element);
const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
element,
chain,
@@ -249,6 +312,32 @@ export function AudioFxGroup({
return (
<FxSection
// A lane's reveal request, but only when it names THIS element: the rack
// shows one element, and a request aimed at another must not reopen
// whatever happens to be mounted. Stale requests (other project, pre-
// reload session) are refused by the same check.
revealTarget={
revealRequest &&
revealRequest.elementKey === element.id &&
isRevealedAudioFxRequestCurrent(revealRequest, {
timelineProjectId,
timelineSessionEpoch,
})
? revealRequest.automationTarget
: null
}
// Forwarded, not dropped: the section consumes by nonce, so without it a
// request never fires and a second click on the same lane is inert.
revealNonce={
revealRequest &&
revealRequest.elementKey === element.id &&
isRevealedAudioFxRequestCurrent(revealRequest, {
timelineProjectId,
timelineSessionEpoch,
})
? revealRequest.nonce
: null
}
// Locked while the carve is measuring. `analyse` captures the chain and
// the automation BEFORE its fetch and decode, then rewrites the whole
// attribute from that snapshot — so an effect added, or a knob committed,
@@ -273,7 +362,8 @@ export function AudioFxGroup({
next.nodes.length ? serializeAudioFxChain(next) : null,
)
}
onAuditionTransport={auditionTransport}
signalPath={signalPath}
onAuditionTransport={(on) => auditionTransport(on, auditionSpans)}
onChainPreview={(next) =>
// Live writes skip the preview refresh entirely, so dragging a knob no
// longer reloads the composition and restarts playback on every pixel.
@@ -0,0 +1,46 @@
/**
* A collapsed group's header row in the flat inspector.
*
* Its own module so `PropertyPanelFlat.tsx` stays under the studio's 600-line
* cap; it reads only its arguments, which is what makes it separable.
*/
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { slugifyDesignInput } from "../../utils/designInputTracking";
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
import type { FlatGroupDescriptor } from "./propertyPanelFlatDescriptors";
/** Its title, its one-line summary, and the entrance animation only the group
* just toggled gets. */
export function closedGroupHeader(
group: FlatGroupDescriptor,
toggleOpen: (id: string) => void,
justToggledIds: readonly string[],
) {
return (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
}
/**
* Is the selection hidden RIGHT NOW.
*
* `selectedElementHidden` is derived from `timelineElements`, and an
* `<hf-audio-group>` is not one it is a mixer bus, and the runtime no longer
* stamps timing on it, so it has no timeline row to carry a `hidden` flag. Its
* `data-hidden` lives only on the element, so the attribute is the fallback.
*/
export function isSelectionHidden(
fromTimeline: boolean,
element: { dataAttributes?: Record<string, string | undefined> } | null | undefined,
): boolean {
return fromTimeline || element?.dataAttributes?.["hidden"] != null;
}
@@ -172,3 +172,34 @@ export function FlatMotionSection({
</div>
);
}
/**
* What the Motion section is called, and what its collapsed line says.
*
* "Motion" names the tween editor. On audio the section is Start/Duration/End
* and nothing else, so the label would promise what it no longer offers and
* "Motion: 0 effects" on a sound is a category error, hence the span instead of
* a count.
*
* Keyed on the TAG by its caller, not on whether the effects half is showing:
* that half also disappears when a host simply has not wired the GSAP handlers,
* and a div in that state is still a thing that moves renaming its section
* would be describing the host's wiring rather than the element.
*/
export function motionSectionLabel(args: {
timingOnly: boolean;
start: number;
duration: number;
effectCount: number;
}): { title: string; summary: string } {
if (args.timingOnly) {
return {
title: "Timing",
summary: `${formatTimingValue(args.start)} ${formatTimingValue(args.start + args.duration)}`,
};
}
return {
title: "Motion",
summary: `${args.effectCount} effect${args.effectCount === 1 ? "" : "s"}`,
};
}
@@ -0,0 +1,64 @@
/**
* Opening the Audio FX section for a lane's reveal request.
*
* Split out of `PropertyPanelFlat.tsx` to keep it under the studio's 600-line
* cap. All three of the request's hazards live here rather than being restated
* at the call site: it must be current, it is consumed by NONCE, and it is
* retired when the panel goes away.
*/
import { useEffect, useState } from "react";
import { usePlayerStore } from "../../player";
import { isRevealedAudioFxRequestCurrent } from "../../player/store/keyframeSlice";
export interface AudioFxRevealSectionInput {
/** The element the panel is showing, or null. */
elementId: string | null | undefined;
/** False when the panel does not render an Audio FX section at all. */
hasAudioFxSection: boolean;
}
/**
* The nonce this panel should act on, or null.
*
* Consumption is keyed on the NONCE, not the request object: clicking a lane
* selects the clip first, which REMOUNTS this panel, so a `!==` against the
* previous value would initialise to the already-set request and never fire.
* The nonce also makes a second click on the same lane a new request.
*/
export function useAudioFxRevealSection(input: AudioFxRevealSectionInput): {
/** Non-null exactly once per request: open the section on this commit. */
revealNonce: number | null;
consume: (nonce: number) => void;
} {
const revealedAudioFxTarget = usePlayerStore((s) => s.revealedAudioFxTarget);
const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
const clearRevealedAudioFxTarget = usePlayerStore((s) => s.clearRevealedAudioFxTarget);
const [consumed, setConsumed] = useState<number | null>(null);
// Retire the request once this panel is gone. Consumption is nonce-guarded so
// a stale request was already harmless — but it sat in the store until the
// next click, and a request nobody will ever consume is state every reader
// then has to reason about.
useEffect(() => {
if (consumed === null) return;
return () => clearRevealedAudioFxTarget(consumed);
}, [consumed, clearRevealedAudioFxTarget]);
const forThisPanel =
revealedAudioFxTarget !== null &&
revealedAudioFxTarget.elementKey === input.elementId &&
isRevealedAudioFxRequestCurrent(revealedAudioFxTarget, {
timelineProjectId,
timelineSessionEpoch,
}) &&
input.hasAudioFxSection
? revealedAudioFxTarget.nonce
: null;
return {
revealNonce: forThisPanel !== null && forThisPanel !== consumed ? forThisPanel : null,
consume: setConsumed,
};
}
@@ -1,61 +0,0 @@
import { useEffect, useMemo } from "react";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { usePlayerStore } from "../player/store/playerStore";
import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
interface IframeWindow extends Window {
__hf?: { setAudioSolo?: (ids: readonly string[]) => void };
}
/**
* Pushes the studio's "Hear only this" set into the preview runtime whenever
* it changes. A dedicated push, not a DOM write: solo is session-only and
* must never touch an attribute (design doc §2.2 / the export-safety
* guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
* the way group mute does see `window.__hf.setAudioSolo`.
*/
export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
const soloed = usePlayerStore((s) => s.soloed);
useEffect(() => {
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
win?.__hf?.setAudioSolo?.([...soloed]);
}, [soloed, previewIframeRef]);
}
/** One soloed id's display label reads the live preview DOM directly (same
* approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
* but the document's own element ids. A group carries its label on
* `data-label`; anything else falls back to the same label rule the
* timeline itself uses. */
function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
const el = doc?.getElementById(id);
if (!el) return id;
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
}
return getTimelineElementDisplayLabel({
id,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: el.tagName,
});
}
/**
* The transport bar's "Hear only this" banner text `null` while nothing is
* soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
* (design doc §2.2's banner rule); "your export is not affected" is fixed
* copy the caller owns, this only resolves the variable half.
*/
export function useSoloBannerText(previewIframeRef: {
current: HTMLIFrameElement | null;
}): string | null {
const soloed = usePlayerStore((s) => s.soloed);
return useMemo(() => {
if (soloed.size === 0) return null;
if (soloed.size === 1) {
const doc = previewIframeRef.current?.contentDocument;
return resolveSoloLabel(doc, [...soloed][0]);
}
return `${soloed.size} tracks`;
}, [soloed, previewIframeRef]);
}
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useRef } from "react";
import { waitForMediaJob } from "../components/studioMediaJobs";
import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
interface RemoveBackgroundOptions {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
}
/**
* One removal in flight at a time: starting a second one aborts whichever job
* is still running, so a stale progress callback can't overwrite a newer
* result. Unmounting aborts too, or the job would keep running against a
* panel that is no longer there to show its progress.
*/
export function useRemoveBackground(
projectId: string,
refreshFileTree: () => Promise<void>,
showToast: (message: string, kind?: "info" | "error") => void,
) {
const abortRef = useRef<AbortController | null>(null);
useEffect(
() => () => {
abortRef.current?.abort();
},
[],
);
return useCallback(
// fallow-ignore-next-line complexity
async (inputPath: string, options: RemoveBackgroundOptions) => {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputPath,
createBackgroundPlate: options.createBackgroundPlate === true,
quality: options.quality ?? "balanced",
}),
},
);
const data = (await response.json().catch(() => ({}))) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
throw new Error(data.error || `Background removal failed (${response.status})`);
}
showToast("Removing background...", "info");
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
await refreshFileTree();
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
return result;
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
}
},
[projectId, refreshFileTree, showToast],
);
}
@@ -55,6 +55,17 @@ export type RevealedAudioFxTargetRequest = Omit<
"projectId" | "sessionEpoch" | "nonce"
>;
/** Whether a reveal request still belongs to what is on screen. */
export function isRevealedAudioFxRequestCurrent(
request: RevealedAudioFxTarget,
state: TimelineSessionIdentity,
): boolean {
return (
request.projectId === state.timelineProjectId &&
request.sessionEpoch === state.timelineSessionEpoch
);
}
interface TimelineSessionIdentity {
timelineProjectId: string | null;
timelineSessionEpoch: number;