From 36cc6d5023f9cabdd60f739247bf2a545b2c9bfd Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Thu, 18 Dec 2025 19:51:26 +0000
Subject: [PATCH] Optimize timeline waveform rendering and markings
---
.../src/routes/editor/Timeline/ClipTrack.tsx | 240 ++++++++++++++----
.../src/routes/editor/Timeline/index.tsx | 123 +++++----
2 files changed, 266 insertions(+), 97 deletions(-)
diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
index 051b671b0f..25fe95a44a 100644
--- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
+++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
@@ -9,10 +9,10 @@ import {
createMemo,
createRoot,
createSignal,
- For,
Index,
Match,
onCleanup,
+ onMount,
Show,
Switch,
} from "solid-js";
@@ -44,9 +44,12 @@ function gainToScale(gain?: number) {
return Math.max(0, 1 + value / -WAVEFORM_MIN_DB);
}
+const MAX_WAVEFORM_SAMPLES = 6000;
+
function createWaveformPath(
segment: { start: number; end: number },
- waveform?: number[],
+ waveform: number[] | undefined,
+ targetSamples: number,
) {
if (typeof Path2D === "undefined") return;
if (!waveform || waveform.length === 0) return;
@@ -54,10 +57,20 @@ function createWaveformPath(
const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP);
if (!Number.isFinite(duration) || duration <= 0) return;
+ const nativeSamples = Math.ceil(duration / WAVEFORM_SAMPLE_STEP) + 1;
+ const numSamples = Math.min(
+ Math.max(targetSamples, 50),
+ MAX_WAVEFORM_SAMPLES,
+ nativeSamples,
+ );
+
+ const timeStep = duration / numSamples;
+
const path = new Path2D();
path.moveTo(0, 1);
- const amplitudeAt = (index: number) => {
+ const amplitudeAt = (time: number) => {
+ const index = Math.floor(time * 10);
const sample = waveform[index];
const db =
typeof sample === "number" && Number.isFinite(sample)
@@ -70,17 +83,13 @@ function createWaveformPath(
const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25);
- for (
- let time = segment.start;
- time <= segment.end + WAVEFORM_SAMPLE_STEP;
- time += WAVEFORM_SAMPLE_STEP
- ) {
- const index = Math.floor(time * 10);
- const normalizedX = (index / 10 - segment.start) / duration;
- const prevX =
- (index / 10 - WAVEFORM_SAMPLE_STEP - segment.start) / duration;
- const y = 1 - amplitudeAt(index);
- const prevY = 1 - amplitudeAt(index - 1);
+ for (let i = 0; i <= numSamples; i++) {
+ const time = segment.start + i * timeStep;
+ const normalizedX = (time - segment.start) / duration;
+ const prevTime = time - timeStep;
+ const prevX = Math.max(0, (prevTime - segment.start) / duration);
+ const y = 1 - amplitudeAt(time);
+ const prevY = 1 - amplitudeAt(prevTime);
const cpX1 = prevX + controlStep / 2;
const cpX2 = normalizedX - controlStep / 2;
path.bezierCurveTo(cpX1, prevY, cpX2, y, normalizedX, y);
@@ -108,41 +117,115 @@ function formatTime(totalSeconds: number): string {
}
}
+const MAX_CANVAS_WIDTH = 2000;
+const SAMPLES_PER_PIXEL = 2;
+
function WaveformCanvas(props: {
systemWaveform?: number[];
micWaveform?: number[];
segment: { start: number; end: number };
+ segmentOffset: number;
}) {
- const { project } = useEditorContext();
+ const { project, editorState } = useEditorContext();
const { width } = useSegmentContext();
- const segmentRange = createMemo(() => ({
- start: props.segment.start,
- end: props.segment.end,
- }));
- const micPath = createMemo(() =>
- createWaveformPath(segmentRange(), props.micWaveform),
- );
- const systemPath = createMemo(() =>
- createWaveformPath(segmentRange(), props.systemWaveform),
- );
+ const { timelineBounds } = useTimelineContext();
let canvas: HTMLCanvasElement | undefined;
+ let rafId: number | null = null;
+ let lastRenderKey = "";
- createEffect(() => {
+ const renderCanvas = () => {
+ rafId = null;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
- const canvasWidth = Math.max(width(), 1);
+ const segmentDuration = props.segment.end - props.segment.start;
+ const fullSegmentWidth = width();
+
+ if (fullSegmentWidth < 1 || segmentDuration <= 0) {
+ return;
+ }
+
+ const useVirtualization = fullSegmentWidth > MAX_CANVAS_WIDTH;
+
+ let canvasWidth: number;
+ let leftOffsetPx: number;
+ let renderWidth: number;
+ let renderSegment: { start: number; end: number };
+
+ if (useVirtualization) {
+ const viewportWidth = timelineBounds.width ?? 800;
+ const transform = editorState.timeline.transform;
+ const viewStart = transform.position;
+ const viewEnd = viewStart + transform.zoom;
+
+ const segStart = props.segmentOffset;
+ const segEnd = segStart + segmentDuration;
+
+ const visibleStart = Math.max(viewStart, segStart);
+ const visibleEnd = Math.min(viewEnd, segEnd);
+
+ if (visibleEnd <= visibleStart) {
+ canvas.width = 1;
+ canvas.style.left = "0px";
+ canvas.style.width = "1px";
+ return;
+ }
+
+ const visibleStartInSegment = visibleStart - segStart;
+ const visibleEndInSegment = visibleEnd - segStart;
+
+ const pxPerSec = fullSegmentWidth / segmentDuration;
+ const visibleWidthPx = Math.min(
+ (visibleEndInSegment - visibleStartInSegment) * pxPerSec,
+ viewportWidth + 200,
+ );
+
+ canvasWidth = Math.min(
+ Math.max(Math.ceil(visibleWidthPx), 1),
+ MAX_CANVAS_WIDTH,
+ );
+ leftOffsetPx = visibleStartInSegment * pxPerSec;
+ renderWidth = visibleWidthPx;
+ renderSegment = {
+ start: props.segment.start + visibleStartInSegment,
+ end: props.segment.start + visibleEndInSegment,
+ };
+ } else {
+ canvasWidth = Math.max(Math.ceil(fullSegmentWidth), 1);
+ leftOffsetPx = 0;
+ renderWidth = fullSegmentWidth;
+ renderSegment = {
+ start: props.segment.start,
+ end: props.segment.end,
+ };
+ }
+
+ const renderKey = `${canvasWidth}-${renderSegment.start.toFixed(2)}-${renderSegment.end.toFixed(2)}`;
+ if (renderKey === lastRenderKey) {
+ return;
+ }
+ lastRenderKey = renderKey;
+
canvas.width = canvasWidth;
+ canvas.style.left = `${leftOffsetPx}px`;
+ canvas.style.width = `${renderWidth}px`;
+
const canvasHeight = canvas.height;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
- const drawPath = (
- path: Path2D | undefined,
+ const numSamples = Math.min(
+ Math.ceil(canvasWidth * SAMPLES_PER_PIXEL),
+ MAX_WAVEFORM_SAMPLES,
+ );
+
+ const drawWaveform = (
+ waveform: number[] | undefined,
color: string,
gain?: number,
) => {
+ const path = createWaveformPath(renderSegment, waveform, numSamples);
if (!path) return;
const scale = gainToScale(gain);
if (scale <= 0) return;
@@ -156,8 +239,50 @@ function WaveformCanvas(props: {
ctx.restore();
};
- drawPath(micPath(), "rgba(255,255,255,0.4)", project.audio.micVolumeDb);
- drawPath(systemPath(), "rgba(255,150,0,0.5)", project.audio.systemVolumeDb);
+ drawWaveform(
+ props.micWaveform,
+ "rgba(255,255,255,0.4)",
+ project.audio.micVolumeDb,
+ );
+ drawWaveform(
+ props.systemWaveform,
+ "rgba(255,150,0,0.5)",
+ project.audio.systemVolumeDb,
+ );
+ };
+
+ createEffect(() => {
+ width();
+ timelineBounds.width;
+ editorState.timeline.transform.position;
+ editorState.timeline.transform.zoom;
+ props.segment.start;
+ props.segment.end;
+ props.micWaveform;
+ props.systemWaveform;
+ project.audio.micVolumeDb;
+ project.audio.systemVolumeDb;
+
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ }
+ rafId = requestAnimationFrame(renderCanvas);
+ });
+
+ onMount(() => {
+ setTimeout(() => {
+ lastRenderKey = "";
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ }
+ rafId = requestAnimationFrame(renderCanvas);
+ }, 300);
+ });
+
+ onCleanup(() => {
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ }
});
return (
@@ -165,7 +290,8 @@ function WaveformCanvas(props: {
ref={(el) => {
canvas = el;
}}
- class="absolute inset-0 w-full h-full pointer-events-none"
+ class="absolute top-0 h-full pointer-events-none"
+ style={{ left: "0px" }}
height={CANVAS_HEIGHT}
/>
);
@@ -511,6 +637,7 @@ export function ClipTrack(
micWaveform={micWaveform()}
systemWaveform={systemAudioWaveform()}
segment={segment()}
+ segmentOffset={prevDuration()}
/>
)}
@@ -764,35 +891,40 @@ function Markings(props: { segment: TimelineSegment; prevDuration: number }) {
const { editorState } = useEditorContext();
const { secsPerPixel, markingResolution } = useTimelineContext();
- const markings = () => {
- const resolution = markingResolution();
+ const transform = () => editorState.timeline.transform;
- const { transform } = editorState.timeline;
+ const markingParams = () => {
+ const resolution = markingResolution();
const visibleMin =
- transform.position - props.prevDuration + props.segment.start;
- const visibleMax = visibleMin + transform.zoom;
-
+ transform().position - props.prevDuration + props.segment.start;
+ const visibleMax = visibleMin + transform().zoom;
const start = Math.floor(visibleMin / resolution);
+ const count = Math.ceil(visibleMax / resolution) - start;
+ return { resolution, start, count };
+ };
- return Array.from(
- { length: Math.ceil(visibleMax / resolution) - start },
- (_, i) => (start + i) * resolution,
- );
+ const getMarkingTime = (index: number) => {
+ const { resolution, start } = markingParams();
+ return (start + index) * resolution;
};
return (
-