Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/studio/src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,30 @@ interface TooltipProps {
children: ReactElement<{ "aria-describedby"?: string }>;
/** Hover delay in ms. */
delay?: number;
disabled?: boolean;
side?: "top" | "bottom" | "left" | "right";
}

/** Matches the old bubble's gap from its trigger, and its viewport margin. */
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>
Expand Down
66 changes: 55 additions & 11 deletions packages/studio/src/player/components/ClipPeakMarks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();
});
});
26 changes: 16 additions & 10 deletions packages/studio/src/player/components/ClipPeakMarks.tsx
Original file line number Diff line number Diff line change
@@ -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`;

Expand Down Expand Up @@ -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}
Expand All @@ -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>
)}
Expand Down
21 changes: 21 additions & 0 deletions packages/studio/src/player/components/ClipPeakTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
5 changes: 1 addition & 4 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,7 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
...passengerStyle,
};

return (
const clip = (
<button
type="button"
data-clip={isGestureActor ? undefined : "true"}
Expand Down Expand Up @@ -256,4 +257,5 @@ export const TimelineClip = memo(function TimelineClip({
)}
</button>
);
return isAudioClip || el.hasAudio ? <ClipPeakTooltip>{clip}</ClipPeakTooltip> : clip;
});
6 changes: 5 additions & 1 deletion packages/studio/src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading