mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio,core): reach presets and the rack from the timeline
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ba1d807621
commit
071dcfe90d
@@ -1,7 +1,17 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import {
|
||||
HF_AUDIO_FX_ATTR,
|
||||
serializeAudioFxChain,
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { classifyAudioName } from "@hyperframes/core/audio-carve";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
||||
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
@@ -380,6 +390,31 @@ export function TimelineTrackHeader({
|
||||
const soloed = usePlayerStore((s) => s.soloed);
|
||||
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||
|
||||
// C1: the FX entry point. A single audio clip has one chain to point at; a
|
||||
// track holding several ungrouped ones has no single chain — the design
|
||||
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
||||
// at grouping (B6's normative rule) instead of a popover.
|
||||
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
||||
useTimelineEditContextOptional();
|
||||
const domEditActions = useDomEditActionsContextOptional();
|
||||
const singleAudioClip =
|
||||
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
||||
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
||||
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
|
||||
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
||||
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
||||
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
};
|
||||
const openClipFxRack = (clip: TimelineElement) => {
|
||||
void domEditActions?.handleTimelineElementSelect(clip);
|
||||
};
|
||||
const groupUngroupedClips = () => {
|
||||
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
|
||||
if (!doc || !onGroupClips) return;
|
||||
const clipIds = trackElements.map((el) => el.key ?? el.id);
|
||||
void onGroupClips(clipIds, mintGroupId(doc));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="rowheader"
|
||||
@@ -398,19 +433,34 @@ export function TimelineTrackHeader({
|
||||
}}
|
||||
>
|
||||
{!keyframeClip || !disclosable ? (
|
||||
<PlainTrackHeader
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
trackLabel={trackLabel}
|
||||
clipCount={clipCount}
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
<>
|
||||
<PlainTrackHeader
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
trackLabel={trackLabel}
|
||||
clipCount={clipCount}
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
{singleAudioClip && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
||||
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
|
||||
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
||||
/>
|
||||
)}
|
||||
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
|
||||
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayerDisclosureRow
|
||||
|
||||
Reference in New Issue
Block a user