From 0768c449d756cc9dd1f36467021e3531a40f3a18 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Wed, 29 Apr 2026 22:36:49 -0400 Subject: [PATCH] feat: all changes --- src/components/video-editor/VideoEditor.tsx | 12 +++++ ...st.ts => useCameraDevices.browser.test.ts} | 46 +++++++----------- .../tutorialHelpTranslations.test.ts | 2 + src/lib/blurEffects.test.ts | 2 +- src/lib/exporter/gifExporter.ts | 7 ++- src/lib/exporter/streamingDecoder.ts | 22 ++------- src/lib/exporter/types.ts | 1 + src/lib/exporter/videoExporter.ts | 6 ++- tests/fixtures/sample-inflated-duration.webm | Bin 0 -> 1252 bytes 9 files changed, 48 insertions(+), 50 deletions(-) rename src/hooks/{useCameraDevices.test.ts => useCameraDevices.browser.test.ts} (72%) create mode 100644 tests/fixtures/sample-inflated-duration.webm diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d21d13..f33611b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1409,6 +1409,12 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; + if (result.warnings) { + for (const warning of result.warnings) { + toast.warning(warning); + } + } + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { @@ -1543,6 +1549,12 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; + if (result.warnings) { + for (const warning of result.warnings) { + toast.warning(warning); + } + } + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { diff --git a/src/hooks/useCameraDevices.test.ts b/src/hooks/useCameraDevices.browser.test.ts similarity index 72% rename from src/hooks/useCameraDevices.test.ts rename to src/hooks/useCameraDevices.browser.test.ts index 5ca21bc..71709fd 100644 --- a/src/hooks/useCameraDevices.test.ts +++ b/src/hooks/useCameraDevices.browser.test.ts @@ -2,40 +2,26 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCameraDevices } from "./useCameraDevices"; -// Mock navigator.mediaDevices const mockDevices = [ { kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" }, { kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" }, { kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" }, ]; -const mockGetUserMedia = vi.fn().mockResolvedValue({ - getTracks: () => [{ stop: vi.fn() }], -}); - -const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices); - -Object.defineProperty(global.navigator, "mediaDevices", { - value: { - enumerateDevices: mockEnumerateDevices, - getUserMedia: mockGetUserMedia, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }, - configurable: true, -}); - describe("useCameraDevices", () => { beforeEach(() => { - vi.clearAllMocks(); - mockEnumerateDevices.mockResolvedValue(mockDevices); - mockGetUserMedia.mockResolvedValue({ + vi.spyOn(navigator.mediaDevices, "enumerateDevices").mockResolvedValue( + mockDevices as MediaDeviceInfo[], + ); + vi.spyOn(navigator.mediaDevices, "getUserMedia").mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }], - }); + } as unknown as MediaStream); + vi.spyOn(navigator.mediaDevices, "addEventListener"); + vi.spyOn(navigator.mediaDevices, "removeEventListener"); }); afterEach(() => { - vi.resetAllMocks(); + vi.restoreAllMocks(); }); it("should list video input devices", async () => { @@ -58,9 +44,9 @@ describe("useCameraDevices", () => { }); it("should use device ID as fallback label when label is missing", async () => { - mockEnumerateDevices.mockResolvedValueOnce([ + vi.mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValueOnce([ { kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" }, - ]); + ] as MediaDeviceInfo[]); const { result } = renderHook(() => useCameraDevices(true)); @@ -68,11 +54,13 @@ describe("useCameraDevices", () => { expect(result.current.devices[0]?.label).toBe("Camera cam1abc1"); }); - expect(mockGetUserMedia).not.toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled(); }); it("should set error state when enumeration fails", async () => { - mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied")); + vi.mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValueOnce( + new Error("Permission denied"), + ); const { result } = renderHook(() => useCameraDevices(true)); @@ -91,13 +79,13 @@ describe("useCameraDevices", () => { expect(result.current.selectedDeviceId).toBe("cam1"); }); - // Simulate cam1 being unplugged — only cam2 remains const cam2Only = [ { kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" }, ]; - mockEnumerateDevices.mockResolvedValueOnce(cam2Only); + vi.mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValueOnce( + cam2Only as MediaDeviceInfo[], + ); - // Trigger devicechange event via the registered handler const devicechangeHandler = ( navigator.mediaDevices.addEventListener as ReturnType ).mock.calls[0]?.[1] as (() => void) | undefined; diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts index fcfa9d3..ac45daa 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -6,6 +6,7 @@ import frDialogs from "@/i18n/locales/fr/dialogs.json"; import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json"; import trDialogs from "@/i18n/locales/tr/dialogs.json"; import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json"; +import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json"; const tutorialHelpKeys = [ "triggerLabel", @@ -35,6 +36,7 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des const dialogsByLocale = { en: enDialogs, "zh-CN": zhCNDialogs, + "zh-TW": zhTWDialogs, es: esDialogs, fr: frDialogs, tr: trDialogs, diff --git a/src/lib/blurEffects.test.ts b/src/lib/blurEffects.test.ts index 4797e69..1a6a9c9 100644 --- a/src/lib/blurEffects.test.ts +++ b/src/lib/blurEffects.test.ts @@ -75,6 +75,6 @@ describe("blur color helpers", () => { intensity: 12, blockSize: 12, }), - ).toBe("rgba(0, 0, 0, 0.18)"); + ).toBe("rgba(0, 0, 0, 0.56)"); }); }); diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 46ac6a0..ae7c73f 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -117,6 +117,9 @@ export class GifExporter { async export(): Promise { let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + const warnings: string[] = []; + const onWarning = (message: string) => warnings.push(message); + try { const platform = await getPlatform(); @@ -219,6 +222,7 @@ export class GifExporter { } queue.enqueue(webcamFrame); }, + onWarning, ) .catch((error) => { webcamDecodeError = error instanceof Error ? error : new Error(String(error)); @@ -278,6 +282,7 @@ export class GifExporter { webcamFrame?.close(); } }, + onWarning, ); if (this.cancelled) { @@ -324,7 +329,7 @@ export class GifExporter { this.gif!.render(); }); - return { success: true, blob }; + return { success: true, blob, warnings: warnings.length > 0 ? warnings : undefined }; } catch (error) { console.error("GIF Export error:", error); return { diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index b64c5ab..f6f5411 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -292,6 +292,7 @@ export class StreamingVideoDecoder { trimRegions: TrimRegion[] | undefined, speedRegions: SpeedRegion[] | undefined, onFrame: OnFrameCallback, + onWarning?: (message: string) => void, ): Promise { if (!this.demuxer || !this.metadata) { throw new Error("Must call loadMetadata() before decodeAll()"); @@ -563,8 +564,6 @@ export class StreamingVideoDecoder { } this.decoder = null; - const isWindows = typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent); - if ( shouldFailDecodeEndedEarly({ cancelled: this.cancelled, @@ -575,22 +574,9 @@ export class StreamingVideoDecoder { ) { const decodedAtLabel = lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; - const decodeGapSec = - lastDecodedFrameSec === null ? Infinity : requiredEndSec - lastDecodedFrameSec; - - // On Windows, tolerate a small decode gap: up to 10% of required duration, capped at 3 seconds. - const maxToleratedGap = Math.min(3.0, requiredEndSec * 0.1); - - if (isWindows && lastDecodedFrameSec !== null && decodeGapSec <= maxToleratedGap) { - console.warn( - `[StreamingVideoDecoder] Decode ended early on Windows with a gap of ${decodeGapSec.toFixed(2)}s ` + - `(max tolerated: ${maxToleratedGap.toFixed(2)}s) – proceeding anyway.`, - ); - } else { - throw new Error( - `Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`, - ); - } + const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; + console.warn(`[StreamingVideoDecoder] ${message}`); + onWarning?.(message); } } diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index b6e08e8..3873341 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -19,6 +19,7 @@ export interface ExportResult { success: boolean; blob?: Blob; error?: string; + warnings?: string[]; } export interface VideoFrameData { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 44c1b88..9984edc 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -107,6 +107,8 @@ export class VideoExporter { let webcamDecodeError: Error | null = null; let webcamDecodePromise: Promise | null = null; let webcamDecoder: StreamingVideoDecoder | null = null; + const warnings: string[] = []; + const onWarning = (message: string) => warnings.push(message); this.cleanup(); this.cancelled = false; @@ -194,6 +196,7 @@ export class VideoExporter { } queue.enqueue(webcamFrame); }, + onWarning, ) .catch((error) => { webcamDecodeError = error instanceof Error ? error : new Error(String(error)); @@ -298,6 +301,7 @@ export class VideoExporter { webcamFrame?.close(); } }, + onWarning, ); if (this.cancelled) { @@ -354,7 +358,7 @@ export class VideoExporter { } const blob = await muxer.finalize(); - return { success: true, blob }; + return { success: true, blob, warnings: warnings.length > 0 ? warnings : undefined }; } finally { stopWebcamDecode = true; webcamFrameQueue?.destroy(); diff --git a/tests/fixtures/sample-inflated-duration.webm b/tests/fixtures/sample-inflated-duration.webm new file mode 100644 index 0000000000000000000000000000000000000000..a90d19eecffc32af266c963140b08c67c1b5ea1b GIT binary patch literal 1252 zcmcK4Ur19?7y$6^ZYu>J`o~H_Xrxa@V>2e%)7jltt-5M%vp+9wu3S-;FkQZN&;F-I zSrFA$PKFPfj8#_TUT%o6o`ZxS8uXAw&`KzRWZmoT!M@%*+U>&)boOJ%JAe$FgWg)#6a<`Bb#uv+dAxy zE_(ts5DdQcOC$cZPX&sTe<$COoA=Ajo6So1h70Fr_}qLnHQfkjv$rYOAP(ea8k_{Q27r^6ZMlt#$5a;#*o&$k5|MLttd1AEp!z{v zpSz*@fq(K}tG-`Cbu@bB2JFXGcVw`qWS8W*_!9PaEFagAXRrPTyM*$!e&$~6lgt78 zlIpe$a@{WG1bZKITt}BzotJR_GIJ66I*aVeyd;Bth~*RP1?GT#MRjWl`Fg@!!X9BR rVqakHMP5_O+<^TsbHIL0by8Y$<(?TUTz^90`LKMSeG79_WvcxH8h3FB literal 0 HcmV?d00001