Skip to content

Commit 626093d

Browse files
committed
feat(studio): show the Audio FX section on audio tracks
Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders.
1 parent 42933e8 commit 626093d

3 files changed

Lines changed: 111 additions & 0 deletions

File tree

packages/core/src/editing/affordances.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,14 @@ describe("resolveEditingSections (sections-only export)", () => {
198198
expect(s).toMatchObject({ timing: true, animation: true });
199199
});
200200
});
201+
202+
describe("audioFx section", () => {
203+
it("applies to an audio element", () => {
204+
expect(resolveEditingSections(baseFacts({ tag: "audio" })).audioFx).toBe(true);
205+
});
206+
207+
it("does not apply to video, whose sound lives on a separate audio element", () => {
208+
expect(resolveEditingSections(baseFacts({ tag: "video" })).audioFx).toBe(false);
209+
expect(resolveEditingSections(baseFacts({ tag: "div" })).audioFx).toBe(false);
210+
});
211+
});

packages/core/src/editing/affordances.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export interface EditingSectionApplicability {
3434
/** Position/size/rotation/stacking — meaningless on an element with no
3535
* rendered box (e.g. `<audio>`, which never paints a visual frame). */
3636
layout: boolean;
37+
/** Audio FX chain and voiceover carve — `<audio>` only. Video carries its
38+
* sound on a separate `<audio>` element, so this never applies to it. */
39+
audioFx: boolean;
3740
/** Fill/radius/stroke/shadow/blend-mode/clip — same "no rendered box" gate
3841
* as `layout`, kept separate since a future tag could need one without
3942
* the other. */
@@ -207,6 +210,7 @@ export function resolveEditingSections(facts: EditableElementFacts): EditingSect
207210
return {
208211
text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition,
209212
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
213+
audioFx: facts.tag === "audio",
210214
colorGrading: facts.tag === "video" || facts.tag === "img",
211215
timing: facts.hasTimingStart || facts.animationCount > 0,
212216
animation: facts.animationCount > 0,

packages/studio/src/components/editor/PropertyPanelFlat.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,20 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
1313
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
1414
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
1515
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
16+
import {
17+
HF_AUDIO_FX_ATTR,
18+
parseAudioFxChain,
19+
serializeAudioFxChain,
20+
type HfAudioFxChain,
21+
} from "@hyperframes/core/audio-fx";
22+
import {
23+
HF_AUDIO_CARVE_ATTR,
24+
normalizeCarveSettings,
25+
type HfCarveSettings,
26+
} from "@hyperframes/core/audio-carve";
1627
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
28+
import type { DomEditSelection } from "./domEditing";
29+
import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection";
1730
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
1831
import { createGsapLivePreview } from "./gsapLivePreview";
1932
import { formatTextFieldPreview } from "./propertyPanelSections";
@@ -420,6 +433,14 @@ export function PropertyPanelFlat({
420433
});
421434
}
422435
}
436+
if (sections.audioFx) {
437+
groups.push({
438+
id: "audio-fx",
439+
title: "Audio FX",
440+
summary: audioFxSummary(element),
441+
content: <AudioFxGroup element={element} onSetAttribute={onSetAttribute} />,
442+
});
443+
}
423444
if (sections.media) {
424445
groups.push({
425446
id: "media",
@@ -514,3 +535,78 @@ export function PropertyPanelFlat({
514535
</DesignPanelInputProvider>
515536
);
516537
}
538+
539+
/** Chain length at a glance, so the collapsed group says whether anything is on. */
540+
function audioFxSummary(element: DomEditSelection): string {
541+
const raw = element.dataAttributes?.["fx-chain"];
542+
const carve = element.dataAttributes?.["fx-carve"];
543+
let count = 0;
544+
if (raw) {
545+
try {
546+
count = parseAudioFxChain(raw).nodes.filter((n) => n.enabled !== false).length;
547+
} catch {
548+
return "unreadable";
549+
}
550+
}
551+
const parts: string[] = [];
552+
if (count > 0) parts.push(`${count} effect${count === 1 ? "" : "s"}`);
553+
if (carve) parts.push("carve");
554+
return parts.length > 0 ? parts.join(" + ") : "none";
555+
}
556+
557+
/**
558+
* Bridges the FX panel to the element/attribute world. Chain and carve are
559+
* serialised onto the element the way colour grading carries its config, so
560+
* persistence is an ordinary attribute write with no new server route.
561+
*/
562+
function AudioFxGroup({
563+
element,
564+
onSetAttribute,
565+
}: {
566+
element: DomEditSelection;
567+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
568+
}) {
569+
const chain = ((): HfAudioFxChain => {
570+
const raw = element.dataAttributes?.["fx-chain"];
571+
if (!raw) return { version: 1, nodes: [] };
572+
try {
573+
return parseAudioFxChain(raw);
574+
} catch {
575+
// Show an unreadable chain as empty rather than breaking the panel; the
576+
// attribute is left untouched until the user changes something.
577+
return { version: 1, nodes: [] };
578+
}
579+
})();
580+
581+
const carve = ((): HfCarveSettings | null => {
582+
const raw = element.dataAttributes?.["fx-carve"];
583+
if (!raw) return null;
584+
try {
585+
return normalizeCarveSettings(JSON.parse(raw));
586+
} catch {
587+
return null;
588+
}
589+
})();
590+
591+
const sourceOptions: AudioTrackOption[] = (() => {
592+
const doc = element.element?.ownerDocument;
593+
if (!doc) return [];
594+
return Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]"))
595+
.filter((a) => a.id !== element.id)
596+
.map((a) => ({ id: a.id, label: a.id }));
597+
})();
598+
599+
return (
600+
<FxSection
601+
chain={chain}
602+
onChainChange={(next) =>
603+
onSetAttribute(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : "")
604+
}
605+
carve={carve}
606+
onCarveChange={(next) =>
607+
onSetAttribute(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : "")
608+
}
609+
sourceOptions={sourceOptions}
610+
/>
611+
);
612+
}

0 commit comments

Comments
 (0)