Skip to content

Commit a49cb4a

Browse files
Merge pull request #1460 from CapSoftware/editor-frontend-speed
Optimize timeline waveform rendering and markings
2 parents 828b874 + 36cc6d5 commit a49cb4a

File tree

2 files changed

+266
-97
lines changed

2 files changed

+266
-97
lines changed

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 186 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {
99
createMemo,
1010
createRoot,
1111
createSignal,
12-
For,
1312
Index,
1413
Match,
1514
onCleanup,
15+
onMount,
1616
Show,
1717
Switch,
1818
} from "solid-js";
@@ -44,20 +44,33 @@ function gainToScale(gain?: number) {
4444
return Math.max(0, 1 + value / -WAVEFORM_MIN_DB);
4545
}
4646

47+
const MAX_WAVEFORM_SAMPLES = 6000;
48+
4749
function createWaveformPath(
4850
segment: { start: number; end: number },
49-
waveform?: number[],
51+
waveform: number[] | undefined,
52+
targetSamples: number,
5053
) {
5154
if (typeof Path2D === "undefined") return;
5255
if (!waveform || waveform.length === 0) return;
5356

5457
const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP);
5558
if (!Number.isFinite(duration) || duration <= 0) return;
5659

60+
const nativeSamples = Math.ceil(duration / WAVEFORM_SAMPLE_STEP) + 1;
61+
const numSamples = Math.min(
62+
Math.max(targetSamples, 50),
63+
MAX_WAVEFORM_SAMPLES,
64+
nativeSamples,
65+
);
66+
67+
const timeStep = duration / numSamples;
68+
5769
const path = new Path2D();
5870
path.moveTo(0, 1);
5971

