mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user