mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): audio pills show real peaks and loudness (#4282)
* feat(studio): audio clips draw real peaks shaded by loudness The pill fill is the track tint at 70 percent, and each bar's opacity follows the cached server peaks. Muting greys the pill in place. Linked video audio shows a 1px tick. * style(studio): format the audio waveform markup * fix(studio): map audio peaks across the full clip width * fix(studio): keep waveform visible through fade wedges * fix(studio): keep silent waveform bins visible * fix(studio): keep waveform baseline visible edge to edge * fix(studio): keep waveform visible over fades * fix(studio): keep quiet waveform bars visible * fix(studio): paint waveform above fade wedges * fix(studio): fit waveform inside audio clip * fix(studio): preserve waveform contrast tokens * test(studio): ratchet waveform color literals * fix(studio): keep loudness floor internal * chore(studio): document intentional fallow findings * refactor(studio): share fade handle rendering * fix(studio): pass fade handle styles explicitly * refactor(studio): split waveform drawing stages * test(studio): cover waveform canvas painting
This commit is contained in:
@@ -13,7 +13,7 @@ import { useRenderClipContent } from "./useRenderClipContent";
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({ thumbnailMode: "hidden" });
|
||||
usePlayerStore.setState({ thumbnailMode: "hidden", elements: [] });
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
@@ -144,6 +144,36 @@ describe("useRenderClipContent", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks audio linked when a video clip uses the same file, and muted when hidden", () => {
|
||||
usePlayerStore.setState({
|
||||
thumbnailMode: "hidden",
|
||||
elements: [
|
||||
{
|
||||
id: "picture",
|
||||
tag: "video",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
src: "assets/clip.mp4",
|
||||
},
|
||||
],
|
||||
});
|
||||
const linked = renderClipContent({
|
||||
id: "bed",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 1,
|
||||
src: "assets/clip.mp4",
|
||||
hidden: true,
|
||||
});
|
||||
expect(isValidElement<{ linked: boolean; muted: boolean }>(linked)).toBe(true);
|
||||
if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) {
|
||||
expect(linked.props.linked).toBe(true);
|
||||
expect(linked.props.muted).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
||||
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from "react";
|
||||
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
||||
import { audioPillFlags } from "../player/components/audioClipLink";
|
||||
import { AudioWaveform } from "../player/components/AudioWaveform";
|
||||
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
||||
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
||||
@@ -70,6 +71,7 @@ function renderAudioClip(
|
||||
sessionEpoch: number,
|
||||
labelColor: string,
|
||||
context: TimelineClipRenderContext,
|
||||
elements: readonly TimelineElement[],
|
||||
): ReactNode {
|
||||
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
||||
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
||||
@@ -91,6 +93,7 @@ function renderAudioClip(
|
||||
projectId: pid,
|
||||
sessionEpoch,
|
||||
priority: context.priority,
|
||||
...audioPillFlags(el, elements),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +115,7 @@ export function useRenderClipContent({
|
||||
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
||||
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
||||
const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
return useCallback(
|
||||
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -127,7 +131,7 @@ export function useRenderClipContent({
|
||||
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
||||
if (effectiveMode === "hidden") {
|
||||
return el.tag === "audio"
|
||||
? renderAudioClip(el, pid, sessionEpoch, style.label, context)
|
||||
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -165,7 +169,7 @@ export function useRenderClipContent({
|
||||
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
||||
// captured frame from the currently drilled composition preview.
|
||||
if (el.tag === "audio") {
|
||||
return renderAudioClip(el, pid, sessionEpoch, style.label, context);
|
||||
return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
||||
}
|
||||
|
||||
// When drilled into a composition, render all inner elements via
|
||||
@@ -251,6 +255,7 @@ export function useRenderClipContent({
|
||||
effectiveMode,
|
||||
sessionEpoch,
|
||||
contentRevision,
|
||||
elements,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
|
||||
useThumbnailLease: leaseSpy,
|
||||
}));
|
||||
|
||||
import { AudioWaveform } from "./AudioWaveform";
|
||||
import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -21,6 +21,30 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AudioWaveform", () => {
|
||||
it("paints a baseline and peak bar for every mapped waveform bin", () => {
|
||||
const fillRect = vi.fn();
|
||||
const context = {
|
||||
scale: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
fillRect,
|
||||
fillStyle: "",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const canvas = document.createElement("canvas");
|
||||
Object.defineProperties(canvas, {
|
||||
clientWidth: { value: 6 },
|
||||
clientHeight: { value: 20 },
|
||||
});
|
||||
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
||||
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
|
||||
expect(fillRect).toHaveBeenCalledTimes(4);
|
||||
expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
|
||||
[0, 18, 3, 2],
|
||||
[0, 15, 3, 5],
|
||||
[3, 18, 3, 2],
|
||||
[3, 0, 3, 20],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
@@ -50,4 +74,32 @@ describe("AudioWaveform", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("greys the clip in place when muted and draws the parent tick when linked", () => {
|
||||
const host = document.createElement("div");
|
||||
host.className = "timeline-clip is-audio";
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<AudioWaveform
|
||||
audioUrl="/media/voice.wav"
|
||||
label=""
|
||||
labelColor="#fff"
|
||||
projectId="project-a"
|
||||
sessionEpoch={1}
|
||||
priority="visible"
|
||||
muted
|
||||
linked
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.getAttribute("data-audio-muted")).toBe("true");
|
||||
expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
expect(host.hasAttribute("data-audio-muted")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
||||
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
||||
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
@@ -13,11 +14,68 @@ interface AudioWaveformProps {
|
||||
projectId: string;
|
||||
sessionEpoch: number;
|
||||
priority: ThumbnailPriority;
|
||||
/** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
|
||||
muted?: boolean;
|
||||
/** Same media file as a video clip. Draws the 1px parent tick. */
|
||||
linked?: boolean;
|
||||
}
|
||||
|
||||
const BAR_WIDTH = 2;
|
||||
const BAR_STEP = 3;
|
||||
|
||||
type BarGeometry = { x: number; width: number; height: number };
|
||||
|
||||
function paintWaveformBars(
|
||||
context: CanvasRenderingContext2D,
|
||||
bars: readonly BarGeometry[],
|
||||
height: number,
|
||||
waveformBarRgb: string,
|
||||
waveformBaselineRgb: string,
|
||||
amplitudes: readonly number[],
|
||||
) {
|
||||
bars.forEach((bar, index) => {
|
||||
const amplitude = amplitudes[index] ?? 0;
|
||||
context.fillStyle = `rgb(${waveformBaselineRgb})`;
|
||||
context.fillRect(bar.x, height - 2, bar.width, 2);
|
||||
context.fillStyle = `rgba(${waveformBarRgb},${loudnessToOpacity(amplitude).toFixed(2)})`;
|
||||
context.fillRect(bar.x, height - bar.height, bar.width, bar.height);
|
||||
});
|
||||
}
|
||||
|
||||
export function drawWaveformCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
peaks: readonly number[],
|
||||
muted: boolean,
|
||||
trimStartFraction: number,
|
||||
trimEndFraction: number,
|
||||
) {
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.ceil(width * scale);
|
||||
canvas.height = Math.ceil(height * scale);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.scale(scale, scale);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const amplitudes = decimatePeaks(
|
||||
peaks,
|
||||
trimStartFraction,
|
||||
trimEndFraction,
|
||||
Math.max(1, Math.ceil(width / BAR_STEP)),
|
||||
);
|
||||
const bars = amplitudes.map((amplitude, index) => ({
|
||||
x: (index * width) / amplitudes.length,
|
||||
width: Math.max(1, width / amplitudes.length),
|
||||
height: Math.max(3, amplitude * height),
|
||||
}));
|
||||
const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
|
||||
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
|
||||
const waveformBaselineRgb = getComputedStyle(canvas).getPropertyValue(
|
||||
"--timeline-waveform-baseline-rgb",
|
||||
);
|
||||
paintWaveformBars(context, bars, height, waveformBarRgb, waveformBaselineRgb, amplitudes);
|
||||
}
|
||||
|
||||
function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
||||
const peaks: number[] = [];
|
||||
const samplesPerBar = Math.floor(channelData.length / barCount);
|
||||
@@ -92,7 +150,10 @@ export const AudioWaveform = memo(function AudioWaveform({
|
||||
projectId,
|
||||
sessionEpoch,
|
||||
priority,
|
||||
muted = false,
|
||||
linked = false,
|
||||
}: AudioWaveformProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const cacheKey = waveformUrl ?? audioUrl;
|
||||
@@ -121,30 +182,8 @@ export const AudioWaveform = memo(function AudioWaveform({
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !peaks) return;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.ceil(width * scale);
|
||||
canvas.height = Math.ceil(height * scale);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.scale(scale, scale);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0));
|
||||
const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1));
|
||||
const start = Math.floor(startFraction * peaks.length);
|
||||
const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
|
||||
const span = end - start;
|
||||
const barCount = Math.floor(width / BAR_STEP);
|
||||
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
|
||||
for (let index = 0; index < barCount; index++) {
|
||||
const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
|
||||
const amplitude = peaks[peakIndex] ?? 0;
|
||||
const barHeight = Math.max(2, amplitude * height);
|
||||
context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
|
||||
context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
|
||||
}
|
||||
}, [peaks, trimEndFraction, trimStartFraction]);
|
||||
drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
|
||||
}, [muted, peaks, trimEndFraction, trimStartFraction]);
|
||||
|
||||
const setCanvasRef = useCallback(
|
||||
(canvas: HTMLCanvasElement | null) => {
|
||||
@@ -170,51 +209,62 @@ export const AudioWaveform = memo(function AudioWaveform({
|
||||
|
||||
useMountEffect(() => () => observerRef.current?.disconnect());
|
||||
|
||||
useEffect(() => {
|
||||
const clip = rootRef.current?.closest(".timeline-clip");
|
||||
if (!(clip instanceof HTMLElement)) return;
|
||||
if (muted) clip.setAttribute("data-audio-muted", "true");
|
||||
else clip.removeAttribute("data-audio-muted");
|
||||
return () => clip.removeAttribute("data-audio-muted");
|
||||
}, [muted]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<canvas
|
||||
ref={setCanvasRef}
|
||||
className="absolute inset-x-0 bottom-0 w-full"
|
||||
style={{ top: 16 }}
|
||||
/>
|
||||
{snapshot.status === "loading" && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
||||
style={{
|
||||
background: "var(--timeline-thumbnail-shimmer)",
|
||||
}}
|
||||
<div ref={rootRef} className="absolute inset-0">
|
||||
{linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
|
||||
<canvas
|
||||
ref={setCanvasRef}
|
||||
className="absolute inset-x-0 bottom-0 w-full"
|
||||
style={{ top: 16, height: "calc(100% - 16px)" }}
|
||||
/>
|
||||
)}
|
||||
{/* Degraded state — the decode failed; say so rather than paint a
|
||||
waveform the author could edit against. */}
|
||||
{snapshot.status === "error" && (
|
||||
<div
|
||||
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
||||
style={{ top: 16, bottom: 0 }}
|
||||
>
|
||||
{snapshot.status === "loading" && (
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
|
||||
style={{
|
||||
bottom: "20%",
|
||||
height: 2,
|
||||
background: "var(--timeline-waveform-error)",
|
||||
background: "var(--timeline-thumbnail-shimmer)",
|
||||
}}
|
||||
/>
|
||||
<span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
||||
waveform unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{label && (
|
||||
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
||||
<span
|
||||
className="block truncate text-[9px] font-semibold leading-tight"
|
||||
style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
|
||||
)}
|
||||
{/* Degraded state — the decode failed; say so rather than paint a
|
||||
waveform the author could edit against. */}
|
||||
{snapshot.status === "error" && (
|
||||
<div
|
||||
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
||||
style={{ top: 16, bottom: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
bottom: "20%",
|
||||
height: 2,
|
||||
background: "var(--timeline-waveform-error)",
|
||||
}}
|
||||
/>
|
||||
<span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
|
||||
waveform unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{label && (
|
||||
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
|
||||
<span
|
||||
className="block truncate text-[9px] font-semibold leading-tight"
|
||||
style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -192,13 +192,14 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
||||
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* The shaded wedge is the gain that is NOT there: above the ramp. */}
|
||||
{/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
|
||||
{showIn && (
|
||||
<>
|
||||
<polygon
|
||||
data-testid="clip-fade-in"
|
||||
points={`0,0 ${inPx},0 0,100`}
|
||||
fill="var(--timeline-fade-shade)"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<line
|
||||
x1={0}
|
||||
@@ -218,6 +219,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
||||
data-testid="clip-fade-out"
|
||||
points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
|
||||
fill="var(--timeline-fade-shade)"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
<line
|
||||
x1={widthPx - outPx}
|
||||
@@ -235,54 +237,85 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={-1}
|
||||
aria-label="Fade in"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
|
||||
aria-valuenow={fades.fadeIn}
|
||||
aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
|
||||
data-testid="clip-fade-handle-in"
|
||||
title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
|
||||
<FadeHandle
|
||||
direction="in"
|
||||
value={fades.fadeIn}
|
||||
max={Math.max(0, el.duration - fades.fadeOut)}
|
||||
style={handleStyle(inPx)}
|
||||
dragging={dragging === "in"}
|
||||
onPointerDown={onHandlePointerDown("in")}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={(e) => finish(e, false)}
|
||||
onPointerCancel={(e) => finish(e, true)}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FadeDot active={dragging === "in"} />
|
||||
</div>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={-1}
|
||||
aria-label="Fade out"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
|
||||
aria-valuenow={fades.fadeOut}
|
||||
aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
|
||||
data-testid="clip-fade-handle-out"
|
||||
title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
|
||||
/>
|
||||
<FadeHandle
|
||||
direction="out"
|
||||
value={fades.fadeOut}
|
||||
max={Math.max(0, el.duration - fades.fadeIn)}
|
||||
style={handleStyle(widthPx - outPx)}
|
||||
dragging={dragging === "out"}
|
||||
onPointerDown={onHandlePointerDown("out")}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={(e) => finish(e, false)}
|
||||
onPointerCancel={(e) => finish(e, true)}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FadeDot active={dragging === "out"} />
|
||||
</div>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeHandle({
|
||||
direction,
|
||||
value,
|
||||
max,
|
||||
style,
|
||||
dragging,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onKeyDown,
|
||||
}: {
|
||||
direction: "in" | "out";
|
||||
value: number;
|
||||
max: number;
|
||||
style: CSSProperties;
|
||||
dragging: boolean;
|
||||
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
||||
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const label = direction === "in" ? "Fade in" : "Fade out";
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={`${formatFadeSeconds(value)}s`}
|
||||
data-testid={`clip-fade-handle-${direction}`}
|
||||
title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
|
||||
style={style}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FadeDot active={dragging} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeDot({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { audioPillFlags, isLinkedVideoAudio } from "./audioClipLink";
|
||||
|
||||
const audio = { id: "bed", tag: "audio", src: "assets/City Ride.mp4?v=2" };
|
||||
|
||||
describe("isLinkedVideoAudio", () => {
|
||||
it("links an audio pill to a video of the same file", () => {
|
||||
expect(
|
||||
isLinkedVideoAudio(audio, [
|
||||
audio,
|
||||
{ id: "picture", tag: "video", src: "/preview/assets/city ride.mp4" },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("greys a hidden clip and a muted group without dropping the link", () => {
|
||||
expect(audioPillFlags({ ...audio, hidden: true }, [])).toEqual({
|
||||
muted: true,
|
||||
linked: false,
|
||||
});
|
||||
expect(audioPillFlags({ ...audio, audioGroupHidden: true }, [])).toEqual({
|
||||
muted: true,
|
||||
linked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a standalone audio file unlinked", () => {
|
||||
expect(
|
||||
isLinkedVideoAudio({ id: "vo", tag: "audio", src: "assets/voice.wav" }, [
|
||||
{ id: "picture", tag: "video", src: "assets/city.mp4" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TimelineElement } from "../store/timelineElement";
|
||||
|
||||
function mediaFileKey(src: string | undefined): string | null {
|
||||
if (!src) return null;
|
||||
const path = src.split(/[?#]/, 1)[0] ?? "";
|
||||
const segment = path.split(/[/\\]/).pop()?.trim().toLowerCase() ?? "";
|
||||
return segment.length > 0 ? segment : null;
|
||||
}
|
||||
|
||||
export function audioPillFlags(
|
||||
audio: Pick<TimelineElement, "id" | "tag" | "src" | "hidden" | "audioGroupHidden">,
|
||||
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
||||
): { muted: boolean; linked: boolean } {
|
||||
return {
|
||||
muted: audio.hidden === true || audio.audioGroupHidden === true,
|
||||
linked: isLinkedVideoAudio(audio, elements),
|
||||
};
|
||||
}
|
||||
|
||||
/** An audio pill whose file is also a video clip on this timeline. */
|
||||
export function isLinkedVideoAudio(
|
||||
audio: Pick<TimelineElement, "id" | "tag" | "src">,
|
||||
elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
|
||||
): boolean {
|
||||
if (audio.tag.trim().toLowerCase() !== "audio") return false;
|
||||
const key = mediaFileKey(audio.src);
|
||||
if (!key) return false;
|
||||
return elements.some((element) => {
|
||||
if (element.id === audio.id) return false;
|
||||
if (element.tag.trim().toLowerCase() !== "video") return false;
|
||||
return mediaFileKey(element.src) === key;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
||||
|
||||
describe("loudnessToOpacity", () => {
|
||||
it("maps silence to the floor and the file peak to fully opaque", () => {
|
||||
expect(loudnessToOpacity(0)).toBe(0.64);
|
||||
expect(loudnessToOpacity(1)).toBe(1);
|
||||
expect(loudnessToOpacity(0.5)).toBeCloseTo(0.82, 5);
|
||||
});
|
||||
|
||||
it("clamps out-of-range and non-finite peaks to the same floor or ceiling", () => {
|
||||
expect(loudnessToOpacity(-0.2)).toBe(0.64);
|
||||
expect(loudnessToOpacity(4)).toBe(1);
|
||||
expect(loudnessToOpacity(Number.NaN)).toBe(0.64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decimatePeaks", () => {
|
||||
it("keeps a transient that a single sample in the bucket would miss", () => {
|
||||
const peaks = [0, 0, 0, 1, 0, 0, 0, 0];
|
||||
expect(decimatePeaks(peaks, 0, 1, 2)).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
it("windows to the trimmed source slice", () => {
|
||||
expect(decimatePeaks([0.1, 0.2, 0.9, 0.3], 0.5, 1, 1)).toEqual([0.9]);
|
||||
});
|
||||
|
||||
it("returns no bars when there is nothing to draw", () => {
|
||||
expect(decimatePeaks([], 0, 1, 4)).toEqual([]);
|
||||
expect(decimatePeaks([0.5], 0, 1, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Quietest bar that still clears the timeline contrast gate on the 70% pill. */
|
||||
const LOUDNESS_OPACITY_FLOOR = 0.64;
|
||||
|
||||
const LOUDNESS_OPACITY_SPAN = 1 - LOUDNESS_OPACITY_FLOOR;
|
||||
|
||||
/** Peak is 0–1 against the file's own maximum. Hotter peaks paint more opaque. */
|
||||
export function loudnessToOpacity(peak: number): number {
|
||||
if (!Number.isFinite(peak)) return LOUDNESS_OPACITY_FLOOR;
|
||||
const clamped = Math.min(1, Math.max(0, peak));
|
||||
return LOUDNESS_OPACITY_FLOOR + LOUDNESS_OPACITY_SPAN * clamped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max-in-bucket over the trimmed peak window, one value per bar.
|
||||
* A point sample drops a transient that sits between bar columns.
|
||||
*/
|
||||
export function decimatePeaks(
|
||||
peaks: readonly number[],
|
||||
startFraction: number,
|
||||
endFraction: number,
|
||||
barCount: number,
|
||||
): number[] {
|
||||
if (barCount <= 0 || peaks.length === 0) return [];
|
||||
const startFractionClamped = Math.min(1, Math.max(0, startFraction));
|
||||
const endFractionClamped = Math.min(1, Math.max(startFractionClamped, endFraction));
|
||||
const start = Math.floor(startFractionClamped * peaks.length);
|
||||
const endIndex = Math.ceil(endFractionClamped * peaks.length);
|
||||
const end = Math.max(start + 1, Math.min(peaks.length, endIndex));
|
||||
const span = end - start;
|
||||
const bars: number[] = [];
|
||||
for (let index = 0; index < barCount; index++) {
|
||||
let from = start + Math.floor((index * span) / barCount);
|
||||
let to = start + Math.floor(((index + 1) * span) / barCount);
|
||||
if (to <= from) {
|
||||
from = Math.min(from, end - 1);
|
||||
to = from + 1;
|
||||
}
|
||||
let max = 0;
|
||||
for (let cursor = from; cursor < to; cursor++) {
|
||||
const value = peaks[cursor] ?? 0;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
bars.push(max);
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"total": 302,
|
||||
"total": 297,
|
||||
"files": {
|
||||
"packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
|
||||
"packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
|
||||
@@ -49,7 +49,7 @@
|
||||
"packages/studio/src/components/sidebar/AudioRow.tsx": 2,
|
||||
"packages/studio/src/components/ui/HyperframesMark.tsx": 4,
|
||||
"packages/studio/src/hooks/useInlineTextEdit.ts": 1,
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx": 1,
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx": 2,
|
||||
"packages/studio/src/player/components/BeatStrip.tsx": 6,
|
||||
"packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
|
||||
"packages/studio/src/player/components/Player.tsx": 2,
|
||||
@@ -58,7 +58,7 @@
|
||||
"packages/studio/src/player/components/SpeedMenu.tsx": 2,
|
||||
"packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
|
||||
"packages/studio/src/telemetry/client.ts": 1,
|
||||
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1,
|
||||
"packages/studio/src/styles/studio.css": 48
|
||||
"packages/studio/src/styles/studio.css": 48,
|
||||
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,24 @@ body {
|
||||
0 8px 24px var(--timeline-clip-shadow-dragging);
|
||||
}
|
||||
|
||||
/* Mute greys the pill in place. Hot path: no transition. */
|
||||
.timeline-clip.is-audio[data-clip-hidden="true"],
|
||||
.timeline-clip.is-audio[data-audio-muted="true"] {
|
||||
background-color: var(--timeline-clip-audio-muted-bg);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.timeline-audio-link {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: -9px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: var(--timeline-audio-link);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-clip.is-hovered {
|
||||
background-color: var(--clip-bg-hover);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
--timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
|
||||
--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
|
||||
--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
|
||||
--timeline-clip-audio-muted-bg: rgb(72, 74, 78);
|
||||
--timeline-audio-link: #a0aaa4;
|
||||
--timeline-clip-selection: rgba(255, 255, 255, 0.85);
|
||||
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
|
||||
--timeline-clip-radius: 8px;
|
||||
@@ -91,10 +93,12 @@
|
||||
transparent 100%
|
||||
);
|
||||
--timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
||||
--timeline-waveform-bar-rgb: 75, 163, 210;
|
||||
--timeline-waveform-bar-rgb: 245, 237, 255;
|
||||
--timeline-waveform-muted-rgb: 236, 238, 242;
|
||||
--timeline-waveform-baseline-rgb: 226, 216, 238;
|
||||
--timeline-waveform-error: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(75, 163, 210, 0.35) 0 2px,
|
||||
rgba(245, 237, 255, 0.35) 0 2px,
|
||||
transparent 2px 5px
|
||||
);
|
||||
--timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
@@ -361,6 +365,7 @@
|
||||
--timeline-audio-link: #626d66;
|
||||
--timeline-waveform-bar-rgb: 20, 12, 32;
|
||||
--timeline-waveform-muted-rgb: 24, 28, 26;
|
||||
--timeline-waveform-baseline-rgb: 76, 62, 88;
|
||||
--timeline-waveform-error: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(20, 12, 32, 0.35) 0 2px,
|
||||
|
||||
@@ -9,7 +9,5 @@
|
||||
"dark/hovered-handle": 2.1777784316890663,
|
||||
"dark/active-hovered-handle": 1.8873389821795008,
|
||||
"dark/dragging-handle": 2.7718581542466456,
|
||||
"dark/active-dragging-handle": 2.3310570156777346,
|
||||
"dark/quiet-waveform": 2.1687299852625723,
|
||||
"dark/active-quiet-waveform": 1.7915634653703223
|
||||
"dark/active-dragging-handle": 2.3310570156777346
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:139",
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:145"
|
||||
],
|
||||
"opacity": 0.45,
|
||||
"opacity": 0.64,
|
||||
"format": "rgb-channels"
|
||||
},
|
||||
{
|
||||
@@ -255,7 +255,53 @@
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:139",
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:145"
|
||||
],
|
||||
"opacity": 0.45,
|
||||
"opacity": 0.64,
|
||||
"format": "rgb-channels"
|
||||
},
|
||||
{
|
||||
"id": "quiet-muted-waveform",
|
||||
"foreground": "--timeline-waveform-muted-rgb",
|
||||
"background": ["--timeline-row-bg", "--timeline-clip-audio-muted-bg"],
|
||||
"role": "graphic",
|
||||
"minimum": 3,
|
||||
"sources": [
|
||||
"packages/studio/src/styles/studio.css:246",
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:144"
|
||||
],
|
||||
"opacity": 0.64,
|
||||
"format": "rgb-channels"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "paper",
|
||||
"selector": ":root[data-theme=\"paper\"]",
|
||||
"canvas": "--timeline-shell-bg",
|
||||
"pairs": [
|
||||
{
|
||||
"id": "quiet-waveform",
|
||||
"foreground": "--timeline-waveform-bar-rgb",
|
||||
"background": ["--timeline-row-bg", "--timeline-clip-audio-bg"],
|
||||
"role": "graphic",
|
||||
"minimum": 3,
|
||||
"sources": [
|
||||
"packages/studio/src/styles/theme.css:336",
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:144"
|
||||
],
|
||||
"opacity": 0.64,
|
||||
"format": "rgb-channels"
|
||||
},
|
||||
{
|
||||
"id": "quiet-muted-waveform",
|
||||
"foreground": "--timeline-waveform-muted-rgb",
|
||||
"background": ["--timeline-row-bg", "--timeline-clip-audio-muted-bg"],
|
||||
"role": "graphic",
|
||||
"minimum": 3,
|
||||
"sources": [
|
||||
"packages/studio/src/styles/theme.css:340",
|
||||
"packages/studio/src/player/components/AudioWaveform.tsx:144"
|
||||
],
|
||||
"opacity": 0.64,
|
||||
"format": "rgb-channels"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user