mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
fix(studio): close four gaps found against the rendered designs
Until now I had only the ASCII stand-ins in `plans/audio-mixer-groups.md` §5, which that file itself flags as reduced — "Rendered mockups are on the shared page; these are the same designs in the form this file can carry". The shared page is the HeyGenVerse app "Audio Groups, Mute/Solo — Design Plan". Read against it, four things were wrong or missing: **A muted group's name is not struck through.** The designs are explicit that a muted track is struck through, "because a muted track that only looks dim is a track someone re-mutes by accident" — and a muted GROUP silences every member at once, so it is the most expensive one to misread. Plain track rows already did this; the group header did not. **A group's automation lanes had no label column.** The curve rendered on the canvas with nothing naming it. The designs draw `▤ Volume 0.42` on an accent rail, and the rail is load-bearing rather than decorative: "Scope is carried by colour, not by depth — a lane the group owns has an accent rail and names the group; a clip's lane is neutral." Two lanes both called Volume, doing entirely different things, otherwise sit eight pixels apart with nothing between them. **The number has to be the value at the playhead.** Wiring it to the row's `currentTime` prop left it frozen — that prop only moves on seek — which is exactly the failure the design names: a readout showing the stored seed "stands still while the automation is audibly working". It reads the live playhead now. Verified across the curve: 0.99 at t=0, 0.85 at the trough, 1.00 at t=20. **The rack's IN/OUT copy was the ASCII's, not the design's.** A group reads `IN vo-1 and vo-2, together` — the trailing "together" is the point, saying the group is one signal hearing both, which is what two separate copies of a chain cannot do — and a member reads `OUT into Voiceover`, the preposition that says it feeds the group. I had built `vo-1, vo-2` and `to Voiceover` from the ASCII. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4343 green.
This commit is contained in:
@@ -13,9 +13,12 @@ const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
|
|||||||
|
|
||||||
describe("audioFxSignalPath", () => {
|
describe("audioFxSignalPath", () => {
|
||||||
// The design doc's §5 mockup, both columns.
|
// The design doc's §5 mockup, both columns.
|
||||||
|
// Copy taken from the rendered designs, which are more specific than the
|
||||||
|
// ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
|
||||||
|
// Voiceover", not "vo-1, vo-2" / "to Voiceover".
|
||||||
it("names what a group sums, and sends it to the mix", () => {
|
it("names what a group sums, and sends it to the mix", () => {
|
||||||
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
|
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
|
||||||
inLabel: "vo-1, vo-2",
|
inLabel: "vo-1 and vo-2, together",
|
||||||
outLabel: "to mix",
|
outLabel: "to mix",
|
||||||
subject: "group",
|
subject: "group",
|
||||||
});
|
});
|
||||||
@@ -24,7 +27,7 @@ describe("audioFxSignalPath", () => {
|
|||||||
it("names the group a member feeds, so routing reads from either end", () => {
|
it("names the group a member feeds, so routing reads from either end", () => {
|
||||||
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
|
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
|
||||||
inLabel: "this track",
|
inLabel: "this track",
|
||||||
outLabel: "to Voiceover",
|
outLabel: "into Voiceover",
|
||||||
subject: "track",
|
subject: "track",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
|
|||||||
* `groups` is the resolved set from the composition; `elementId` and `tag` come
|
* `groups` is the resolved set from the composition; `elementId` and `tag` come
|
||||||
* from the selection. Pure so the labels can be asserted without a DOM.
|
* from the selection. Pure so the labels can be asserted without a DOM.
|
||||||
*/
|
*/
|
||||||
|
/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
|
||||||
|
function joinNatural(items: readonly string[]): string {
|
||||||
|
if (items.length <= 1) return items[0] ?? "";
|
||||||
|
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function audioFxSignalPath(
|
export function audioFxSignalPath(
|
||||||
tag: string | undefined,
|
tag: string | undefined,
|
||||||
elementId: string | undefined,
|
elementId: string | undefined,
|
||||||
@@ -49,11 +55,17 @@ export function audioFxSignalPath(
|
|||||||
// making one, so it must not look like a bug.
|
// making one, so it must not look like a bug.
|
||||||
const members = group?.memberIds ?? [];
|
const members = group?.memberIds ?? [];
|
||||||
return {
|
return {
|
||||||
inLabel: members.length > 0 ? members.join(", ") : "nothing yet",
|
// "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
|
||||||
|
// comma list. The trailing "together" is the point: it says the group is
|
||||||
|
// ONE signal hearing both, which is the thing two separate copies of a
|
||||||
|
// chain cannot do, and it says it without "sum" or "bus".
|
||||||
|
inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet",
|
||||||
outLabel: "to mix",
|
outLabel: "to mix",
|
||||||
subject: "group",
|
subject: "group",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
|
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
|
||||||
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `to ${owner.label}` } : CLIP_SIGNAL_PATH;
|
// "into Voiceover", not "to" — a member feeds the group, and the design uses
|
||||||
|
// the preposition that says so.
|
||||||
|
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,13 @@ export function TimelineGroupHeader({
|
|||||||
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
||||||
▤
|
▤
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
{/* Struck through, not merely dimmed — the designs are explicit that "a
|
||||||
|
muted track that only looks dim is a track someone re-mutes by
|
||||||
|
accident", and a muted GROUP silences every member at once, so it is
|
||||||
|
the most expensive one to misread. */}
|
||||||
|
<span className={`min-w-0 flex-1 truncate font-medium${hidden ? " line-through" : ""}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* The label column beside a group's own automation lanes.
|
||||||
|
*
|
||||||
|
* The designs draw a group lane as `▤ Volume 0.42` on an accent rail — and
|
||||||
|
* the rail is load-bearing, not decoration: "Scope is carried by colour, not by
|
||||||
|
* depth — a lane the group owns has an accent rail and names the group; a
|
||||||
|
* clip's lane is neutral." Two lanes both called Volume, doing entirely
|
||||||
|
* different things, otherwise sit eight pixels apart with nothing to tell them
|
||||||
|
* apart.
|
||||||
|
*
|
||||||
|
* The number is the value AT THE PLAYHEAD, not the stored seed: a readout that
|
||||||
|
* showed the seed would stand still while the curve is audibly working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sampleAutomationLane } from "@hyperframes/core/audio-automation";
|
||||||
|
import { automationLaneLabelParts, elementAutomation, elementFxChain } from "./automationLaneData";
|
||||||
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||||
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
|
||||||
|
|
||||||
|
export function TimelineGroupLaneLabels({
|
||||||
|
groupElement,
|
||||||
|
groupLabel,
|
||||||
|
top,
|
||||||
|
columnWidth,
|
||||||
|
gutterBackground,
|
||||||
|
accentColor,
|
||||||
|
}: {
|
||||||
|
/** The group wearing a clip's shape — see `groupAutomationElement`. */
|
||||||
|
groupElement: TimelineElement;
|
||||||
|
groupLabel: string;
|
||||||
|
/** y of the first lane, matching the canvas slot's own offset. */
|
||||||
|
top: number;
|
||||||
|
columnWidth: number;
|
||||||
|
gutterBackground: string;
|
||||||
|
accentColor: string;
|
||||||
|
}) {
|
||||||
|
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
|
||||||
|
// on seek, so the readout sat frozen while the curve was audibly working,
|
||||||
|
// which is precisely the failure this number exists to prevent.
|
||||||
|
const currentTime = useLivePlayheadTime();
|
||||||
|
const chain = elementFxChain(groupElement);
|
||||||
|
const lanes = elementAutomation(groupElement).lanes;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lanes.map((lane, index) => {
|
||||||
|
const parts = automationLaneLabelParts(lane.target, chain);
|
||||||
|
if (!parts) return null;
|
||||||
|
// A group's clock is composition time (§1.3), so the playhead needs no
|
||||||
|
// clip-local rebase here — unlike a clip's lane.
|
||||||
|
const value = sampleAutomationLane(lane, currentTime);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lane.target}
|
||||||
|
data-group-lane-label={lane.target}
|
||||||
|
className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||||
|
style={{
|
||||||
|
top: top + index * AUTOMATION_LANE_H,
|
||||||
|
width: columnWidth,
|
||||||
|
height: AUTOMATION_LANE_H,
|
||||||
|
background: gutterBackground,
|
||||||
|
borderLeft: `2px solid ${accentColor}`,
|
||||||
|
}}
|
||||||
|
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
|
||||||
|
▤
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
|
||||||
|
<span className="truncate font-mono text-[9px] text-white/70">{parts.name}</span>
|
||||||
|
{parts.param ? (
|
||||||
|
<span className="truncate font-mono text-[9px] text-white/40">{parts.param}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
|
||||||
|
{value.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
|||||||
import { groupAutomationLanes } from "./automationLaneData";
|
import { groupAutomationLanes } from "./automationLaneData";
|
||||||
import { groupAutomationElement } from "./groupAutomationElement";
|
import { groupAutomationElement } from "./groupAutomationElement";
|
||||||
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
|
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
|
||||||
|
import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
|
||||||
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
||||||
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
import type { UseAutomationLanesResult } from "./useAutomationLanes";
|
||||||
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
|
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
|
||||||
@@ -202,6 +203,19 @@ export function TimelineGroupRow({
|
|||||||
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
||||||
clip's: the binder writes through the dom-edit selection, so a lane is
|
clip's: the binder writes through the dom-edit selection, so a lane is
|
||||||
editable once the group is selected — which clicking its name does. */}
|
editable once the group is selected — which clicking its name does. */}
|
||||||
|
{/* The label column for those lanes, on the accent rail. Outside the
|
||||||
|
offset content cell below, because the labels belong to the sticky
|
||||||
|
gutter the row header occupies, not to the scrolling canvas. */}
|
||||||
|
{isLaneOpen && (
|
||||||
|
<TimelineGroupLaneLabels
|
||||||
|
groupElement={groupElement}
|
||||||
|
groupLabel={group.label}
|
||||||
|
top={TRACK_H + STRIP_H}
|
||||||
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||||
|
gutterBackground={theme.gutterBackground}
|
||||||
|
accentColor={GROUP_LANE_ACCENT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isLaneOpen && (
|
{isLaneOpen && (
|
||||||
// The same offset content cell a track row wraps its lanes in — the
|
// The same offset content cell a track row wraps its lanes in — the
|
||||||
// slot positions absolutely, so mounted straight on the row it resolved
|
// slot positions absolutely, so mounted straight on the row it resolved
|
||||||
|
|||||||
Reference in New Issue
Block a user