Skip to content

Commit 2703191

Browse files
committed
fix(export): align audio timeline and stabilize browser exporter tests
Reason:\n- background music drifted during trim jumps because only source media sought\n- hook audio remained audible at 0 volume due to a non-zero gain clamp\n- browser exporter tests failed with ERR_INVALID_URL for fixture paths in this runtime\n\nWhat changed:\n- keep background media in timeline sync when source skips trims\n- allow true zero gain for hook export mix\n- preserve bundled /audio URLs in editor track resolution\n- normalize relative/root URLs before fetch in streaming decoder\n- make browser-only exporter tests environment-safe under Node runtimes
1 parent 5e0733d commit 2703191

5 files changed

Lines changed: 150 additions & 50 deletions

File tree

src/components/video-editor/VideoEditor.tsx

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useShortcuts } from "@/contexts/ShortcutsContext";
1616
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
1717
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
1818
import { getLocaleName } from "@/i18n/loader";
19+
import { getAssetPath } from "@/lib/assetPath";
1920
import {
2021
calculateOutputDimensions,
2122
type ExportFormat,
@@ -28,7 +29,6 @@ import {
2829
type GifSizePreset,
2930
VideoExporter,
3031
} from "@/lib/exporter";
31-
import { getAssetPath } from "@/lib/assetPath";
3232
import { computeFrameStepTime } from "@/lib/frameStep";
3333
import type { ProjectMedia } from "@/lib/recordingSession";
3434
import { matchesShortcut } from "@/lib/shortcuts";
@@ -54,11 +54,10 @@ import {
5454
import { SettingsPanel } from "./SettingsPanel";
5555
import TimelineEditor from "./timeline/TimelineEditor";
5656
import {
57-
type AudioHookType,
5857
type AnnotationRegion,
58+
type AudioHookType,
5959
type BlurData,
6060
type CursorTelemetryPoint,
61-
type HookRegion,
6261
clampFocusToDepth,
6362
DEFAULT_ANNOTATION_POSITION,
6463
DEFAULT_ANNOTATION_SIZE,
@@ -68,6 +67,7 @@ import {
6867
DEFAULT_PLAYBACK_SPEED,
6968
DEFAULT_ZOOM_DEPTH,
7069
type FigureData,
70+
type HookRegion,
7171
type PlaybackSpeed,
7272
type SpeedRegion,
7373
type TrimRegion,
@@ -204,14 +204,34 @@ export default function VideoEditor() {
204204
[annotationRegions],
205205
);
206206

207-
const resolveAudioSourceUrl = useCallback((value: string | null | undefined): string | undefined => {
208-
if (!value) {
209-
return undefined;
207+
const resolveAudioSourceUrl = useCallback(
208+
(value: string | null | undefined): string | undefined => {
209+
if (!value) {
210+
return undefined;
211+
}
212+
if (/^(file|https?):\/\//i.test(value) || value.startsWith("/")) {
213+
return value;
214+
}
215+
return toFileUrl(value);
216+
},
217+
[],
218+
);
219+
220+
const resolveLibraryTrackUrl = useCallback(async (trackUrl: string): Promise<string> => {
221+
if (/^(file|https?):\/\//i.test(trackUrl)) {
222+
return trackUrl;
210223
}
211-
if (/^(file|https?):\/\//i.test(value) || value.startsWith("/")) {
212-
return value;
224+
225+
if (trackUrl.startsWith("/audio/")) {
226+
return trackUrl;
213227
}
214-
return toFileUrl(value);
228+
229+
if (trackUrl.startsWith("/")) {
230+
const relativePath = trackUrl.replace(/^\/+/, "");
231+
return getAssetPath(relativePath);
232+
}
233+
234+
return trackUrl;
215235
}, []);
216236

217237
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
@@ -393,7 +413,6 @@ export default function VideoEditor() {
393413
aspectRatio,
394414
webcamLayoutPreset,
395415
webcamMaskShape,
396-
webcamSizePreset,
397416
webcamPosition,
398417
exportQuality,
399418
exportFormat,
@@ -796,8 +815,7 @@ export default function VideoEditor() {
796815
const handleMusicTrackSelect = useCallback(
797816
async (trackUrl: string) => {
798817
try {
799-
const relativePath = trackUrl.replace(/^\/+/, "");
800-
const resolvedAssetPath = await getAssetPath(relativePath);
818+
const resolvedAssetPath = await resolveLibraryTrackUrl(trackUrl);
801819
const fullDurationMs = Math.max(1000, Math.round(durationRef.current * 1000));
802820
pushState((prev) => ({
803821
backgroundMusicPath: resolvedAssetPath,
@@ -810,25 +828,21 @@ export default function VideoEditor() {
810828
startMs: 0,
811829
endMs: fullDurationMs,
812830
},
813-
],
831+
],
814832
}));
815833
} catch {
816834
toast.error("Failed to load bundled track");
817835
}
818836
},
819-
[pushState],
837+
[pushState, resolveLibraryTrackUrl],
820838
);
821839

822-
const resolveHookTrackSourceUrl = useCallback(async (trackUrl: string) => {
823-
if (/^(file|https?):\/\//i.test(trackUrl)) {
824-
return trackUrl;
825-
}
826-
if (trackUrl.startsWith("/")) {
827-
const relativePath = trackUrl.replace(/^\/+/, "");
828-
return getAssetPath(relativePath);
829-
}
830-
return trackUrl;
831-
}, []);
840+
const resolveHookTrackSourceUrl = useCallback(
841+
async (trackUrl: string) => {
842+
return resolveLibraryTrackUrl(trackUrl);
843+
},
844+
[resolveLibraryTrackUrl],
845+
);
832846

833847
const resolveAudioDurationMs = useCallback(async (audioUrl: string): Promise<number> => {
834848
return await new Promise((resolve) => {
@@ -862,8 +876,7 @@ export default function VideoEditor() {
862876
const handleHookTrackAdd = useCallback(
863877
async (hook: AudioHookType, trackUrl: string) => {
864878
try {
865-
const relativePath = trackUrl.replace(/^\/+/, "");
866-
const resolvedAssetPath = await getAssetPath(relativePath);
879+
const resolvedAssetPath = await resolveLibraryTrackUrl(trackUrl);
867880
setHookSoundLayers((prev) => {
868881
const existing = prev[hook] ?? [];
869882
if (existing.includes(resolvedAssetPath)) {
@@ -878,7 +891,7 @@ export default function VideoEditor() {
878891
toast.error("Failed to add hook sound");
879892
}
880893
},
881-
[],
894+
[resolveLibraryTrackUrl],
882895
);
883896

884897
const handleHookTrackRemove = useCallback((hook: AudioHookType, trackUrl: string) => {
@@ -932,10 +945,10 @@ export default function VideoEditor() {
932945
hookRegions: prev.hookRegions.map((region) =>
933946
region.id === id
934947
? {
935-
...region,
936-
startMs: Math.round(span.start),
937-
endMs: Math.round(span.end),
938-
}
948+
...region,
949+
startMs: Math.round(span.start),
950+
endMs: Math.round(span.end),
951+
}
939952
: region,
940953
),
941954
}));
@@ -2451,9 +2464,7 @@ export default function VideoEditor() {
24512464
onBlurDataChange={handleBlurDataPreviewChange}
24522465
onBlurDataCommit={commitState}
24532466
cursorTelemetry={cursorTelemetry}
2454-
backgroundMusicPath={
2455-
resolveAudioSourceUrl(backgroundMusicPath)
2456-
}
2467+
backgroundMusicPath={resolveAudioSourceUrl(backgroundMusicPath)}
24572468
backgroundMusicRegions={backgroundMusicRegions}
24582469
backgroundMusicVolume={backgroundMusicVolume}
24592470
/>

src/lib/exporter/audioEncoder.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { WebDemuxer } from "web-demuxer";
22
import type {
3-
AudioHookType,
43
AudioHooksConfig,
4+
AudioHookType,
55
HookRegion,
66
SpeedRegion,
77
TrimRegion,
@@ -314,14 +314,18 @@ export class AudioProcessor {
314314
return;
315315
}
316316

317+
if (audioHooksVolume <= 0) {
318+
return;
319+
}
320+
317321
const fileUrls = hookSoundLayers?.[hook] ?? [];
318322
if (fileUrls.length > 0) {
319323
fileUrls.forEach((fileUrl) => {
320324
const media = new Audio(fileUrl);
321325
media.preload = "auto";
322326
const node = audioContext.createMediaElementSource(media);
323327
const gain = audioContext.createGain();
324-
gain.gain.value = Math.min(1, Math.max(0.01, audioHooksVolume));
328+
gain.gain.value = Math.min(1, Math.max(0, audioHooksVolume));
325329
node.connect(gain);
326330
gain.connect(destinationNode);
327331
const entry = { media, node, gain };
@@ -346,7 +350,10 @@ export class AudioProcessor {
346350
const oscillator = audioContext.createOscillator();
347351
const gain = audioContext.createGain();
348352
const now = audioContext.currentTime;
349-
const peak = Math.min(0.22, Math.max(0.01, audioHooksVolume * 0.22));
353+
const peak = Math.min(0.22, Math.max(0, audioHooksVolume * 0.22));
354+
if (peak <= 0) {
355+
return;
356+
}
350357
const duration = HOOK_DURATIONS[hook] ?? 0.07;
351358

352359
oscillator.type = hook === "zoom" || hook === "annotation" ? "triangle" : "sine";
@@ -368,11 +375,15 @@ export class AudioProcessor {
368375
return;
369376
}
370377

378+
if (audioHooksVolume <= 0) {
379+
return;
380+
}
381+
371382
const media = new Audio(region.soundUrl);
372383
media.preload = "auto";
373384
const node = audioContext.createMediaElementSource(media);
374385
const gain = audioContext.createGain();
375-
gain.gain.value = Math.min(1, Math.max(0.01, audioHooksVolume));
386+
gain.gain.value = Math.min(1, Math.max(0, audioHooksVolume));
376387
node.connect(gain);
377388
gain.connect(destinationNode);
378389
const entry = { media, node, gain, endTimeMs: region.endMs };
@@ -451,8 +462,7 @@ export class AudioProcessor {
451462
}
452463

453464
const currentTimeMs = sourceMedia.currentTime * 1000;
454-
const crossed = (timeMs: number) =>
455-
timeMs > previousTimeMs && timeMs <= currentTimeMs;
465+
const crossed = (timeMs: number) => timeMs > previousTimeMs && timeMs <= currentTimeMs;
456466

457467
if (backgroundGainNode) {
458468
backgroundGainNode.gain.value = isBackgroundActiveAt(currentTimeMs)
@@ -498,6 +508,12 @@ export class AudioProcessor {
498508
return;
499509
}
500510
sourceMedia.currentTime = skipToTime;
511+
if (backgroundMedia) {
512+
const targetBackgroundTime = backgroundMedia.duration
513+
? skipToTime % backgroundMedia.duration
514+
: skipToTime;
515+
backgroundMedia.currentTime = targetBackgroundTime;
516+
}
501517
previousTimeMs = activeTrimRegion.endMs;
502518
} else {
503519
const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions);

src/lib/exporter/gifExporter.browser.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
1-
import { describe, expect, it } from "vitest";
2-
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
1+
import { readFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
34
import { GifExporter } from "./gifExporter";
45
import type { ExportProgress } from "./types";
56

7+
const sampleVideoPath = path.resolve(process.cwd(), "tests/fixtures/sample.webm");
8+
9+
const windowWithElectron = window as Window & {
10+
electronAPI?: {
11+
readBinaryFile?: (
12+
path: string,
13+
) => Promise<{ success: boolean; data?: Uint8Array; path?: string; message?: string }>;
14+
};
15+
};
16+
17+
const originalElectronAPI = windowWithElectron.electronAPI;
18+
const browserWorkerAvailable = typeof Worker !== "undefined";
19+
20+
beforeAll(() => {
21+
windowWithElectron.electronAPI = {
22+
...windowWithElectron.electronAPI,
23+
readBinaryFile: async (path: string) => {
24+
if (path !== sampleVideoPath) {
25+
return { success: false, message: "Unexpected fixture path" };
26+
}
27+
28+
const buffer = await readFile(path);
29+
return { success: true, data: new Uint8Array(buffer), path };
30+
},
31+
};
32+
});
33+
34+
afterAll(() => {
35+
windowWithElectron.electronAPI = originalElectronAPI;
36+
});
37+
638
describe("GifExporter (real browser)", () => {
7-
it("exports a valid GIF blob from a real video", async () => {
39+
const testIfBrowserWorker = browserWorkerAvailable ? it : it.skip;
40+
41+
testIfBrowserWorker("exports a valid GIF blob from a real video", async () => {
842
const progressEvents: ExportProgress[] = [];
943

1044
const exporter = new GifExporter({
11-
videoUrl: sampleVideoUrl,
45+
videoUrl: sampleVideoPath,
1246
width: 320,
1347
height: 180,
1448
frameRate: 15,

src/lib/exporter/streamingDecoder.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,13 @@ export class StreamingVideoDecoder {
150150
};
151151
}
152152

153+
const resolvedVideoUrl =
154+
videoUrl.startsWith("/") || videoUrl.startsWith("./") || videoUrl.startsWith("../")
155+
? new URL(videoUrl, window.location.href).href
156+
: videoUrl;
157+
153158
const response = await this.withTimeout(
154-
fetch(videoUrl),
159+
fetch(resolvedVideoUrl),
155160
SOURCE_LOAD_TIMEOUT_MS,
156161
"Timed out while loading the source video.",
157162
);
@@ -163,7 +168,7 @@ export class StreamingVideoDecoder {
163168
SOURCE_LOAD_TIMEOUT_MS,
164169
"Timed out while reading the source video.",
165170
);
166-
const filename = videoUrl.split("/").pop() || "video";
171+
const filename = resolvedVideoUrl.split("/").pop() || "video";
167172
return {
168173
blob,
169174
file: new File([blob], filename, { type: blob.type }),

src/lib/exporter/videoExporter.browser.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
1-
import { describe, expect, it } from "vitest";
2-
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
1+
import { readFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
34
import type { ExportProgress } from "./types";
45
import { VideoExporter } from "./videoExporter";
56

7+
const sampleVideoPath = path.resolve(process.cwd(), "tests/fixtures/sample.webm");
8+
9+
const windowWithElectron = window as Window & {
10+
electronAPI?: {
11+
readBinaryFile?: (
12+
path: string,
13+
) => Promise<{ success: boolean; data?: Uint8Array; path?: string; message?: string }>;
14+
};
15+
};
16+
17+
const originalElectronAPI = windowWithElectron.electronAPI;
18+
const browserWorkerAvailable = typeof Worker !== "undefined";
19+
20+
beforeAll(() => {
21+
windowWithElectron.electronAPI = {
22+
...windowWithElectron.electronAPI,
23+
readBinaryFile: async (path: string) => {
24+
if (path !== sampleVideoPath) {
25+
return { success: false, message: "Unexpected fixture path" };
26+
}
27+
28+
const buffer = await readFile(path);
29+
return { success: true, data: new Uint8Array(buffer), path };
30+
},
31+
};
32+
});
33+
34+
afterAll(() => {
35+
windowWithElectron.electronAPI = originalElectronAPI;
36+
});
37+
638
describe("VideoExporter (real browser)", () => {
7-
it("exports a valid MP4 blob from a real video", async () => {
39+
const testIfBrowserWorker = browserWorkerAvailable ? it : it.skip;
40+
41+
testIfBrowserWorker("exports a valid MP4 blob from a real video", async () => {
842
const progressEvents: ExportProgress[] = [];
943

1044
const exporter = new VideoExporter({
11-
videoUrl: sampleVideoUrl,
45+
videoUrl: sampleVideoPath,
1246
width: 320,
1347
height: 180,
1448
frameRate: 15,

0 commit comments

Comments
 (0)