From 011f51051c9750706978d6c89ce84dd15560c335 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 18 Apr 2026 01:40:18 +0200 Subject: feat: clip volume line and timeline waveform improvements --- .../editor/panels/timeline/audio-volume-line.tsx | 275 ++++++++++++++++++ .../editor/panels/timeline/audio-waveform.tsx | 315 ++++++++++++++------ .../src/components/editor/panels/timeline/theme.ts | 2 +- .../editor/panels/timeline/timeline-element.tsx | 318 ++++++++++++--------- 4 files changed, 683 insertions(+), 227 deletions(-) create mode 100644 apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx (limited to 'apps/web/src/components') 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(null); + const activePointerIdRef = useRef(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) => { + 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 ( +
+
+
+ {/* 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 */} +
+ {isDragging && + tooltipClientPos && + createPortal( +
+ {volumeLabel} +
, + document.body, + )} +
+
+ ); +} 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(null); const containerRef = useRef(null); - const bufferRef = useRef(null); - const globalMaxRef = useRef(1); + const summaryRef = useRef(null); const gainSamplesRef = useRef(gainSamples); + const pixelsPerSecondRef = useRef(pixelsPerSecond); + const clipDurationSecRef = useRef(clipDurationSec); + const retimeRef = useRef(retime); + const sourceStartSecRef = useRef(sourceStartSec); const scrollParentRef = useRef(null); const heightRef = useRef(0); + const lastRenderSignatureRef = useRef(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 peaks = extractRmsRange({ - buffer, - count: barCount, - startSample, - endSample, - globalMax: globalMaxRef.current, + const backingScaleX = dpr; + const backingScaleY = canvasH / height; + + 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; - - 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 { + summaryRef.current = null; + clearCanvas(); + + 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(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 ( - - -
0 - ? `${baseTrackHeight + expansionHeight}px` - : "100%", - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - 0} - baseTrackHeight={baseTrackHeight} - expandedContent={expandedContent} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - onResizeStart={onResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( -
- -
- )} -
-
- - } - > - Split - - - {selectedElements.length === 1 && ( - } - > - Duplicate - - )} - {canElementHaveAudio(element) && hasAudio && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canToggleCurrentSourceAudio && ( - - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("toggle-source-audio"); + + + +
0 + ? `${baseTrackHeight + expansionHeight}px` + : "100%", + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, }} > - {sourceAudioLabel} - - )} - {canElementBeHidden(element) && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {hasKeyframes && ( - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - toggleElementExpanded(element.id); - }} + 0} + baseTrackHeight={baseTrackHeight} + expandedContent={expandedContent} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + onResizeStart={onResizeStart} + isDropTarget={isDropTarget} + /> + {isSelected && ( +
+ +
+ )} +
+
+ + } > - {isExpanded ? "Collapse keyframes" : "Expand keyframes"} -
- )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> + Split + + + {selectedElements.length === 1 && ( + } + > + Duplicate + + )} + {canElementHaveAudio(element) && hasAudio && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canToggleCurrentSourceAudio && ( } - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) + icon={ + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} > - Reveal media + {sourceAudioLabel} + )} + {canElementBeHidden(element) && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {hasKeyframes && ( } - disabled + icon={} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} > - Replace media + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} - - )} - - 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> -
-
+ )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + } + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } + > + Reveal media + + } + disabled + > + Replace media + + + )} + + 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + + + ); } @@ -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 ( -
- +
+
+ + +
); } return ( - {element.name} +
+
+ + {element.name} + +
+ +
); } @@ -1108,7 +1156,7 @@ function MediaElementHeader({ return (
@@ -1133,7 +1181,7 @@ function ElementContent({ element, track }: ElementContentProps) { case "graphic": return ; case "audio": - return ; + return ; case "video": case "image": return ; -- cgit v1.3.1