diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-18 01:40:18 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-18 01:40:18 +0200 |
| commit | 011f51051c9750706978d6c89ce84dd15560c335 (patch) | |
| tree | 2e7b23ebebb798934d6cb6aeace855f31699b633 /apps/web/src/components/editor/panels/timeline | |
| parent | c46d28891fee62ca2d3132da876af6411a41d9da (diff) | |
| download | opencut-011f51051c9750706978d6c89ce84dd15560c335.tar.gz | |
feat: clip volume line and timeline waveform improvements
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
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} />; |
