diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index be475809..db90b746 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -141,7 +141,7 @@ export function AnnotationSettingsPanel({
return (
-
+
{t("annotations.settings")}
@@ -786,6 +786,7 @@ export function AnnotationSettingsPanel({
{t("annotations.tipCycleBackward")}
+
-
);
}
diff --git a/src/components/video-editor/audio/clipAudio.ts b/src/components/video-editor/audio/clipAudio.ts
index d539ad4b..c6bf7409 100644
--- a/src/components/video-editor/audio/clipAudio.ts
+++ b/src/components/video-editor/audio/clipAudio.ts
@@ -1,14 +1,13 @@
-import { mapSourceTimeToTimelineTime } from "../types";
+import { getClipSourceEndMs, sortClipRegions } from "../types";
import type { ClipRegion } from "../types";
export function getActiveClipIdAtSourceTime(
sourceTimeSeconds: number,
clipRegions: ClipRegion[],
): string | null {
- const sourceMs = sourceTimeSeconds * 1000;
- const timelineMs = mapSourceTimeToTimelineTime(sourceMs, clipRegions);
- const activeClip = clipRegions.find(
- (clip) => timelineMs >= clip.startMs && timelineMs < clip.endMs,
+ const sourceMs = Math.round(sourceTimeSeconds * 1000);
+ const activeClip = sortClipRegions(clipRegions).find(
+ (clip) => sourceMs >= clip.startMs && sourceMs < getClipSourceEndMs(clip),
);
return activeClip?.id ?? null;
}
diff --git a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts
index 2e776eb2..799d6db9 100644
--- a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts
+++ b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts
@@ -120,7 +120,9 @@ export function useSourceAudioTrackSettings({
[selectedClipId]: {
...prevClip,
[id]: {
- volume: Math.max(0, Math.min(2, volume)),
+ volume: Number.isFinite(volume)
+ ? Math.max(0, Math.min(2, volume))
+ : (prevClip[id]?.volume ?? 1),
normalize: prevClip[id]?.normalize ?? false,
},
},
diff --git a/src/components/video-editor/audio/waveform/WaveformGenerator.ts b/src/components/video-editor/audio/waveform/WaveformGenerator.ts
index a51475d8..b44c274f 100644
--- a/src/components/video-editor/audio/waveform/WaveformGenerator.ts
+++ b/src/components/video-editor/audio/waveform/WaveformGenerator.ts
@@ -8,43 +8,52 @@ export class WaveformGenerator {
private peaksCache = new Map();
private pending = new Map>();
private workerRequestSeq = 0;
- private workerResolvers = new Map void>();
+ private workerResolvers = new Map void; reject: (err: Error) => void }>();
constructor() {
this.audioContext = new (window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)();
this.worker = new WorkerConstructor();
+
this.worker.addEventListener(
"message",
- (event: MessageEvent<{ requestId: number; peaks: Float32Array }>) => {
- const { requestId, peaks } = event.data;
- const resolve = this.workerResolvers.get(requestId);
- if (!resolve) return;
+ (event: MessageEvent<{ requestId: number; peaks?: Float32Array; error?: string }>) => {
+ const { requestId, peaks, error } = event.data;
+ const resolver = this.workerResolvers.get(requestId);
+ if (!resolver) return;
+
this.workerResolvers.delete(requestId);
- resolve(peaks);
+ if (error) {
+ resolver.reject(new Error(error));
+ } else if (peaks) {
+ resolver.resolve(peaks);
+ }
},
);
+
+ this.worker.addEventListener("error", (error: ErrorEvent) => {
+ console.error("[WaveformGenerator] Worker fatal error:", error);
+ const fatalError = error.error ?? new Error(error.message || "Worker crashed");
+
+ // Reject all pending requests if the worker itself crashes
+ for (const resolver of this.workerResolvers.values()) {
+ resolver.reject(fatalError);
+ }
+ this.workerResolvers.clear();
+ });
}
- private computePeaksWithWorker(channelData: Float32Array, samples: number): Promise {
+ private computePeaksWithWorker(channels: Float32Array[], samples: number): Promise {
return new Promise((resolve, reject) => {
const requestId = ++this.workerRequestSeq;
- const onError = (error: ErrorEvent) => {
- this.worker.removeEventListener("error", onError);
- this.workerResolvers.delete(requestId);
- reject(error.error ?? new Error(error.message));
- };
- this.worker.addEventListener("error", onError, { once: true });
- this.workerResolvers.set(requestId, (peaks) => {
- this.worker.removeEventListener("error", onError);
- resolve(peaks);
- });
+ this.workerResolvers.set(requestId, { resolve, reject });
+
this.worker.postMessage(
{
requestId,
- channelData,
+ channels,
samples,
},
- [channelData.buffer],
+ channels.map(c => c.buffer),
);
});
}
@@ -65,8 +74,14 @@ export class WaveformGenerator {
const arrayBuffer = await response.arrayBuffer();
const decoded = await this.audioContext.decodeAudioData(arrayBuffer);
- const channelData = decoded.getChannelData(0).slice();
- const peaks = await this.computePeaksWithWorker(channelData, peakCount);
+
+ const channels: Float32Array[] = [];
+ for (let i = 0; i < decoded.numberOfChannels; i++) {
+ // We slice to transfer the underlying buffer to the worker
+ channels.push(decoded.getChannelData(i).slice());
+ }
+
+ const peaks = await this.computePeaksWithWorker(channels, peakCount);
let max = 0;
for (let i = 0; i < peaks.length; i++) {
diff --git a/src/components/video-editor/audio/waveform/waveform.worker.ts b/src/components/video-editor/audio/waveform/waveform.worker.ts
index 1db7b382..f5332b64 100644
--- a/src/components/video-editor/audio/waveform/waveform.worker.ts
+++ b/src/components/video-editor/audio/waveform/waveform.worker.ts
@@ -1,34 +1,39 @@
self.onmessage = (e: MessageEvent) => {
- const { requestId, channelData, samples } = e.data as {
+ const { requestId, channels, samples } = e.data as {
requestId: number;
- channelData: Float32Array;
+ channels: Float32Array[];
samples: number;
};
- if (!channelData || samples <= 0) {
+ if (!channels || channels.length === 0 || samples <= 0) {
const empty = new Float32Array(0);
(self as any).postMessage({ requestId, peaks: empty }, [empty.buffer]);
return;
}
try {
- const step = Math.max(1, Math.floor(channelData.length / samples));
+ const firstChannel = channels[0];
+ const step = Math.max(1, Math.floor(firstChannel.length / samples));
const result = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const start = i * step;
- const end = Math.min(start + step, channelData.length);
+ const end = Math.min(start + step, firstChannel.length);
let max = 0;
for (let j = start; j < end; j++) {
- const val = Math.abs(channelData[j]);
- if (val > max) max = val;
+ for (let c = 0; c < channels.length; c++) {
+ const val = Math.abs(channels[c][j]);
+ if (val > max) max = val;
+ }
}
result[i] = max;
}
(self as any).postMessage({ requestId, peaks: result }, [result.buffer]);
- } catch {
- const empty = new Float32Array(0);
- (self as any).postMessage({ requestId, peaks: empty }, [empty.buffer]);
+ } catch (err) {
+ (self as any).postMessage({
+ requestId,
+ error: err instanceof Error ? err.message : "Unknown worker error",
+ });
}
};