fix(studio): keep the volume slider editable while automation owns the level (#4066)

* fix(studio): keep the volume slider editable while automation owns the level

The Design panel's Volume slider disabled itself outright once a volume
automation lane existed, with no alternative write path ever wired in — so
turning automation on froze the control permanently.

A commit now inserts or moves a keyframe at the playhead instead, reusing
the exact merge logic the timeline automation lane's own double-click
gesture already uses. The slider also now shows the envelope's live value
at the playhead rather than the static, possibly stale attribute.

* fix(studio): sample the live playhead for volume automation, not the stale store

The store's currentTime only updates once at the end of playback, so a
keyframe committed mid-transport landed at the last seek instead of the
real playhead and could silently overwrite the lane's seed point.
useLivePlayheadTime subscribes to the same live signal the timeline
follows, matching how the audio FX panel already reads it.

Also stops treating a video element's automation as invisible on the
timeline: groupAutomationLanes, its paired row-height count, and the
lane-drawing filter all gated on audio tag, so a volume lane written
from the property panel never showed a track to expand or a keyframe
to see. All three now key off "has lanes" (or audio-or-video where the
element itself, not its attribute, has to gate a live edit), matching
groupAutomationLanes and useTimelineTrackLayout's documented invariant
that the row's reserved height and its drawn lanes count the same clips.

* refactor(studio): simplify pass on the volume-automation fix

Extracted mergeInsertPoint (automationLaneGeometry.ts) so the panel's
withPointAt and the timeline lane's own double-click gesture share one
merge/seed/sort rule instead of two copies of the same three lines.

Fixed a real duplicate subscription: useVolumeAutomation was calling
useLivePlayheadTime() itself even though its only caller already reads
the same value via the prop PropertyPanel.tsx computes and passes down.
Threads currentTime through as a parameter instead.

Unified the three places that decide whether an element can draw an
automation lane (groupAutomationLanes, automationLaneCountOf,
TimelineAutomationLaneSlot's clip filter) behind one exported predicate,
isAudioOrVideoTimelineElement, so the row's reserved height and its
drawn lanes can't independently drift on what counts.

* test(studio): pin the clip-local time translation for volume automation

The adversarial reviewer mutated out `currentTime - elStart` and every
existing test still passed — the duration clamp happened to mask its
absence in every case that touched it. New case starts the clip at
t=10 so the clamp can't hide a missing subtraction.
This commit is contained in:
Miguel Ángel
2026-09-18 09:48:37 -04:00
committed by GitHub
parent 5ef5419a9e
commit 3306dbcdc0
14 changed files with 321 additions and 55 deletions
@@ -285,17 +285,17 @@ export function PropertyPanelFlat({
}
: 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.
// Gated on the tag, not `sections.animation` (`animationCount > 0`): an audio
// clip/bus has no tween to move, but a fresh div with no tweens yet must
// still offer "+ Add" — "has none" and "can have none" differ.
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
const showMotionGroup = showMotionTiming || showMotionEffects;
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
const volumeAutomation = useVolumeAutomation(
element,
currentTime,
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
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import type { HfAutomation } from "@hyperframes/core/audio-automation";
import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js";
import {
automationAttrValue,
withLane,
withoutLane,
withPointAt,
} from "./propertyPanelAutomation.js";
const carved = (): HfAutomation => ({
version: 1,
@@ -45,6 +50,82 @@ describe("withLane", () => {
});
});
// A panel control has no drag gesture of its own, so a commit has to land on
// the same point the timeline envelope's double-click would have produced.
describe("withPointAt", () => {
it("adds a point to an existing lane, keeping the others", () => {
const next = withPointAt(carved(), "volume", 5, 0.3);
const volume = next.lanes.find((l) => l.target === "volume");
expect(volume?.points).toEqual([
{ t: 0, v: 0.8 },
{ t: 5, v: 0.3 },
]);
});
it("replaces the nearby point instead of stacking a second one on top of it", () => {
const automation: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 0.8 },
{ t: 5, v: 0.3 },
],
},
],
};
const next = withPointAt(automation, "volume", 5.01, 0.6);
const volume = next.lanes.find((l) => l.target === "volume");
expect(volume?.points).toEqual([
{ t: 0, v: 0.8 },
{ t: 5.01, v: 0.6 },
]);
});
it("keeps points sorted after an insert earlier than the last one", () => {
const automation: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 0.2 },
{ t: 10, v: 0.9 },
],
},
],
};
const next = withPointAt(automation, "volume", 4, 0.5);
expect(next.lanes.find((l) => l.target === "volume")?.points.map((p) => p.t)).toEqual([
0, 4, 10,
]);
});
it("seeds the start when writing to an empty lane away from t=0", () => {
// A single point would be a constant jump the instant playback crosses it,
// not the envelope a fresh keyframe should produce.
const automation: HfAutomation = { version: 1, lanes: [{ target: "volume", points: [] }] };
const next = withPointAt(automation, "volume", 5, 0.3);
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([
{ t: 0, v: 0.3 },
{ t: 5, v: 0.3 },
]);
});
it("does not seed a second point when writing at the start of an empty lane", () => {
const automation: HfAutomation = { version: 1, lanes: [{ target: "volume", points: [] }] };
const next = withPointAt(automation, "volume", 0, 0.4);
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.4 }]);
});
it("leaves sibling lanes untouched", () => {
const next = withPointAt(carved(), "fx.n1.gain", 3, -2);
expect(next.lanes.find((l) => l.target === "fx.n2.gain")?.points).toEqual([{ t: 0, v: -9 }]);
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]);
});
});
describe("withoutLane", () => {
it("takes one lane and leaves the rest", () => {
// A node removed without its lane leaves an orphan driving a parameter that
@@ -17,6 +17,7 @@ import {
type HfAutomation,
} from "@hyperframes/core/audio-automation";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { mergeInsertPoint } from "../../player/components/automationLaneGeometry";
const EMPTY: HfAutomation = { version: 1, lanes: [] };
@@ -89,6 +90,20 @@ export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAu
};
}
/** Insert or replace the point nearest `t` on `target`'s lane — reuses the
* timeline envelope's own double-click merge rule, so a panel control with no
* drag gesture of its own can still write into an automated parameter the
* same way. */
export function withPointAt(
automation: HfAutomation,
target: string,
t: number,
v: number,
): HfAutomation {
const points = automation.lanes.find((lane) => lane.target === target)?.points ?? [];
return withLane(automation, { target, points: mergeInsertPoint(points, t, v) });
}
/** The attribute value for an automation set; empty when nothing is automated. */
export function automationAttrValue(automation: HfAutomation): string {
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
@@ -189,6 +189,41 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
act(() => root.unmount());
});
it("writes through the envelope instead of the attribute once volume is automated", () => {
const onSetAttribute = vi.fn();
const onCommitVolumeAt = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "1" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={vi.fn()}
volumeAutomated
onCommitVolumeAt={onCommitVolumeAt}
/>,
);
});
const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
Object.defineProperty(volumeTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
expect(onCommitVolumeAt).toHaveBeenCalledTimes(1);
expect(onCommitVolumeAt.mock.calls[0][0]).toBeCloseTo(3.981072, 6);
expect(onSetAttribute).not.toHaveBeenCalledWith("volume", expect.anything());
act(() => root.unmount());
});
it("commits a new rate value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement();
@@ -34,6 +34,8 @@ export function FlatMediaSection({
volumeAutomated,
onAutomateVolume,
onRemoveVolumeAutomation,
onCommitVolumeAt,
automatedVolumeValue,
}: {
projectDir: string | null;
element: DomEditSelection;
@@ -41,10 +43,12 @@ export function FlatMediaSection({
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
/** A volume lane in the timeline drives the level; the slider cannot. */
/** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
volumeAutomated?: boolean;
onAutomateVolume?: () => void;
onRemoveVolumeAutomation?: () => void;
onCommitVolumeAt?: (v: number) => void;
automatedVolumeValue?: number;
onRemoveBackground?: (
inputPath: string,
options: {
@@ -61,7 +65,13 @@ export function FlatMediaSection({
const isVisualMedia = isVideo || isImage;
const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
// While a lane owns the level, the envelope's live value at the playhead is
// what the slider must show — the static attribute is only what the engine
// falls back to outside automation.
const volume =
volumeAutomated && automatedVolumeValue !== undefined
? automatedVolumeValue
: (parseNumericValue(element.dataAttributes.volume ?? "") ?? 1);
const volumeFaderPosition = audioGainToFaderPosition(volume);
const mediaStart =
Number.parseFloat(
@@ -213,9 +223,9 @@ export function FlatMediaSection({
)}
{(isVideo || isAudio) && (
<>
{/* The slider is disabled while a lane owns the level: a value set
here would be overwritten by the envelope on the next tick. The
toggle beside it carries the tooltip. */}
{/* While a lane owns the level, a commit writes a keyframe at the
playhead instead of the plain attribute — same slider, same
gesture, the write just goes through the envelope. */}
<div
className="hf-volume-row flex items-center gap-1"
data-volume-automated={volumeAutomated ? "" : undefined}
@@ -228,11 +238,15 @@ export function FlatMediaSection({
max={AUDIO_GAIN_FADER_MAX}
tier={volume === 1 ? "default" : "explicitCustom"}
displayValue={audioGainToText(volume)}
disabled={volumeAutomated}
centerTick
onCommit={(next) =>
void onSetAttribute("volume", formatAudioGain(audioFaderPositionToGain(next)))
}
onCommit={(next) => {
const gain = audioFaderPositionToGain(next);
if (volumeAutomated) {
onCommitVolumeAt?.(gain);
} else {
void onSetAttribute("volume", formatAudioGain(gain));
}
}}
/>
</div>
<AutomationToggle
@@ -7,12 +7,15 @@ import type { DomEditSelection } from "./domEditingTypes";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function bind(dataAttributes: Record<string, string>) {
// The clip-local playhead time the hook derives is `currentTime - data-start`,
// clamped to `data-duration`: tests that care about it set both attributes.
function bind(dataAttributes: Record<string, string>, currentTime = 0) {
const onSetAttributeQuiet = vi.fn();
const captured: { current: VolumeAutomationBinding | null } = { current: null };
function Probe() {
captured.current = useVolumeAutomation(
{ dataAttributes } as unknown as DomEditSelection,
currentTime,
onSetAttributeQuiet,
);
return null;
@@ -29,6 +32,14 @@ function bind(dataAttributes: Record<string, string>) {
const volumeLane = (v: number) =>
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
const writtenAutomation = (onSetAttributeQuiet: ReturnType<typeof vi.fn>) =>
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1]));
const writtenVolumePoints = (onSetAttributeQuiet: ReturnType<typeof vi.fn>) =>
writtenAutomation(onSetAttributeQuiet).lanes.find(
(l: { target: string }) => l.target === "volume",
).points;
describe("useVolumeAutomation", () => {
it("reports an unautomated track", () => {
expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
@@ -61,7 +72,7 @@ describe("useVolumeAutomation", () => {
it("treats a missing data-volume as unity", () => {
const { binding, onSetAttributeQuiet } = bind({});
act(() => binding.onAutomateVolume());
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
});
it("keeps FX lanes when adding the volume one", () => {
@@ -72,9 +83,7 @@ describe("useVolumeAutomation", () => {
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
act(() => binding.onAutomateVolume());
expect(
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
(l: { target: string }) => l.target,
),
writtenAutomation(onSetAttributeQuiet).lanes.map((l: { target: string }) => l.target),
).toEqual(["fx.n1.frequency", "volume"]);
});
@@ -89,9 +98,7 @@ describe("useVolumeAutomation", () => {
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
act(() => binding.onRemoveVolumeAutomation());
expect(
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
(l: { target: string }) => l.target,
),
writtenAutomation(onSetAttributeQuiet).lanes.map((l: { target: string }) => l.target),
).toEqual(["fx.n1.frequency"]);
});
@@ -102,6 +109,70 @@ describe("useVolumeAutomation", () => {
expect(onSetAttributeQuiet).toHaveBeenCalledWith("data-automation", null);
});
it("writes a keyframe at the playhead instead of the flat attribute", () => {
// The slider used to be disabled while automated; this is what replaces
// that: a commit lands in the envelope, not on `data-volume`.
const { binding, onSetAttributeQuiet } = bind(
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
5,
);
act(() => binding.onCommitVolumeAt(0.7));
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
{ t: 0, v: 0.2 },
{ t: 5, v: 0.7 },
]);
});
it("clamps the playhead to the clip's own span, not the composition's", () => {
// A playhead past the clip's end must still land the keyframe at the
// clip's own last instant, not off the end of its automation lane.
const { binding, onSetAttributeQuiet } = bind(
{ volume: "0.4", automation: volumeLane(0.2), start: "10", duration: "4" },
50,
);
act(() => binding.onCommitVolumeAt(0.9));
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
{ t: 0, v: 0.2 },
{ t: 4, v: 0.9 },
]);
});
it("moves the existing point instead of stacking a new one when committing near it", () => {
const { binding, onSetAttributeQuiet } = bind(
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
0,
);
act(() => binding.onCommitVolumeAt(0.9));
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([{ t: 0, v: 0.9 }]);
});
it("reports the envelope's own value at the playhead, not the static attribute", () => {
const { binding } = bind(
{ volume: "0.4", automation: volumeLane(0.2), start: "0", duration: "10" },
0,
);
expect(binding.automatedVolumeValue).toBe(0.2);
});
it("translates to clip-local time, not composition time, for a clip that starts mid-timeline", () => {
// start=10 keeps this inside the clip's own duration either way, so the
// duration clamp can't mask a missing `currentTime - elStart` subtraction
// the way start=0 cases elsewhere in this file do.
const { binding, onSetAttributeQuiet } = bind(
{ volume: "0.4", automation: volumeLane(0.2), start: "10", duration: "20" },
15,
);
act(() => binding.onCommitVolumeAt(0.9));
expect(writtenVolumePoints(onSetAttributeQuiet)).toEqual([
{ t: 0, v: 0.2 },
{ t: 5, v: 0.9 },
]);
});
it("reports no automated value when the track is not automated", () => {
expect(bind({ volume: "0.4" }).binding.automatedVolumeValue).toBeUndefined();
});
it("reads an unreadable attribute as no automation", () => {
expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
});
@@ -111,12 +182,12 @@ describe("useVolumeAutomation", () => {
// engine read the same empty attribute as unity.
const { binding, onSetAttributeQuiet } = bind({ volume: "" });
act(() => binding.onAutomateVolume());
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
});
it("seeds at unity when data-volume is not a number", () => {
const { binding, onSetAttributeQuiet } = bind({ volume: "loud" });
act(() => binding.onAutomateVolume());
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
});
});
@@ -6,24 +6,36 @@
* rather than a second interpretation of the attribute.
*/
import { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
import {
HF_AUDIO_AUTOMATION_DATA_KEY,
sampleAutomationLane,
VOLUME_TARGET,
} from "@hyperframes/core/audio-automation";
import type { DomEditSelection } from "./domEditingTypes";
import {
automationAttrValue,
HF_AUDIO_AUTOMATION_ATTR,
readPanelAutomation,
withoutLane,
withPointAt,
withSeededLane,
} from "./propertyPanelAutomation";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { clampNumber } from "../../utils/studioHelpers";
export interface VolumeAutomationBinding {
volumeAutomated: boolean;
onAutomateVolume: () => void;
onRemoveVolumeAutomation: () => void;
/** Write `v` as a keyframe at the playhead instead of the disabled fallback. */
onCommitVolumeAt: (v: number) => void;
/** The envelope's own value at the playhead, so the slider tracks it live. */
automatedVolumeValue: number | undefined;
}
export function useVolumeAutomation(
element: DomEditSelection,
currentTime: number,
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
): VolumeAutomationBinding {
// The chain is not needed to resolve a volume lane — volume is always a valid
@@ -32,6 +44,12 @@ export function useVolumeAutomation(
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
undefined,
);
// ponytail: no GSAP animations passed — a media clip always carries an
// explicit data-duration, so `deriveElementTiming` never infers from animations here.
// `currentTime` comes from the caller's own `useLivePlayheadTime()` — a second
// subscription here would race the same re-render the caller already triggers.
const { start: elStart, duration: elDuration } = deriveElementTiming(element);
const clipTimeSec = clampNumber(currentTime - elStart, 0, elDuration > 0 ? elDuration : Infinity);
const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
// Quiet: clicking the toggle used to reload the preview and restart every
// playing track, while the same click on an effect parameter did not.
@@ -43,12 +61,15 @@ export function useVolumeAutomation(
const raw = element.dataAttributes?.["volume"];
const parsed = raw ? Number(raw) : 1;
const current = Number.isFinite(parsed) ? parsed : 1;
const volumeLane = automation.lanes.find((lane) => lane.target === VOLUME_TARGET);
return {
volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
volumeAutomated: volumeLane !== undefined,
automatedVolumeValue: volumeLane ? sampleAutomationLane(volumeLane, clipTimeSec) : undefined,
// Seeded at the level the slider already shows, so automating the track does
// not change how loud it is.
onAutomateVolume: () =>
write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
onCommitVolumeAt: (v: number) => write(withPointAt(automation, VOLUME_TARGET, clipTimeSec, v)),
};
}
@@ -13,7 +13,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { getTimelineLaneTop } from "./timelineLayout";
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
@@ -174,7 +174,9 @@ export function TimelineAutomationLaneSlot({
currentTime,
beatTimes,
}: TimelineAutomationLaneSlotProps) {
const clips = elements.filter(isAudioTimelineElement);
// Broader than a raw-attribute check — a clip mid-edit (see the stale-
// selection test) has lanes in its live binding before the attribute commits.
const clips = elements.filter(isAudioOrVideoTimelineElement);
const rowsByClip = new Map<string, ClipLaneRow[]>();
groupAutomationLanes(clips).forEach((group, rowIndex) => {
for (const entry of group.entries) {
@@ -260,9 +260,17 @@ describe("groupAutomationLanes", () => {
expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
});
it("ignores clips that are not audio, the way the reserved height does", () => {
const video = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
expect(groupAutomationLanes([video])).toEqual([]);
it("draws lanes for a video element, matching the reserved height", () => {
// A <video>'s volume can be automated from the property panel same as an
// audio clip's; gating this on `isAudioTimelineElement` alone hid every
// keyframe the panel wrote for a video clip.
const video = el({ id: "titles", key: "titles", tag: "video", automation: lanesOf("volume") });
expect(groupAutomationLanes([video]).map((g) => g.key)).toEqual(["Volume"]);
});
it("ignores a clip that is neither audio nor video, the way the reserved height does", () => {
const div = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
expect(groupAutomationLanes([div])).toEqual([]);
});
it("skips a target that does not resolve against its clip's chain", () => {
@@ -22,7 +22,7 @@ import {
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
import type { TimelineElement } from "../store/playerStore";
const EMPTY: HfAutomation = { version: 1, lanes: [] };
@@ -245,13 +245,14 @@ export interface AutomationLaneGroup {
* spectrum, top down), so the first clip to carry a property fixes its row and
* later clips only append properties nobody has shown yet.
*
* Non-audio elements contribute nothing, matching `automationLaneCountOf` — the
* row's reserved height and its drawn lanes have to count the same clips.
* Gated on `isAudioOrVideoTimelineElement`, the same predicate `automationLaneCountOf`
* and `TimelineAutomationLaneSlot`'s clip filter use — the row's reserved height
* and its drawn lanes have to count the same clips.
*/
export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
const groups = new Map<string, AutomationLaneGroup>();
for (const element of elements) {
if (!isAudioTimelineElement(element)) continue;
if (!isAudioOrVideoTimelineElement(element)) continue;
const chain = elementFxChain(element);
for (const lane of elementAutomationLanes(element)) {
const key = laneGroupKey(lane.target, chain);
@@ -23,6 +23,21 @@ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
/** Points nearer than this in clip seconds are the same point, not two. */
export const POINT_MERGE_SEC = 0.02;
/** Insert or replace the point nearest `t` — the one merge/seed rule every
* hand-editing gesture on a lane uses (timeline double-click, panel control). */
export function mergeInsertPoint(
points: readonly HfAutomationLane["points"][number][],
t: number,
v: number,
): HfAutomationLane["points"] {
const kept = points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
// A lane's first point alone would be a constant, which is not what
// committing to an empty lane means: seed the far end at the same value so
// the envelope has somewhere to go.
const seeded = points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
return [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t);
}
/**
* Closest two breakpoints may sit in clip seconds while still being two points.
*
@@ -13,7 +13,7 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
import { curveForDrag, formatValue, GRAB_PX, POINT_MERGE_SEC } from "./automationLaneGeometry";
import { curveForDrag, formatValue, GRAB_PX, mergeInsertPoint } from "./automationLaneGeometry";
import { pointInSelection } from "./automationLaneSelection";
import { capturePointer } from "./automationLanePointer";
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
@@ -548,15 +548,7 @@ export function useAutomationLaneGestures({
return;
}
const { t, v } = pointAt(e.clientX, e.clientY);
const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
// A lane's first point alone would be a constant, which is not what
// clicking an empty lane means: seed the far end at the same value so the
// envelope has somewhere to go.
const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
commitPoints(
[...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
true,
);
commitPoints(mergeInsertPoint(lane.points, t, v), true);
},
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
);
@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationLaneGroups } from "./TimelinePropertyLanes";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -44,14 +44,15 @@ export function trackShowsBeatStrip(
}
/**
* Automation lanes on one clip, or 0 for anything that is not audio.
* Automation lanes on one clip.
*
* An audio clip can be worth expanding without carrying a single tween, so this
* counts toward whether a track has anything to disclose. A function rather than
* a map so every caller reads the same cached parse and none can drift.
* A clip can be worth expanding without carrying a single tween, so this counts
* toward whether a track has anything to disclose. Gated on
* `isAudioOrVideoTimelineElement`, matching `groupAutomationLanes`. A function
* rather than a map so every caller reads the same cached parse and none can drift.
*/
function automationLaneCountOf(element: TimelineElement): number {
return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
return isAudioOrVideoTimelineElement(element) ? elementAutomationLanes(element).length : 0;
}
/**
@@ -34,6 +34,16 @@ export function isAudioTimelineElement(
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
}
/** The two tags the property panel lets you put a volume automation lane on.
* Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
* `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
export function isAudioOrVideoTimelineElement(
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
): boolean {
if (!element) return false;
return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
}
/** True for the music track: an audio element with data-timeline-role="music",
* or — when no role is set — an id matching the music regex. Voiceover/other
* audio (explicit non-music role) is excluded. */