diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index b3124acb..59566e62 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -87,8 +87,8 @@ describe("editorPreferences", () => { expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); }); - it("defaults cursor preferences to macOS at 2.5x with gentler sway", () => { - expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("macos"); + it("defaults cursor preferences to Tahoe at 2.5x with gentler sway", () => { + expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("tahoe"); expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5); expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.4); }); @@ -126,7 +126,9 @@ describe("editorPreferences", () => { const loaded = loadEditorPreferences(); - expect(loaded.zoomMotionBlurTuning).toEqual(DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning); + expect(loaded.zoomMotionBlurTuning).toEqual( + DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning, + ); }); it("does not save dev-only split blur tuning overrides to editor preferences", () => { @@ -401,6 +403,38 @@ describe("editorPreferences", () => { ]); }); + it("does not let localStorage failures mask Electron preset persistence", () => { + const settingsStore = stubElectronSettings(); + const localStorage = createStorageMock(); + localStorage.setItem = () => { + throw new Error("localStorage unavailable"); + }; + vi.stubGlobal("localStorage", localStorage); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: "Demo Preset", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(settingsStore.get(EDITOR_PRESETS_STORAGE_KEY)).toMatchObject([ + { + id: "preset-1", + name: "Demo Preset", + }, + ]); + }); + it("preserves crop region in editor preset snapshots", () => { const localStorage = createStorageMock(); vi.stubGlobal("localStorage", localStorage); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index ee3bdafd..b567188d 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -285,14 +285,12 @@ function normalizeEditorControls( zoomInDurationMs: sanitizedRaw.zoomInDurationMs ?? fallback.zoomInDurationMs, zoomInOverlapMs: sanitizedRaw.zoomInOverlapMs ?? fallback.zoomInOverlapMs, zoomOutDurationMs: sanitizedRaw.zoomOutDurationMs ?? fallback.zoomOutDurationMs, - connectedZoomGapMs: - sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs, + connectedZoomGapMs: sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs, connectedZoomDurationMs: sanitizedRaw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs, zoomInEasing: sanitizedRaw.zoomInEasing ?? fallback.zoomInEasing, zoomOutEasing: sanitizedRaw.zoomOutEasing ?? fallback.zoomOutEasing, - connectedZoomEasing: - sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing, + connectedZoomEasing: sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing, showCursor: sanitizedRaw.showCursor ?? fallback.showCursor, loopCursor: sanitizedRaw.loopCursor ?? fallback.loopCursor, cursorStyle: sanitizedRaw.cursorStyle ?? fallback.cursorStyle, @@ -302,16 +300,14 @@ function normalizeEditorControls( sanitizedRaw.cursorSpringStiffnessMultiplier ?? fallback.cursorSpringStiffnessMultiplier, cursorSpringDampingMultiplier: - sanitizedRaw.cursorSpringDampingMultiplier ?? - fallback.cursorSpringDampingMultiplier, + sanitizedRaw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier, cursorSpringMassMultiplier: sanitizedRaw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier, cameraSpringStiffnessMultiplier: sanitizedRaw.cameraSpringStiffnessMultiplier ?? fallback.cameraSpringStiffnessMultiplier, cameraSpringDampingMultiplier: - sanitizedRaw.cameraSpringDampingMultiplier ?? - fallback.cameraSpringDampingMultiplier, + sanitizedRaw.cameraSpringDampingMultiplier ?? fallback.cameraSpringDampingMultiplier, cameraSpringMassMultiplier: sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier, cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur, @@ -324,8 +320,7 @@ function normalizeEditorControls( frame: sanitizedRaw.frame !== undefined ? sanitizedRaw.frame : fallback.frame, webcam: sanitizedRaw.webcam ?? fallback.webcam, aspectRatio: sanitizedRaw.aspectRatio ?? fallback.aspectRatio, - exportEncodingMode: - sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode, + exportEncodingMode: sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode, exportBackendPreference: sanitizedRaw.exportBackendPreference === undefined ? fallback.exportBackendPreference @@ -452,15 +447,25 @@ export function saveEditorPreferences(preferences: Partial): const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); const persisted = stripPersistedDevMotionBlurSettings(merged); saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted); - globalThis.localStorage?.setItem( - EDITOR_PREFERENCES_STORAGE_KEY, - JSON.stringify(persisted), - ); + saveLocalStorageJson(EDITOR_PREFERENCES_STORAGE_KEY, persisted); } catch { // Ignore storage failures so editor controls still work. } } +function saveLocalStorageJson(key: string, value: unknown): boolean { + try { + if (typeof globalThis.localStorage === "undefined") { + return false; + } + + globalThis.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + export function loadEditorPresets(): EditorPreset[] { const persisted = loadAppSetting(EDITOR_PRESETS_STORAGE_KEY); if (persisted !== null) { @@ -482,9 +487,12 @@ export function loadEditorPresets(): EditorPreset[] { export function saveEditorPresets(presets: EditorPreset[]): boolean { try { const normalized = normalizeEditorPresets(presets); - const persisted = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); - globalThis.localStorage?.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized)); - return persisted || typeof globalThis.localStorage !== "undefined"; + const persistedToAppSettings = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); + const persistedToLocalStorage = saveLocalStorageJson( + EDITOR_PRESETS_STORAGE_KEY, + normalized, + ); + return persistedToAppSettings || persistedToLocalStorage; } catch { // Ignore storage failures so editor controls still work. return false; diff --git a/src/lib/exporter/audioEncoder.test.ts b/src/lib/exporter/audioEncoder.test.ts index 9e9deeec..309ff8fe 100644 --- a/src/lib/exporter/audioEncoder.test.ts +++ b/src/lib/exporter/audioEncoder.test.ts @@ -14,8 +14,8 @@ type OfflineRenderTestHarness = AudioProcessor & { sourceAudioFallbackPaths: string[], sourceAudioFallbackStartDelayMsByPath?: Record, ): Promise<{ - mainBuffer: AudioBuffer | null; - companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number }>; + mainBufferEntry: { buffer: AudioBuffer; gain: number } | null; + companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number; gain: number }>; }>; renderAndMuxOfflineAudio( videoUrl: string, @@ -55,9 +55,11 @@ describe("AudioProcessor offline render preparation", () => { ["/tmp/recording.mp4", "/tmp/recording.mic.wav"], ); - expect(prepared.mainBuffer).toBe(mainBuffer); + expect(prepared.mainBufferEntry?.buffer).toBe(mainBuffer); + expect(prepared.mainBufferEntry?.gain).toBe(1); expect(prepared.companionEntries).toHaveLength(1); expect(prepared.companionEntries[0]?.buffer).toBe(micBuffer); + expect(prepared.companionEntries[0]?.gain).toBe(1); expect(decodeAudioFromUrl).toHaveBeenCalledWith("file:///tmp/recording.mp4"); expect(decodeAudioFromUrl).toHaveBeenCalledWith("/tmp/recording.mic.wav"); expect(decodeAudioFromUrl).not.toHaveBeenCalledWith("/tmp/recording.mp4"); diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index a362c126..718b02eb 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -363,19 +363,30 @@ describe("ModernFrameRenderer webcam frame cache", () => { } }); - it("keeps the refresh throttle for default crop regions", () => { + it("renders non-video webcam frames directly for default crop regions", () => { const renderer = createRenderer() as any; + const liveSource = {}; renderer.config.webcam.cropRegion = { x: 0, y: 0, width: 1, height: 1 }; - renderer.webcamFrameCacheCanvas = { width: 1280, height: 720 }; - renderer.lastWebcamCacheRefreshTime = 10; - renderer.currentVideoTime = 10.1; + const renderableSource = renderer.resolveRenderableWebcamSource( + liveSource, + 1280, + 720, + true, + ); - expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(false); + expect(renderableSource).toMatchObject({ + source: liveSource, + width: 1280, + height: 720, + mode: "live", + }); + expect(renderer.webcamFrameCacheCanvas).toBeNull(); }); - it("bypasses the refresh throttle for cropped webcam regions", () => { + it("uses a cache-backed source for cropped webcam regions", () => { const renderer = createRenderer() as any; + const liveSource = {}; renderer.config.webcam.cropRegion = { x: 0.25, @@ -383,11 +394,30 @@ describe("ModernFrameRenderer webcam frame cache", () => { width: 0.5, height: 1, }; - renderer.webcamFrameCacheCanvas = { width: 640, height: 720 }; - renderer.lastWebcamCacheRefreshTime = 10; - renderer.currentVideoTime = 10.1; + const renderableSource = renderer.resolveRenderableWebcamSource( + liveSource, + 1280, + 720, + true, + ); - expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(true); + expect(renderableSource?.source).toBe(renderer.webcamFrameCacheCanvas); + expect(renderableSource).toMatchObject({ + width: 640, + height: 720, + mode: "cached", + }); + expect(renderer.webcamFrameCacheCtx.drawImage).toHaveBeenCalledWith( + liveSource, + 320, + 0, + 640, + 720, + 0, + 0, + 640, + 720, + ); }); }); @@ -720,31 +750,23 @@ describe("ModernFrameRenderer temporal webcam sync", () => { zoom: { scale: 1, focusX: 0.5, focusY: 0.5, progress: 0 }, })); - await renderer.renderTemporalMotionBlurFrame( - 1_000_000, - 1_000_000, - 1_000_000, - 33_333, - { - stageSize: { width: 1920, height: 1080 }, - videoSize: { width: 1920, height: 1080 }, - baseScale: 1, - baseOffset: { x: 0, y: 0 }, - maskRect: { - x: 0, - y: 0, - width: 1920, - height: 1080, - sourceCrop: { x: 0, y: 0, width: 1, height: 1 }, - }, + await renderer.renderTemporalMotionBlurFrame(1_000_000, 1_000_000, 1_000_000, 33_333, { + stageSize: { width: 1920, height: 1080 }, + videoSize: { width: 1920, height: 1080 }, + baseScale: 1, + baseOffset: { x: 0, y: 0 }, + maskRect: { + x: 0, + y: 0, + width: 1920, + height: 1080, + sourceCrop: { x: 0, y: 0, width: 1, height: 1 }, }, - ); + }); expect(renderer.renderSceneSample).toHaveBeenCalledTimes(3); expect(renderer.renderSceneSample.mock.calls.map((call: unknown[]) => call[6])).toEqual([ - 1, - 1, - 1, + 1, 1, 1, ]); expect( new Set(renderer.renderSceneSample.mock.calls.map((call: unknown[]) => call[0])).size, diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index ccc1d8db..683651fe 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { ModernVideoExporter as ModernVideoExporterClass } from "./modernVideoExporter"; const mocks = vi.hoisted(() => { const videoInfo = { @@ -67,13 +68,18 @@ vi.mock("./muxer", () => ({ })); describe("ModernVideoExporter native fallback routing", () => { + let ModernVideoExporter: typeof ModernVideoExporterClass; + + beforeAll(async () => { + ({ ModernVideoExporter } = await import("./modernVideoExporter")); + }, 30_000); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); }); it("falls back to WebCodecs instead of surfacing a native error when Breeze is unavailable", async () => { - const { ModernVideoExporter } = await import("./modernVideoExporter"); const exporter = new ModernVideoExporter({ videoUrl: "file:///recording.mp4", width: 1920, @@ -124,7 +130,6 @@ describe("ModernVideoExporter native fallback routing", () => { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", }); - const { ModernVideoExporter } = await import("./modernVideoExporter"); const nativeResult = { success: true, blob: new Blob([], { type: "video/mp4" }), @@ -181,7 +186,6 @@ describe("ModernVideoExporter native fallback routing", () => { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", }); - const { ModernVideoExporter } = await import("./modernVideoExporter"); const staticLayoutResult = { success: true, blob: new Blob([], { type: "video/mp4" }), @@ -235,7 +239,6 @@ describe("ModernVideoExporter native fallback routing", () => { }, 15_000); it("retries the main decode path once with a readable file-backed source", async () => { - const { ModernVideoExporter } = await import("./modernVideoExporter"); mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); mocks.streamingDecoderDecodeAll .mockRejectedValueOnce(