summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx275
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx311
-rw-r--r--apps/web/src/components/editor/panels/timeline/theme.ts2
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx318
4 files changed, 681 insertions, 225 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx b/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
new file mode 100644
index 00000000..208f3beb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getDbFromLinePos,
+ getLinePosFromDb,
+} from "@/lib/timeline/audio-display";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { hasAnimatedVolume } from "@/lib/timeline/audio-state";
+import type { AudioElement } from "@/lib/timeline/types";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import { cn } from "@/utils/ui";
+
+const HIT_AREA_HEIGHT_PX = 14;
+const TOOLTIP_OFFSET_PX = 10;
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+function clampVolume({ value }: { value: number }): number {
+ return clamp({
+ value: snapToStep({ value, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+}
+
+function getVolumeFromPointer({
+ clientY,
+ rect,
+}: {
+ clientY: number;
+ rect: DOMRect;
+}): number {
+ const clampedOffset = clamp({
+ value: clientY - rect.top,
+ min: 0,
+ max: rect.height,
+ });
+ const progressPercent =
+ rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
+ return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
+}
+
+export function AudioVolumeLine({
+ element,
+ trackId,
+}: {
+ element: AudioElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const surfaceRef = useRef<HTMLDivElement>(null);
+ const activePointerIdRef = useRef<number | null>(null);
+ const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
+ const lastPreviewVolumeRef = useRef(
+ element.volume ?? DEFAULTS.element.volume,
+ );
+ const hasChangedRef = useRef(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [tooltipClientPos, setTooltipClientPos] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const hasAnimatedEnvelope = hasAnimatedVolume({ element });
+ const currentVolume = element.volume ?? DEFAULTS.element.volume;
+ const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
+
+ const volumeLabel = `${formatNumberForDisplay({
+ value: currentVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ })} dB`;
+
+ const previewVolume = useCallback(
+ (nextVolume: number) => {
+ if (
+ isNearlyEqual({
+ leftValue: nextVolume,
+ rightValue: lastPreviewVolumeRef.current,
+ })
+ ) {
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { volume: nextVolume },
+ },
+ ],
+ });
+ lastPreviewVolumeRef.current = nextVolume;
+ hasChangedRef.current = !isNearlyEqual({
+ leftValue: startVolumeRef.current,
+ rightValue: nextVolume,
+ });
+ },
+ [editor, element.id, trackId],
+ );
+
+ const finishDrag = useCallback(
+ ({ shouldCommit }: { shouldCommit: boolean }) => {
+ activePointerIdRef.current = null;
+ setIsDragging(false);
+
+ if (shouldCommit && hasChangedRef.current) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.discardPreview();
+ }
+
+ hasChangedRef.current = false;
+ lastPreviewVolumeRef.current = startVolumeRef.current;
+ setTooltipClientPos(null);
+ },
+ [editor],
+ );
+
+ const updateFromPointer = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const rect = surfaceRef.current?.getBoundingClientRect();
+ if (!rect) {
+ return;
+ }
+
+ setTooltipClientPos({
+ x: clientX + TOOLTIP_OFFSET_PX,
+ y: clientY - TOOLTIP_OFFSET_PX,
+ });
+ previewVolume(getVolumeFromPointer({ clientY, rect }));
+ },
+ [previewVolume],
+ );
+
+ const handleClick = useCallback((event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }, []);
+
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
+ event.stopPropagation();
+ }, []);
+
+ const handlePointerDown = useCallback(
+ (event: React.PointerEvent<HTMLDivElement>) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId: element.id }],
+ });
+ activePointerIdRef.current = event.pointerId;
+ startVolumeRef.current = currentVolume;
+ lastPreviewVolumeRef.current = currentVolume;
+ hasChangedRef.current = false;
+ setIsDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ updateFromPointer({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ },
+ [currentVolume, editor.selection, element.id, trackId, updateFromPointer],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ updateFromPointer({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ },
+ [updateFromPointer],
+ );
+
+ const handlePointerUp = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ finishDrag({ shouldCommit: true });
+ },
+ [finishDrag],
+ );
+
+ const handlePointerCancel = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ finishDrag({ shouldCommit: false });
+ },
+ [finishDrag],
+ );
+
+ const handleLostPointerCapture = useCallback(() => {
+ if (activePointerIdRef.current === null) {
+ return;
+ }
+
+ finishDrag({ shouldCommit: hasChangedRef.current });
+ }, [finishDrag]);
+
+ if (hasAnimatedEnvelope) {
+ return null;
+ }
+
+ return (
+ <div className="pointer-events-none absolute inset-0">
+ <div ref={surfaceRef} className="absolute inset-0">
+ <div
+ className={cn(
+ "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
+ isDragging
+ ? "border-white"
+ : "border-white/50 group-hover/audio:border-white/80",
+ )}
+ style={{ top: lineTop }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
+ <div
+ className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
+ style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
+ onClick={handleClick}
+ onMouseDown={handleMouseDown}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerCancel}
+ onLostPointerCapture={handleLostPointerCapture}
+ title="Drag to adjust clip volume"
+ />
+ {isDragging &&
+ tooltipClientPos &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
+ style={{
+ transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
+ }}
+ >
+ {volumeLabel}
+ </div>,
+ document.body,
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index 430575e1..d2442a84 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,73 +1,123 @@
"use client";
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useResizeObserver } from "@/hooks/use-resize-observer";
-import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import {
+ buildWaveformSampleBuckets,
+ sampleSourceWaveformSummary,
+ type SourceWaveformSummary,
+} from "@/lib/media/waveform-summary";
+import type { RetimeConfig } from "@/lib/timeline";
+import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display";
+import { waveformCache } from "@/services/waveform-cache/service";
import { findScrollParent } from "@/utils/browser";
import { cn } from "@/utils/ui";
-const BAR_WIDTH = 2;
+const BAR_WIDTH = 1;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
+const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
-function sampleGain({
+function sampleGainAtClipTime({
samples,
- startFraction,
- endFraction,
- barIndex,
- barCount,
+ clipTimeSec,
+ clipDurationSec,
}: {
samples: number[];
- startFraction: number;
- endFraction: number;
- barIndex: number;
- barCount: number;
+ clipTimeSec: number;
+ clipDurationSec: number;
}): number {
- if (samples.length === 0) return 1;
- const progress =
- startFraction +
- ((barIndex + 0.5) / barCount) * (endFraction - startFraction);
- const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
+ if (samples.length === 0 || clipDurationSec <= 0) {
+ return 1;
+ }
+
+ const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
+ const rawIndex = progress * (samples.length - 1);
const lo = Math.floor(rawIndex);
const hi = Math.min(samples.length - 1, lo + 1);
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
}
+
interface AudioWaveformProps {
+ sourceKey: string;
+ sourceFile?: File;
audioUrl?: string;
audioBuffer?: AudioBuffer;
gainSamples?: number[];
+ pixelsPerSecond: number;
+ clipDurationSec: number;
+ retime?: RetimeConfig;
+ sourceStartSec: number;
color?: string;
+ burnColor?: string;
className?: string;
}
export function AudioWaveform({
+ sourceKey,
+ sourceFile,
audioUrl,
audioBuffer,
gainSamples,
- color = "rgba(255, 255, 255, 0.7)",
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ color,
+ burnColor = WAVEFORM_BURN_COLOR,
className = "",
}: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
- const bufferRef = useRef<AudioBuffer | null>(null);
- const globalMaxRef = useRef<number>(1);
+ const summaryRef = useRef<SourceWaveformSummary | null>(null);
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
+ const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
+ const clipDurationSecRef = useRef<number>(clipDurationSec);
+ const retimeRef = useRef<RetimeConfig | undefined>(retime);
+ const sourceStartSecRef = useRef<number>(sourceStartSec);
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
+ const lastRenderSignatureRef = useRef<string | null>(null);
+
+ gainSamplesRef.current = gainSamples;
+ pixelsPerSecondRef.current = pixelsPerSecond;
+ clipDurationSecRef.current = clipDurationSec;
+ retimeRef.current = retime;
+ sourceStartSecRef.current = sourceStartSec;
+
+ const clearCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+ lastRenderSignatureRef.current = null;
+ }, []);
const drawVisible = useCallback(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
- const buffer = bufferRef.current;
+ const summary = summaryRef.current;
const height = heightRef.current;
- if (!container || !canvas || !buffer || height <= 0) return;
-
- const elementWidth = container.offsetWidth;
- if (elementWidth <= 0) return;
+ if (!container || !canvas || !summary || height <= 0) {
+ clearCanvas();
+ return;
+ }
const containerRect = container.getBoundingClientRect();
+ const elementWidth = containerRect.width;
+ if (elementWidth <= 0) {
+ clearCanvas();
+ return;
+ }
+
const scrollParent = scrollParentRef.current;
let clipLeft: number;
@@ -86,13 +136,43 @@ export function AudioWaveform({
}
const visibleWidth = clipRight - clipLeft;
- if (visibleWidth <= 0) return;
+ if (visibleWidth <= 0) {
+ clearCanvas();
+ return;
+ }
const dpr = window.devicePixelRatio || 1;
- const canvasW = Math.round(visibleWidth * dpr);
- const canvasH = Math.round(height * dpr);
-
- if (canvasW <= 0 || canvasH <= 0) return;
+ const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
+ const canvasH = Math.max(1, Math.round(height * dpr));
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const pixelsPerSecondValue = pixelsPerSecondRef.current;
+ const clipDurationSecValue = clipDurationSecRef.current;
+ const samples = gainSamplesRef.current;
+ const renderSignature = JSON.stringify({
+ elementWidth,
+ clipLeft,
+ clipRight,
+ visibleWidth,
+ canvasW,
+ canvasH,
+ barCount,
+ dpr,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ pixelsPerSecond: pixelsPerSecondValue,
+ retime: retimeRef.current ?? null,
+ summarySourceKey: summary.sourceKey,
+ summarySampleRate: summary.sampleRate,
+ summaryTotalSamples: summary.totalSamples,
+ summaryBucketSize: summary.bucketSize,
+ gainSamples: samples ?? null,
+ color,
+ burnColor,
+ });
+ if (lastRenderSignatureRef.current === renderSignature) {
+ return;
+ }
+ lastRenderSignatureRef.current = renderSignature;
canvas.width = canvasW;
canvas.height = canvasH;
@@ -100,97 +180,150 @@ export function AudioWaveform({
canvas.style.height = `${height}px`;
canvas.style.left = `${clipLeft}px`;
- const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
- const startFraction = clipLeft / elementWidth;
- const endFraction = clipRight / elementWidth;
- const startSample = Math.floor(startFraction * buffer.length);
- const endSample = Math.min(
- buffer.length,
- Math.ceil(endFraction * buffer.length),
- );
+ const backingScaleX = dpr;
+ const backingScaleY = canvasH / height;
- const peaks = extractRmsRange({
- buffer,
- count: barCount,
- startSample,
- endSample,
- globalMax: globalMaxRef.current,
+ const sampleBuckets = buildWaveformSampleBuckets({
+ clipLeftPx: clipLeft,
+ clipRightPx: clipRight,
+ barCount,
+ pixelsPerSecond: pixelsPerSecondValue,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ retime: retimeRef.current,
+ sampleRate: summary.sampleRate,
+ maxSampleExclusive: summary.totalSamples,
+ barStepPx: BAR_STEP,
+ });
+ const amplitudes = sampleSourceWaveformSummary({
+ summary,
+ buckets: sampleBuckets,
});
const ctx = canvas.getContext("2d");
- if (!ctx) return;
+ if (!ctx) {
+ return;
+ }
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvasW, canvasH);
- ctx.scale(dpr, dpr);
- ctx.fillStyle = color;
- const maxBarHeight = height * 0.7;
+ const clipBottom = canvasH;
- const samples = gainSamplesRef.current;
for (let i = 0; i < barCount; i++) {
+ const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
+ const clipCenterSec = Math.max(
+ 0,
+ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
+ );
const gain =
samples != null
- ? sampleGain({
+ ? sampleGainAtClipTime({
samples,
- startFraction,
- endFraction,
- barIndex: i,
- barCount,
+ clipTimeSec: clipCenterSec,
+ clipDurationSec: clipDurationSecValue,
})
: 1;
- const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
- const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
- const barH = Math.max(1, scaled * maxBarHeight);
- ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
- }
- }, [color]);
+ const amplitude = Math.max(0, amplitudes[i] ?? 0);
+ const outputAmplitude = amplitude * Math.max(0, gain);
+ const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
+ const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
+ if (barH <= 0) {
+ continue;
+ }
- useEffect(() => {
- gainSamplesRef.current = gainSamples;
- drawVisible();
- }, [gainSamples, drawVisible]);
+ const barLeft = i * BAR_STEP;
+ const barRight = barLeft + BAR_WIDTH;
+ const deviceLeft = Math.round(barLeft * backingScaleX);
+ const deviceRight = Math.max(
+ deviceLeft + 1,
+ Math.round(barRight * backingScaleX),
+ );
+ const deviceTop = Math.round((height - barH) * backingScaleY);
+ const deviceHeight = Math.max(1, clipBottom - deviceTop);
+
+ ctx.fillStyle = color;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ deviceHeight,
+ );
+
+ if (outputAmplitude > 1) {
+ const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
+ ctx.fillStyle = burnColor;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ burnHeight,
+ );
+ }
+ }
+ }, [burnColor, clearCanvas, color]);
useEffect(() => {
let isCancelled = false;
+ summaryRef.current = null;
+ clearCanvas();
- async function load() {
- let buffer = audioBuffer ?? null;
-
- if (!buffer && audioUrl) {
- try {
- const resp = await fetch(audioUrl);
- const arrayBuffer = await resp.arrayBuffer();
- const actx = new AudioContext();
- buffer = await actx.decodeAudioData(arrayBuffer);
- actx.close();
- } catch {
+ void waveformCache
+ .getSourceSummary({
+ sourceKey,
+ audioBuffer,
+ sourceFile,
+ audioUrl,
+ })
+ .then((summary) => {
+ if (isCancelled) {
return;
}
- }
-
- if (!buffer || isCancelled) return;
-
- bufferRef.current = buffer;
- globalMaxRef.current = computeGlobalMaxRms({ buffer });
- drawVisible();
- }
+ summaryRef.current = summary;
+ drawVisible();
+ })
+ .catch(() => {
+ // Waveform loading failed (e.g. corrupt file, unsupported format).
+ // Fail silently — a missing waveform is preferable to an error state.
+ if (!isCancelled) {
+ clearCanvas();
+ }
+ });
- load();
return () => {
isCancelled = true;
};
- }, [audioUrl, audioBuffer, drawVisible]);
+ }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
+ useLayoutEffect(() => {
+ drawVisible();
+ }, [
+ drawVisible,
+ gainSamples,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ ]);
useEffect(() => {
const container = containerRef.current;
- if (!container) return;
+ if (!container) {
+ return;
+ }
scrollParentRef.current = findScrollParent({ element: container });
const scrollParent = scrollParentRef.current;
- if (!scrollParent) return;
+ if (!scrollParent) {
+ return;
+ }
- scrollParent.addEventListener("scroll", drawVisible, { passive: true });
- return () => scrollParent.removeEventListener("scroll", drawVisible);
+ const handleScroll = () => {
+ drawVisible();
+ };
+ scrollParent.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", handleScroll);
}, [drawVisible]);
const onResize = useCallback(
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts
index e7fc1f11..aae82ca3 100644
--- a/apps/web/src/components/editor/panels/timeline/theme.ts
+++ b/apps/web/src/components/editor/panels/timeline/theme.ts
@@ -11,7 +11,7 @@ export const TIMELINE_TRACK_THEME: Record<
text: { elementClassName: "bg-[#5DBAA0]" },
audio: {
elementClassName: "bg-[#8F5DBA]",
- waveformColor: "rgba(255, 255, 255, 0.5)",
+ waveformColor: "rgba(255, 255, 255, 0.7)",
},
graphic: { elementClassName: "bg-[#BA5D7A]" },
effect: { elementClassName: "bg-[#5d93ba]" },
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 9283c651..75a2dfa8 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -1,8 +1,10 @@
"use client";
+import { createContext, useContext } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
+import { AudioVolumeLine } from "./audio-volume-line";
import { useElementPreview } from "@/hooks/use-element-preview";
import {
useKeyframeDrag,
@@ -45,6 +47,9 @@ import {
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
+import { getTimelinePixelsPerSecond } from "@/lib/timeline";
+import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
+import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
import {
getActionDefinition,
type TAction,
@@ -89,6 +94,8 @@ import {
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
+
+const PixelsPerSecondContext = createContext<number | null>(null);
const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
@@ -264,6 +271,7 @@ export function TimelineElement({
time: displayedDuration,
zoomLevel,
});
+ const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const elementLeft = timelineTimeToSnappedPixels({
time: displayedStartTime,
zoomLevel,
@@ -364,137 +372,141 @@ export function TimelineElement({
) : null;
return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className="absolute top-0 select-none"
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- height:
- expandedRows.length > 0
- ? `${baseTrackHeight + expansionHeight}px`
- : "100%",
- transform:
- isBeingDragged && dragState.isDragging
- ? `translate3d(0, ${dragOffsetY}px, 0)`
- : undefined,
- }}
- >
- <ElementInner
- element={element}
- displayElement={renderElement}
- track={track}
- isSelected={isSelected}
- isExpanded={expandedRows.length > 0}
- baseTrackHeight={baseTrackHeight}
- expandedContent={expandedContent}
- onElementClick={onElementClick}
- onElementMouseDown={onElementMouseDown}
- onResizeStart={onResizeStart}
- isDropTarget={isDropTarget}
- />
- {isSelected && (
- <div
- className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
- style={{ height: `${baseTrackHeight}px` }}
- >
- <KeyframeIndicators
- indicators={keyframeIndicators}
- dragState={keyframeDragState}
- displayedStartTime={displayedStartTime}
- elementLeft={elementLeft}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- />
- </div>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-64">
- <ActionMenuItem
- action="split"
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- >
- Split
- </ActionMenuItem>
- <CopyMenuItem />
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Duplicate
- </ActionMenuItem>
- )}
- {canElementHaveAudio(element) && hasAudio && (
- <MuteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- isMuted={isMuted}
- />
- )}
- {canToggleCurrentSourceAudio && (
- <ContextMenuItem
- icon={
- <HugeiconsIcon
- icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
- />
- }
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("toggle-source-audio");
+ <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute top-0 select-none"
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ height:
+ expandedRows.length > 0
+ ? `${baseTrackHeight + expansionHeight}px`
+ : "100%",
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
}}
>
- {sourceAudioLabel}
- </ContextMenuItem>
- )}
- {canElementBeHidden(element) && (
- <VisibilityMenuItem
- element={element}
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- />
- )}
- {hasKeyframes && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={KeyframeIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- toggleElementExpanded(element.id);
- }}
+ <ElementInner
+ element={element}
+ displayElement={renderElement}
+ track={track}
+ isSelected={isSelected}
+ isExpanded={expandedRows.length > 0}
+ baseTrackHeight={baseTrackHeight}
+ expandedContent={expandedContent}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ onResizeStart={onResizeStart}
+ isDropTarget={isDropTarget}
+ />
+ {isSelected && (
+ <div
+ className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
+ style={{ height: `${baseTrackHeight}px` }}
+ >
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
>
- {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
- </ContextMenuItem>
- )}
- {selectedElements.length === 1 && hasMediaId(element) && (
- <>
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canToggleCurrentSourceAudio && (
<ContextMenuItem
- icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event: React.MouseEvent) =>
- handleRevealInMedia({ event })
+ icon={
+ <HugeiconsIcon
+ icon={
+ isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
+ }
+ />
}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("toggle-source-audio");
+ }}
>
- Reveal media
+ {sourceAudioLabel}
</ContextMenuItem>
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {hasKeyframes && (
<ContextMenuItem
- icon={<HugeiconsIcon icon={Exchange01Icon} />}
- disabled
+ icon={<HugeiconsIcon icon={KeyframeIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ toggleElementExpanded(element.id);
+ }}
>
- Replace media
+ {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
</ContextMenuItem>
- </>
- )}
- <ContextMenuSeparator />
- <DeleteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- elementType={element.type}
- selectedCount={selectedElements.length}
- />
- </ContextMenuContent>
- </ContextMenu>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ </PixelsPerSecondContext.Provider>
);
}
@@ -973,7 +985,19 @@ function GraphicElementContent({
);
}
-function AudioElementContent({ element }: { element: AudioElement }) {
+function AudioElementContent({
+ element,
+ trackId,
+}: {
+ element: AudioElement;
+ trackId: string;
+}) {
+ const pixelsPerSecond = useContext(PixelsPerSecondContext);
+ if (pixelsPerSecond === null) {
+ throw new Error(
+ "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider",
+ );
+ }
const mediaAssets = useEditor((e) => e.media.getAssets());
const mediaAsset =
element.sourceType === "upload"
@@ -984,29 +1008,53 @@ function AudioElementContent({ element }: { element: AudioElement }) {
element.sourceType === "library" ? element.buffer : undefined;
const audioUrl =
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const sourceFile =
+ element.sourceType === "upload" ? mediaAsset?.file : undefined;
+ const sourceKey =
+ element.sourceType === "upload"
+ ? buildWaveformSourceKey({ kind: "media", id: element.mediaId })
+ : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl });
const mediaLabel = mediaAsset?.name ?? element.name;
const gainSamples = useMemo(
() =>
- buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
+ buildWaveformGainSamples({
+ element,
+ count: WAVEFORM_GAIN_SAMPLE_COUNT,
+ }),
[element],
);
-
- if (audioBuffer || audioUrl) {
+ if (audioBuffer || audioUrl || sourceFile) {
return (
- <div className="relative size-full">
- <AudioWaveform
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- gainSamples={gainSamples}
- color={TIMELINE_TRACK_THEME.audio.waveformColor}
- />
+ <div className="group/audio relative size-full">
<MediaElementHeader name={mediaLabel} hasFade={false} />
+ <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden">
+ <AudioWaveform
+ sourceKey={sourceKey}
+ sourceFile={sourceFile}
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ gainSamples={gainSamples}
+ pixelsPerSecond={pixelsPerSecond}
+ clipDurationSec={element.duration / TICKS_PER_SECOND}
+ retime={element.retime}
+ sourceStartSec={element.trimStart / TICKS_PER_SECOND}
+ color={TIMELINE_TRACK_THEME.audio.waveformColor}
+ />
+ <AudioVolumeLine element={element} trackId={trackId} />
+ </div>
</div>
);
}
return (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ <div className="group/audio relative size-full">
+ <div className="flex size-full items-center pl-2">
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ </div>
+ <AudioVolumeLine element={element} trackId={trackId} />
+ </div>
);
}
@@ -1108,7 +1156,7 @@ function MediaElementHeader({
return (
<div
className={cn(
- "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1",
hasFade && "from-black/30 to-transparent",
)}
>
@@ -1133,7 +1181,7 @@ function ElementContent({ element, track }: ElementContentProps) {
case "graphic":
return <GraphicElementContent element={element} />;
case "audio":
- return <AudioElementContent element={element} />;
+ return <AudioElementContent element={element} trackId={track.id} />;
case "video":
case "image":
return <TiledMediaContent element={element} track={track} />;