mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"}`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user