mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user