60-
const amplitudeAt = (index: number) => {
72+
const amplitudeAt = (time: number) => {
73+
const index = Math.floor(time * 10);
6174
const sample = waveform[index];
6275
const db =
6376
typeof sample === "number" && Number.isFinite(sample)
@@ -70,17 +83,13 @@ function createWaveformPath(
7083

7184
const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25);
7285

73-
for (
74-
let time = segment.start;
75-
time <= segment.end + WAVEFORM_SAMPLE_STEP;
76-
time += WAVEFORM_SAMPLE_STEP
77-
) {
78-
const index = Math.floor(time * 10);
79-
const normalizedX = (index / 10 - segment.start) / duration;
80-
const prevX =
81-
(index / 10 - WAVEFORM_SAMPLE_STEP - segment.start) / duration;
82-
const y = 1 - amplitudeAt(index);
83-
const prevY = 1 - amplitudeAt(index - 1);
86+
for (let i = 0; i <= numSamples; i++) {
87+
const time = segment.start + i * timeStep;
88+
const normalizedX = (time - segment.start) / duration;
89+
const prevTime = time - timeStep;
90+
const prevX = Math.max(0, (prevTime - segment.start) / duration);
91+
const y = 1 - amplitudeAt(time);
92+
const prevY = 1 - amplitudeAt(prevTime);
8493
const cpX1 = prevX + controlStep / 2;
8594
const cpX2 = normalizedX - controlStep / 2;
8695
path.bezierCurveTo(cpX1, prevY, cpX2, y, normalizedX, y);
@@ -108,41 +117,115 @@ function formatTime(totalSeconds: number): string {
108117
}
109118
}
110119

120+
const MAX_CANVAS_WIDTH = 2000;
121+
const SAMPLES_PER_PIXEL = 2;
122+
111123
function WaveformCanvas(props: {
112124
systemWaveform?: number[];
113125
micWaveform?: number[];
114126
segment: { start: number; end: number };
127+
segmentOffset: number;
115128
}) {
116-
const { project } = useEditorContext();
129+
const { project, editorState } = useEditorContext();
117130
const { width } = useSegmentContext();
118-
const segmentRange = createMemo(() => ({
119-
start: props.segment.start,
120-
end: props.segment.end,
121-
}));
122-
const micPath = createMemo(() =>
123-
createWaveformPath(segmentRange(), props.micWaveform),
124-
);
125-
const systemPath = createMemo(() =>
126-
createWaveformPath(segmentRange(), props.systemWaveform),
127-
);
131+
const { timelineBounds } = useTimelineContext();
128132

129133
let canvas: HTMLCanvasElement | undefined;
134+
let rafId: number | null = null;
135+
let lastRenderKey = "";
130136

131-
createEffect(() => {
137+
const renderCanvas = () => {
138+
rafId = null;
132139
if (!canvas) return;
133140
const ctx = canvas.getContext("2d");
134141
if (!ctx) return;
135142

136-
const canvasWidth = Math.max(width(), 1);
143+
const segmentDuration = props.segment.end - props.segment.start;
144+
const fullSegmentWidth = width();
145+
146+
if (fullSegmentWidth < 1 || segmentDuration <= 0) {
147+
return;
148+
}
149+
150+
const useVirtualization = fullSegmentWidth > MAX_CANVAS_WIDTH;
151+
152+
let canvasWidth: number;
153+
let leftOffsetPx: number;
154+
let renderWidth: number;
155+
let renderSegment: { start: number; end: number };
156+
157+
if (useVirtualization) {
158+
const viewportWidth = timelineBounds.width ?? 800;
159+
const transform = editorState.timeline.transform;
160+
const viewStart = transform.position;
161+
const viewEnd = viewStart + transform.zoom;
162+
163+
const segStart = props.segmentOffset;
164+
const segEnd = segStart + segmentDuration;
165+
166+
const visibleStart = Math.max(viewStart, segStart);
167+
const visibleEnd = Math.min(viewEnd, segEnd);
168+
169+
if (visibleEnd <= visibleStart) {
170+
canvas.width = 1;
171+
canvas.style.left = "0px";
172+
canvas.style.width = "1px";
173+
return;
174+
}
175+
176+
const visibleStartInSegment = visibleStart - segStart;
177+
const visibleEndInSegment = visibleEnd - segStart;
178+
179+
const pxPerSec = fullSegmentWidth / segmentDuration;
180+
const visibleWidthPx = Math.min(
181+
(visibleEndInSegment - visibleStartInSegment) * pxPerSec,
182+
viewportWidth + 200,
183+
);
184+
185+
canvasWidth = Math.min(
186+
Math.max(Math.ceil(visibleWidthPx), 1),
187+
MAX_CANVAS_WIDTH,
188+
);
189+
leftOffsetPx = visibleStartInSegment * pxPerSec;
190+
renderWidth = visibleWidthPx;
191+
renderSegment = {
192+
start: props.segment.start + visibleStartInSegment,
193+
end: props.segment.start + visibleEndInSegment,
194+
};
195+
} else {
196+
canvasWidth = Math.max(Math.ceil(fullSegmentWidth), 1);
197+
leftOffsetPx = 0;
198+
renderWidth = fullSegmentWidth;
199+
renderSegment = {
200+
start: props.segment.start,
201+
end: props.segment.end,
202+
};
203+
}
204+
205+
const renderKey = `${canvasWidth}-${renderSegment.start.toFixed(2)}-${renderSegment.end.toFixed(2)}`;
206+
if (renderKey === lastRenderKey) {
207+
return;
208+
}
209+
lastRenderKey = renderKey;
210+
137211
canvas.width = canvasWidth;
212+
canvas.style.left = `${leftOffsetPx}px`;
213+
canvas.style.width = `${renderWidth}px`;
214+
138215
const canvasHeight = canvas.height;
139216
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
140217

141-
const drawPath = (
142-
path: Path2D | undefined,
218+
const numSamples = Math.min(
219+
Math.ceil(canvasWidth * SAMPLES_PER_PIXEL),
220+
MAX_WAVEFORM_SAMPLES,
221+
);
222+
223+
const drawWaveform = (
224+
waveform: number[] | undefined,
143225
color: string,
144226
gain?: number,
145227
) => {
228+
const path = createWaveformPath(renderSegment, waveform, numSamples);
146229
if (!path) return;
147230
const scale = gainToScale(gain);
148231
if (scale <= 0) return;
@@ -156,16 +239,59 @@ function WaveformCanvas(props: {
156239
ctx.restore();
157240
};
158241

159-
drawPath(micPath(), "rgba(255,255,255,0.4)", project.audio.micVolumeDb);
160-
drawPath(systemPath(), "rgba(255,150,0,0.5)", project.audio.systemVolumeDb);
242+
drawWaveform(
243+
props.micWaveform,
244+
"rgba(255,255,255,0.4)",
245+
project.audio.micVolumeDb,
246+
);
247+
drawWaveform(
248+
props.systemWaveform,
249+
"rgba(255,150,0,0.5)",
250+
project.audio.systemVolumeDb,
251+
);
252+
};
253+
254+
createEffect(() => {
255+
width();
256+
timelineBounds.width;
257+
editorState.timeline.transform.position;
258+
editorState.timeline.transform.zoom;
259+
props.segment.start;
260+
props.segment.end;
261+
props.micWaveform;
262+
props.systemWaveform;
263+
project.audio.micVolumeDb;
264+
project.audio.systemVolumeDb;
265+
266+
if (rafId !== null) {
267+
cancelAnimationFrame(rafId);
268+
}
269+
rafId = requestAnimationFrame(renderCanvas);
270+
});
271+
272+
onMount(() => {
273+
setTimeout(() => {
274+
lastRenderKey = "";
275+
if (rafId !== null) {
276+
cancelAnimationFrame(rafId);
277+
}
278+
rafId = requestAnimationFrame(renderCanvas);
279+
}, 300);
280+
});
281+
282+
onCleanup(() => {
283+
if (rafId !== null) {
284+
cancelAnimationFrame(rafId);
285+
}
161286
});
162287

163288
return (
164289
<canvas
165290
ref={(el) => {
166291
canvas = el;
167292
}}
168-
class="absolute inset-0 w-full h-full pointer-events-none"
293+
class="absolute top-0 h-full pointer-events-none"
294+
style={{ left: "0px" }}
169295
height={CANVAS_HEIGHT}
170296
/>
171297
);
@@ -511,6 +637,7 @@ export function ClipTrack(
511637
micWaveform={micWaveform()}
512638
systemWaveform={systemAudioWaveform()}
513639
segment={segment()}
640+
segmentOffset={prevDuration()}
514641
/>
515642
)}
516643

@@ -764,35 +891,40 @@ function Markings(props: { segment: TimelineSegment; prevDuration: number }) {
764891
const { editorState } = useEditorContext();
765892
const { secsPerPixel, markingResolution } = useTimelineContext();
766893

767-
const markings = () => {
768-
const resolution = markingResolution();
894+
const transform = () => editorState.timeline.transform;
769895

770-
const { transform } = editorState.timeline;
896+
const markingParams = () => {
897+
const resolution = markingResolution();
771898
const visibleMin =
772-
transform.position - props.prevDuration + props.segment.start;
773-
const visibleMax = visibleMin + transform.zoom;
774-
899+
transform().position - props.prevDuration + props.segment.start;
900+
const visibleMax = visibleMin + transform().zoom;
775901
const start = Math.floor(visibleMin / resolution);
902+
const count = Math.ceil(visibleMax / resolution) - start;
903+
return { resolution, start, count };
904+
};
776905

777-
return Array.from(
778-
{ length: Math.ceil(visibleMax / resolution) - start },
779-
(_, i) => (start + i) * resolution,
780-
);
906+
const getMarkingTime = (index: number) => {
907+
const { resolution, start } = markingParams();
908+
return (start + index) * resolution;
781909
};
782910

783911
return (
784-
<For each={markings()}>
785-
{(marking) => (
786-
<div
787-
style={{
788-
transform: `translateX(${
789-
(marking - props.segment.start) / secsPerPixel()
790-
}px)`,
791-
}}
792-
class="absolute z-10 w-px h-12 bg-gradient-to-b from-transparent to-transparent via-white-transparent-40 dark:via-black-transparent-60"
793-
/>
794-
)}
795-
</For>
912+
<Index each={Array.from({ length: markingParams().count })}>
913+
{(_, index) => {
914+
const marking = () => getMarkingTime(index);
915+
const translateX = () =>
916+
(marking() - props.segment.start) / secsPerPixel();
917+
918+
return (
919+
<div
920+
style={{
921+
transform: `translateX(${translateX()}px)`,
922+
}}
923+
class="absolute z-10 w-px h-12 bg-gradient-to-b from-transparent to-transparent via-white-transparent-40 dark:via-black-transparent-60"
924+
/>
925+
);
926+
}}
927+
</Index>
796928
);
797929
}
798930

0 commit comments

Comments
 (0)