fix(studio,core): audio has timing, not motion — and a bus has neither

Selecting an audio bus showed a Motion section offering tween editors. A
bus has no transform, opacity or box, so every effect on that list moves
nothing; the same is true of an `<audio>` clip.

The panel's gate was "are the GSAP handlers wired", and `App.tsx` always
wires them, so it was true for every selection. It now also asks what was
selected.

Audio keeps its timing — an `<audio>` clip is placed on the timeline like
anything else — but the section is called Timing there and summarises its
span instead of an effect count, because "Motion: 0 effects" on a sound
is a category error. A bus loses timing too: it has no `data-start` and
no duration, and its automation clock is composition time, so Start /
Duration / End would be editing nothing.

Gated on the TAG, not on `sections.animation`: a `div` with no tweens yet
must still offer "+ Add", and keying the rename on `animationCount > 0`
renamed the section for exactly that div — which the existing panel test
caught. Keying it on `showMotionEffects` renamed it for an `img`, which
wires no GSAP handlers. Both halves belong to the tag.

`affordances.ts` carries the same two rules for anything reading the
section list rather than the flat panel. The label pair is
`motionSectionLabel`, in the module that owns the section it names, which
is also what keeps `PropertyPanelFlat.tsx` under the 600-line ceiling.
This commit is contained in:
Vance Ingalls
2026-08-23 02:06:58 -07:00
parent df7ba162e5
commit 553ea25931
5 changed files with 167 additions and 9 deletions
@@ -4,6 +4,7 @@ import {
resolveEditingSections,
type EditableElementFacts,
} from "./affordances";
import { HF_AUDIO_GROUP_TAG } from "../audioGroups";
function baseFacts(over: Partial<EditableElementFacts> = {}): EditableElementFacts {
return {
@@ -209,3 +210,41 @@ describe("audioFx section", () => {
expect(resolveEditingSections(baseFacts({ tag: "div" })).audioFx).toBe(false);
});
});
describe("audio has timing but no animation; a bus has neither", () => {
// Nothing on an `<audio>` element or an `<hf-audio-group>` bus has a
// transform, an opacity or a box, so a tween on one moves nothing. The panel
// showed its GSAP editor for both anyway, because it gated that on the
// handlers being wired rather than on the element being animatable.
it("withholds animation from an audio clip that HAS tweens", () => {
const s = resolveEditingSections(baseFacts({ tag: "audio", animationCount: 3 }));
expect(s.animation).toBe(false);
});
it("withholds it from a bus too", () => {
const s = resolveEditingSections(baseFacts({ tag: HF_AUDIO_GROUP_TAG, animationCount: 3 }));
expect(s.animation).toBe(false);
});
it("still grants it to a div with tweens, so the gate is the tag", () => {
expect(resolveEditingSections(baseFacts({ tag: "div", animationCount: 1 })).animation).toBe(
true,
);
});
// An audio clip is placed on the timeline like any other, so Start/Duration
// stay editable — that is the half of the old Motion section worth keeping.
it("keeps timing on an audio clip with an authored start", () => {
const s = resolveEditingSections(baseFacts({ tag: "audio", hasTimingStart: true }));
expect(s.timing).toBe(true);
});
// A bus has no `data-start` and no duration; its automation clock is
// composition time. Start/Duration/End would be editing nothing.
it("withholds timing from a bus even if something claims a start", () => {
const s = resolveEditingSections(
baseFacts({ tag: HF_AUDIO_GROUP_TAG, hasTimingStart: true, animationCount: 2 }),
);
expect(s.timing).toBe(false);
});
});
+11 -2
View File
@@ -220,8 +220,17 @@ export function resolveEditingSections(facts: EditableElementFacts): EditingSect
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
audioFx: facts.tag === "audio" || isAudioBus,
colorGrading: facts.tag === "video" || facts.tag === "img",
timing: facts.hasTimingStart || facts.animationCount > 0,
animation: facts.animationCount > 0,
// A bus has no clip range at all — no `data-start`, no duration, and its
// automation clock is composition time — so Start/Duration/End would be
// editing nothing. Audio keeps its timing: an `<audio>` clip is placed on
// the timeline like any other, it just cannot be tweened (see `animation`).
timing: !isAudioBus && (facts.hasTimingStart || facts.animationCount > 0),
// Has tweens AND could meaningfully have them. Neither an `<audio>` clip nor
// a bus has a transform, opacity or box, so a tween on one moves nothing —
// the flat panel gates its editor on the same two tags, and does it by tag
// rather than by this flag because a div with no tweens yet must still
// offer "+ Add".
animation: facts.animationCount > 0 && facts.tag !== "audio" && !isAudioBus,
layout: hasVisualBox,
style: hasVisualBox,
};
@@ -177,6 +177,39 @@ function sixGroupElement() {
};
}
/** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
function audioClipElement() {
return {
...baseElement(),
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() {
return {
...baseElement(),
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",
@@ -951,3 +984,39 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — Motion is for things that move", () => {
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,
);
});
@@ -15,7 +15,7 @@ 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";
@@ -282,7 +282,16 @@ export function PropertyPanelFlat({
onSetAllKeyframeEases,
}
: null;
const showMotionEffects = gsapEffectHandlers !== null;
const selectedTag = element.tagName?.toLowerCase();
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
// 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);
@@ -298,9 +307,6 @@ export function PropertyPanelFlat({
return resolveAudioGroups(doc).find((g) => g.memberIds.includes(id))?.label;
})();
const selectedTag = element.tagName?.toLowerCase();
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
const groups: FlatGroupDescriptor[] = [];
if (isTextEditable) {
groups.push({
@@ -388,8 +394,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}
@@ -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"}`,
};
}