mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
revert(studio): pull the audition surface back to what B7 and C1 specify
Re-read `plans/audio-mixer-groups.md` and `plans/audio-execution/{B7,C1}.md`
against what this session actually shipped. Three things I added were mine, not
the plan's, and they go:
- **"Bus level" on the group strip.** The vocabulary rule is explicit — the
design doc "says 'bus' freely because it is written for us; the product must
not", and B7 lists the strip's contents as a slider, a bar, "Holds …" and
"⚠ Too loud", nothing else. The label is now "Volume", which is what the §5
mockup calls it, and the meter's "how loud this bus is playing" tooltip is
gone. "Too loud" regains the ⚠ the mockup gives it.
- **The `silentReason` warning banner.** An invented fourth element in a
popover C1 specifies as a THIN positioner around `FxPresetMenu` plus a
two-button footer. Removed from all five files it had been threaded through.
- **The audition's playhead jump (`auditionStart` + `auditionSpans`).** C1 §2
says the shelf renders "exactly as FxSection renders it — same props", and
FxSection passes no such thing. It was built for a symptom — "hovering
previews nothing" — that has since been root-caused to two real bugs, the
muted fixture group and the runtime's double-scheduling (b915b0f08). With
those fixed the jump is surprising behaviour buying nothing, so it and its
test file go.
Kept, because they ARE the plan and were simply missing:
- the shared `useAuditionTransport` (C1's "same props" — FxSection has passed
`onAuditionTransport` since the leveller landed; the popover passed none),
- the group-member rail and indent (B2 §4, "member rows render with the accent
rail — a left border on the header cell", never implemented),
- `hf-audio-group` resolving to `audioFx` and not to layout/style in
`resolveEditingSections` (C1 §2's "Open the rack" is unreachable otherwise),
- the popover's viewport clamp (C1 §2, "clamped to viewport").
Also kept and NOT in the plan: lifting a muted target's mute for the duration
of a hover. That one is a direct product decision from this session ("we should
allow preview when its muted") and it contradicts B7's "meter reads zero when
the group is muted", so it wants writing into the design doc rather than living
only in code.
Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean, studio suite 4323 green.
This commit is contained in:
@@ -58,10 +58,6 @@ export interface TimelineFxPopoverProps {
|
||||
/** Select the target the way clicking it in the timeline does, and ensure
|
||||
* the property panel's Audio FX group is expanded. */
|
||||
onOpenRack: () => void;
|
||||
/** Why hovering a preset here will make no sound — a muted bus, a track
|
||||
* silenced by someone else's solo. Without this the shelf auditions into
|
||||
* silence and reads as broken rather than as muted. */
|
||||
silentReason?: string | null;
|
||||
}
|
||||
|
||||
export function TimelineFxPopover({
|
||||
@@ -73,7 +69,6 @@ export function TimelineFxPopover({
|
||||
onChainPreview,
|
||||
onAuditionTransport,
|
||||
onOpenRack,
|
||||
silentReason,
|
||||
}: TimelineFxPopoverProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { audition, clearAudition, storedChain } = useFxAudition(
|
||||
@@ -120,11 +115,6 @@ export function TimelineFxPopover({
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{silentReason ? (
|
||||
<p className="mb-1.5 shrink-0 rounded-[3px] bg-[#F5C542]/10 px-1.5 py-1 text-[10px] text-[#F5C542]">
|
||||
{silentReason}
|
||||
</p>
|
||||
) : null}
|
||||
{/* The list scrolls; the footer below stays put. `min-h-0` is load-bearing
|
||||
— a flex child defaults to min-height:auto and would refuse to shrink,
|
||||
pushing the footer out of the popover instead of scrolling. */}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { auditionStart } from "./useAuditionTransport";
|
||||
|
||||
const SPANS = [
|
||||
{ start: 2, duration: 7 },
|
||||
{ start: 18, duration: 7 },
|
||||
];
|
||||
|
||||
describe("auditionStart", () => {
|
||||
// Nothing to aim at — the property panel's rack passes no spans, and it keeps
|
||||
// its old behaviour: play from wherever the author left the playhead.
|
||||
it("stays put when there are no spans", () => {
|
||||
expect(auditionStart(undefined, 0)).toBeNull();
|
||||
expect(auditionStart([], 0)).toBeNull();
|
||||
});
|
||||
|
||||
// Already inside the clip: moving the playhead here would be the UI taking a
|
||||
// decision it was not asked for, and it would cost the author their place for
|
||||
// no gain.
|
||||
it("stays put when the playhead is already inside a span", () => {
|
||||
expect(auditionStart(SPANS, 2)).toBeNull();
|
||||
expect(auditionStart(SPANS, 8.9)).toBeNull();
|
||||
});
|
||||
|
||||
// The bug this exists for: hovering a preset at 0:00 on a group whose members
|
||||
// start at 0:02 played silence under the effect.
|
||||
it("jumps to the next span when the playhead is before or between them", () => {
|
||||
expect(auditionStart(SPANS, 0)).toBe(2);
|
||||
expect(auditionStart(SPANS, 9)).toBe(18);
|
||||
});
|
||||
|
||||
// Past everything, wrap to the first rather than play out the tail in silence.
|
||||
it("wraps to the first span when the playhead is past them all", () => {
|
||||
expect(auditionStart(SPANS, 40)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,8 @@
|
||||
* while the transport is paused — so a paused author hovering a preset heard
|
||||
* nothing at all and the affordance only worked mid-playback. Extracted from
|
||||
* `useFxLevelling`, where the property panel's rack owned it privately, because
|
||||
* the timeline's FX popover needs exactly the same behaviour and had none: its
|
||||
* two call sites passed a preview channel and no transport, so hovering there
|
||||
* was silent by construction.
|
||||
* the timeline's FX popover must render the preset shelf "exactly as FxSection
|
||||
* renders it — same props" (runbook C1 §2) and was passing no transport at all.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
@@ -15,33 +14,7 @@ import { useRef } from "react";
|
||||
// timeline in, and the timeline's FX button imports this hook — a cycle.
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
|
||||
/** A clip the audition is meant to be heard through. */
|
||||
export interface AuditionSpan {
|
||||
start: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to start playing so the preset is actually audible, or null to stay put.
|
||||
*
|
||||
* Playing "from the playhead" is only useful when the thing being auditioned is
|
||||
* sounding there. A group whose members start at 0:02 and a clip parked at 0:36
|
||||
* both play silence under a preset hovered at 0:00 — the transport runs, the
|
||||
* chain is in the graph, and the author hears the rest of the mix unchanged,
|
||||
* which reads as the audition being broken. So: inside a span, stay; otherwise
|
||||
* jump to the next one, or wrap to the first when the playhead is past them all.
|
||||
*/
|
||||
export function auditionStart(
|
||||
spans: readonly AuditionSpan[] | undefined,
|
||||
at: number,
|
||||
): number | null {
|
||||
if (!spans || spans.length === 0) return null;
|
||||
if (spans.some((span) => at >= span.start && at < span.start + span.duration)) return null;
|
||||
const starts = spans.map((span) => span.start).sort((a, b) => a - b);
|
||||
return starts.find((start) => start > at) ?? starts[0] ?? null;
|
||||
}
|
||||
|
||||
export function useAuditionTransport(): (on: boolean, spans?: readonly AuditionSpan[]) => void {
|
||||
export function useAuditionTransport(): (on: boolean) => void {
|
||||
/**
|
||||
* Where the playhead was when an audition started the transport, so leaving
|
||||
* can put it back. Null means this audition did not start playback — the
|
||||
@@ -54,15 +27,11 @@ export function useAuditionTransport(): (on: boolean, spans?: readonly AuditionS
|
||||
* that, and stopping their transport because they passed over a preset would
|
||||
* be the UI taking a decision that was not offered to it.
|
||||
*/
|
||||
return (on: boolean, spans?: readonly AuditionSpan[]): void => {
|
||||
return (on: boolean): void => {
|
||||
const store = usePlayerStore.getState();
|
||||
if (on) {
|
||||
if (store.isPlaying || auditionReturn.current !== null) return;
|
||||
auditionReturn.current = store.currentTime;
|
||||
// Recorded first, so leaving returns to where the author actually was
|
||||
// rather than to the clip this jumped to.
|
||||
const from = auditionStart(spans, store.currentTime);
|
||||
if (from !== null) store.requestSeek(from);
|
||||
store.requestPlayback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
||||
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
|
||||
import {
|
||||
useAuditionTransport,
|
||||
type AuditionSpan,
|
||||
} from "../../components/editor/useAuditionTransport.js";
|
||||
import { useAuditionTransport } from "../../components/editor/useAuditionTransport.js";
|
||||
|
||||
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
|
||||
if (!raw) return { version: 1, nodes: [] };
|
||||
@@ -39,12 +36,6 @@ interface TimelineFxButtonChainProps {
|
||||
fxChainRaw: string | undefined;
|
||||
onChainChange: (next: HfAudioFxChain) => void;
|
||||
onChainPreview?: (next: HfAudioFxChain) => void;
|
||||
/** The clips this chain is heard through, so an audition can start where they
|
||||
* actually sound instead of playing silence from a playhead parked before
|
||||
* the first one. */
|
||||
auditionSpans?: readonly AuditionSpan[];
|
||||
/** Why an audition here will be silent (excluded by someone else's solo). */
|
||||
silentReason?: string | null;
|
||||
/** Whether this target is muted right now. */
|
||||
isMuted?: boolean;
|
||||
/** Set this target's mute on the running graph WITHOUT touching the document,
|
||||
@@ -161,10 +152,9 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
||||
if (on) borrowedMute.current = props.isMuted === true;
|
||||
if (borrowedMute.current) props.onSetMutedLive?.(!on);
|
||||
if (!on) borrowedMute.current = false;
|
||||
transport(on, props.auditionSpans);
|
||||
transport(on);
|
||||
}}
|
||||
onOpenRack={props.onOpenRack}
|
||||
silentReason={props.silentReason}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -73,10 +73,10 @@ export function TimelineGroupBusStrip({
|
||||
style={{ top: TRACK_H, height: STRIP_H }}
|
||||
>
|
||||
{/* Named, because unnamed it reads as an unexplained slider next to an
|
||||
empty capsule: the row opens off a control labelled "lanes", so the
|
||||
first question it has to answer is what it IS. Two words, no dB and no
|
||||
numeric readout — that part of the casual-user rule stands. */}
|
||||
<span className="shrink-0 text-white/45">Bus level</span>
|
||||
empty capsule. "Volume" is the design mockup's own label (groups doc
|
||||
§5) — B7's list is slider, bar, "Holds …", "⚠ Too loud" and NOTHING
|
||||
else, and the vocabulary rule bans "bus" from the product outright. */}
|
||||
<span className="shrink-0 text-white/45">Volume</span>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Group volume"
|
||||
@@ -99,9 +99,6 @@ export function TimelineGroupBusStrip({
|
||||
<div
|
||||
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
||||
style={{ background: theme.gutterBorder }}
|
||||
// Empty while the transport is stopped, which is when somebody is most
|
||||
// likely to be wondering what it is.
|
||||
title="How loud this bus is playing right now"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
@@ -112,7 +109,7 @@ export function TimelineGroupBusStrip({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
||||
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">⚠ Too loud</span>}
|
||||
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
||||
{holdsText}
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
|
||||
|
||||
interface TimelineGroupHeaderProps {
|
||||
label: string;
|
||||
@@ -28,10 +27,6 @@ interface TimelineGroupHeaderProps {
|
||||
fxChain?: string;
|
||||
onFxChainChange: (next: HfAudioFxChain) => void;
|
||||
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
||||
/** Member clips, so hovering a preset auditions where the group sounds. */
|
||||
auditionSpans?: readonly AuditionSpan[];
|
||||
/** Why an audition here will be silent (excluded by someone else's solo). */
|
||||
silentReason?: string | null;
|
||||
/** Set the group mute on the running graph only, so an audition can lift it. */
|
||||
onSetMutedLive?: (muted: boolean) => void;
|
||||
onOpenFxRack: () => void;
|
||||
@@ -59,8 +54,6 @@ export function TimelineGroupHeader({
|
||||
fxChain,
|
||||
onFxChainChange,
|
||||
onFxChainPreview,
|
||||
auditionSpans,
|
||||
silentReason,
|
||||
onSetMutedLive,
|
||||
onOpenFxRack,
|
||||
columnWidth,
|
||||
@@ -156,8 +149,6 @@ export function TimelineGroupHeader({
|
||||
fxChainRaw={fxChain}
|
||||
onChainChange={onFxChainChange}
|
||||
onChainPreview={onFxChainPreview}
|
||||
auditionSpans={auditionSpans}
|
||||
silentReason={silentReason}
|
||||
isMuted={hidden}
|
||||
onSetMutedLive={onSetMutedLive}
|
||||
onOpenRack={onOpenFxRack}
|
||||
|
||||
@@ -86,12 +86,6 @@ export function TimelineGroupRow({
|
||||
// reading (and rendering) as muted throughout.
|
||||
const setGroupMutedLive = (muted: boolean) =>
|
||||
onSetAudioGroupAttributeLive?.(group.id, "data-hidden", muted ? "" : null);
|
||||
// Solo is not ours to borrow the same way — it is a statement about every
|
||||
// other track, and lifting it would silence the one the author soloed. Say so
|
||||
// instead, or the shelf auditions into silence and reads as broken.
|
||||
const silencedBySolo =
|
||||
soloed.size > 0 && !soloed.has(group.id) && !memberIds.some((id) => soloed.has(id));
|
||||
const silentReason = silencedBySolo ? "Another track is soloed — presets here are silent." : null;
|
||||
const openGroupFxRack = () => {
|
||||
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
|
||||
group.id,
|
||||
@@ -139,8 +133,6 @@ export function TimelineGroupRow({
|
||||
fxChain={group.fxChain}
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
auditionSpans={memberElements}
|
||||
silentReason={silentReason}
|
||||
onSetMutedLive={setGroupMutedLive}
|
||||
onOpenFxRack={openGroupFxRack}
|
||||
// Same width as every other row's header. The group row needs a real
|
||||
|
||||
@@ -490,23 +490,13 @@ export function TimelineTrackHeader({
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
||||
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
|
||||
auditionSpans={[singleAudioClip]}
|
||||
// Mute is borrowed for the hover and put back (see the group row);
|
||||
// solo is not ours to lift, so it gets said out loud instead.
|
||||
// Muted, an audition is silent — so the hover lifts the mute on
|
||||
// the running graph and puts it back on the way out, the same
|
||||
// borrow-and-return it already does with the playhead.
|
||||
isMuted={isTrackHidden}
|
||||
onSetMutedLive={(muted) =>
|
||||
onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)
|
||||
}
|
||||
// The clip's own mute is borrowed above. A mute that lives
|
||||
// somewhere else — the bus this clip hangs off, or another
|
||||
// track's solo — is not this row's to lift, so it gets said.
|
||||
silentReason={
|
||||
singleAudioClip.audioGroupHidden
|
||||
? "This clip's group is muted — unmute the group to hear presets."
|
||||
: soloed.size > 0 && !(soloTargetId !== null && soloed.has(soloTargetId))
|
||||
? "Another track is soloed — presets here are silent."
|
||||
: null
|
||||
}
|
||||
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user