Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 186 additions & 54 deletions apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
createMemo,
createRoot,
createSignal,
For,
Index,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js";
Expand Down Expand Up @@ -44,20 +44,33 @@ 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;

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)
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -156,16 +239,59 @@ 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);
});
Comment on lines +272 to +280
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The setTimeout in onMount introduces a potential race condition.

The 300ms delay with lastRenderKey = "" forces a re-render after mount, but if the component unmounts within that window, the timeout callback will still execute. While onCleanup cancels the RAF, it doesn't cancel this timeout.

🔎 Clear the timeout on cleanup
+ let mountTimeoutId: number | null = null;
+
  onMount(() => {
-   setTimeout(() => {
+   mountTimeoutId = window.setTimeout(() => {
      lastRenderKey = "";
      if (rafId !== null) {
        cancelAnimationFrame(rafId);
      }
      rafId = requestAnimationFrame(renderCanvas);
    }, 300);
  });

  onCleanup(() => {
+   if (mountTimeoutId !== null) {
+     clearTimeout(mountTimeoutId);
+   }
    if (rafId !== null) {
      cancelAnimationFrame(rafId);
    }
  });
🤖 Prompt for AI Agents
In apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx around lines 272 to
280, the onMount uses setTimeout which can fire after the component unmounts
causing a race; store the timeout id (e.g. let mountTimer: number | null),
assign mountTimer = window.setTimeout(...), and in onCleanup call
clearTimeout(mountTimer) (and set mountTimer = null) in addition to the existing
cancelAnimationFrame logic so the delayed callback cannot run after unmount.


onCleanup(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
});

return (
<canvas
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}
/>
);
Expand Down Expand Up @@ -511,6 +637,7 @@ export function ClipTrack(
micWaveform={micWaveform()}
systemWaveform={systemAudioWaveform()}
segment={segment()}
segmentOffset={prevDuration()}
/>
)}

Expand Down Expand Up @@ -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 (
<For each={markings()}>
{(marking) => (
<div
style={{
transform: `translateX(${
(marking - props.segment.start) / secsPerPixel()
}px)`,
}}
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"
/>
)}
</For>
<Index each={Array.from({ length: markingParams().count })}>
{(_, index) => {
const marking = () => getMarkingTime(index);
const translateX = () =>
(marking() - props.segment.start) / secsPerPixel();

return (
<div
style={{
transform: `translateX(${translateX()}px)`,
}}
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"
/>
);
}}
</Index>
);
}

Expand Down
Loading