fix(studio): make clip peak warnings plain and hoverable (#5099)

* fix(studio): make clip peak warnings plain and hoverable

* fix(studio): expose peak detail on clip focus above waveforms

* fix(studio): preserve shared badge styling and track test identity
This commit is contained in:
Miguel Ángel
2026-10-06 04:11:06 +00:00
committed by GitHub
parent 00342fc2be
commit 4905b4dbca
7 changed files with 111 additions and 30 deletions
+10 -3
View File
@@ -14,6 +14,7 @@ interface TooltipProps {
children: ReactElement<{ "aria-describedby"?: string }>;
/** Hover delay in ms. */
delay?: number;
disabled?: boolean;
side?: "top" | "bottom" | "left" | "right";
}
@@ -21,16 +22,22 @@ interface TooltipProps {
const SIDE_OFFSET = 6;
const VIEWPORT_MARGIN = 8;
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
export function Tooltip({
label,
children,
delay = 400,
side = "top",
disabled = false,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLSpanElement>(null);
const tooltipId = useId();
return (
<BaseTooltip.Root open={open} onOpenChange={setOpen}>
<BaseTooltip.Root open={open} onOpenChange={setOpen} disabled={disabled}>
<BaseTooltip.Trigger delay={delay} render={<span ref={boxRef} className="contents" />}>
{cloneElement(children, {
"aria-describedby": open ? tooltipId : children.props["aria-describedby"],
"aria-describedby": open && !disabled ? tooltipId : children.props["aria-describedby"],
})}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
@@ -3,6 +3,9 @@ import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { createHappyDomRootHarness } from "./testRootHarness";
import { ClipPeakMarks } from "./ClipPeakMarks";
import { TimelineClip } from "./TimelineClip";
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const harness = createHappyDomRootHarness();
@@ -13,29 +16,70 @@ async function render(url: string, bins: number[], gain: number) {
);
const host = document.createElement("div");
document.body.appendChild(host);
await act(async () => {
harness.mount(host).render(
<ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={gain}>
const root = harness.mount(host);
const view = (volume: number) => (
<TimelineClip
el={{ id: "audio", tag: "audio", start: 0, duration: 2, track: 0 }}
pps={16}
clipY={0}
isSelected={false}
isHovered={false}
hasCustomContent
capabilities={{ canMove: true, canTrimStart: true, canTrimEnd: true }}
isComposition={false}
tabIndex={0}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
<ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={volume}>
<span>wave</span>
</ClipPeakMarks>,
);
});
</ClipPeakMarks>
</TimelineClip>
);
await act(async () => root.render(view(gain)));
await act(async () => {});
vi.unstubAllGlobals();
return host;
return { host, setGain: async (value: number) => act(async () => root.render(view(value))) };
}
describe("ClipPeakMarks", () => {
it("paints red marks and the peak on a clip that redlines", async () => {
const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
it("uses a plain warning and describes the focused clip with the numeric peak", async () => {
const { host } = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
expect(host.textContent).toContain("wave");
expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
"▲ peaks −0.2 dBFS",
"▲ Too loud",
);
expect(host.querySelector("[data-peak-text]")?.textContent).not.toContain("dBFS");
const clip = host.querySelector("button")!;
await act(async () => clip.focus());
await vi.waitFor(() =>
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
"Peaks −0.2 dBFS at this volume; export lowers the whole mix",
),
);
expect(clip.getAttribute("aria-describedby")).toBe(
document.querySelector('[role="tooltip"]')?.id,
);
expect(host.querySelectorAll("button")).toHaveLength(1);
});
it("removes the focused warning when volume becomes quiet without replacing the clip", async () => {
const { host, setGain } = await render("/api/projects/p/peaks/changing.mp4", [1, 1], 1);
const clip = host.querySelector("button")!;
await act(async () => clip.focus());
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
await setGain(0.25);
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
expect(clip.getAttribute("aria-describedby")).toBeNull();
expect(document.activeElement).toBe(clip);
expect(host.querySelector("button")).toBe(clip);
expect(host.querySelector("[data-peak-badge]")).toBeNull();
});
it("paints nothing on a quiet clip", async () => {
const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
const { host } = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { useContext, useEffect, useState, type ReactNode } from "react";
import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
import { loadPeakMap } from "./clipPeakMap";
import { ClipPeakDescriptionContext } from "./ClipPeakTooltip";
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
@@ -30,15 +31,20 @@ export function ClipPeakMarks({
const { runs, peakDbfs } = map
? clipPeakRuns(map, sourceWindow, gain)
: { runs: [], peakDbfs: null };
const detail =
runs.length > 0 && peakDbfs !== null
? `Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`
: null;
const setDetail = useContext(ClipPeakDescriptionContext);
useEffect(() => {
setDetail?.(detail);
return () => setDetail?.(null);
}, [detail, setDetail]);
return (
<div className="relative h-full w-full">
<div className="relative isolate h-full w-full">
{children}
{runs.length > 0 && peakDbfs !== null && (
<div
className="pointer-events-none absolute inset-0"
data-testid="clip-peak-marks"
title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
>
{detail !== null && (
<div className="pointer-events-none absolute inset-0" data-testid="clip-peak-marks">
{runs.map((run) => (
<div
key={run.from}
@@ -50,10 +56,10 @@ export function ClipPeakMarks({
/>
))}
<span
className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400"
className="pointer-events-auto absolute right-4 bottom-1 font-mono text-[9px] leading-none text-red-400"
data-peak-badge
>
▲<span data-peak-text> peaks {dbText(peakDbfs)}</span>
▲<span data-peak-text> Too loud</span>
</span>
</div>
)}
@@ -0,0 +1,21 @@
import { cloneElement, createContext, useState, type ReactElement } from "react";
import { Tooltip } from "../../components/ui/Tooltip";
export const ClipPeakDescriptionContext = createContext<((detail: string | null) => void) | null>(
null,
);
export function ClipPeakTooltip({
children,
}: {
children: ReactElement<{ title?: string; "aria-describedby"?: string }>;
}) {
const [detail, setDetail] = useState<string | null>(null);
return (
<ClipPeakDescriptionContext.Provider value={setDetail}>
<Tooltip label={detail ?? ""} disabled={detail === null}>
{cloneElement(children, { title: detail === null ? children.props.title : undefined })}
</Tooltip>
</ClipPeakDescriptionContext.Provider>
);
}
@@ -87,11 +87,8 @@ describe("timeline viewport geometry", () => {
});
});
/** The track's content row: a clip's parent, past its `display: contents` wrapper. */
function trackContentOf(clip: HTMLElement | null | undefined): HTMLElement | null {
let row = clip?.parentElement ?? null;
while (row?.style.display === "contents") row = row.parentElement;
return row;
return clip?.closest<HTMLElement>('[role="gridcell"]') ?? null;
}
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
@@ -16,6 +16,7 @@ import { ClipBadges } from "./ClipBadges";
import { linkLabelColor } from "./linkLabelColor";
import { OutOfSyncBadge } from "./OutOfSyncBadge";
import { clipSpeedSuffix } from "./clipToolAttrs";
import { ClipPeakTooltip } from "./ClipPeakTooltip";
interface TimelineClipProps {
el: TimelineElement;
@@ -128,7 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
...passengerStyle,
};
return (
const clip = (
<button
type="button"
data-clip={isGestureActor ? undefined : "true"}
@@ -256,4 +257,5 @@ export const TimelineClip = memo(function TimelineClip({
)}
</button>
);
return isAudioClip || el.hasAudio ? <ClipPeakTooltip>{clip}</ClipPeakTooltip> : clip;
});
+5 -1
View File
@@ -314,14 +314,18 @@
display: none;
}
[data-peak-badge] {
z-index: 20;
}
.audible-video-wave [data-peak-badge] {
top: 50%;
bottom: auto;
right: var(--timeline-clip-radius);
translate: 0 -50%;
padding: 1px 3px;
border-radius: 3px;
background: var(--timeline-clip-chip-audio-bg);
z-index: 20;
}
.timeline-clip.is-audio.is-hovered {