From 95c7b7fc2bb9c3a937fa96245a3522164ea63140 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Mon, 20 Apr 2026 23:11:58 -0400 Subject: [PATCH 1/6] fix: add webm inflated duration and fix --- src/lib/exporter/streamingDecoder.ts | 12 ++++++++---- tests/fixtures/sample-inflated-duration.webm | Bin 0 -> 1252 bytes 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/sample-inflated-duration.webm diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 24b9844..c9a9597 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -246,14 +246,13 @@ export class StreamingVideoDecoder { const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); const scanEndSec = hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; - let maxPacketEndUs = 0; + let maxPacketTimestampUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { while (true) { const { done, value } = await scanReader.read(); if (done || !value) break; - const endUs = value.timestamp + (value.duration ?? 0); - if (endUs > maxPacketEndUs) maxPacketEndUs = endUs; + if (value.timestamp > maxPacketTimestampUs) maxPacketTimestampUs = value.timestamp; } } finally { try { @@ -262,7 +261,12 @@ export class StreamingVideoDecoder { /* already closed */ } } - const scannedDuration = maxPacketEndUs / 1_000_000; + // Use last frame's timestamp + one frame duration. The `duration` field on the last + // packet in a WebM container is frequently inflated by the demuxer to match the + // container's declared end, causing validateDuration to see no divergence and + // propagate a duration that the decoder can never actually reach. + const typicalFrameDurationUs = Math.ceil(1_000_000 / frameRate); + const scannedDuration = (maxPacketTimestampUs + typicalFrameDurationUs) / 1_000_000; const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); this.metadata = { diff --git a/tests/fixtures/sample-inflated-duration.webm b/tests/fixtures/sample-inflated-duration.webm new file mode 100644 index 0000000000000000000000000000000000000000..6322840dba15e3d790d34e6e656c054f7e772bbf GIT binary patch literal 1252 zcmcK4O=uHA6ae72Nz_6Q{t+!wXt2dY#gJ55tf$E)QKP1MfTH5>`Vn_oy8H?!-(FI>vv7l ziE6aXoxdPQZ=A(Z`D}}OPH;_!xTb1Y?<&fNA>D(o)jDu;wcP*Ml&%WR3y;DZdEyjD zkM;)p+ggl!jb@YSxCMo_YY8%!SnWY+{$*b6jVw^kKcCpO-0&)w37G3sw|7`O zZT4?zqLc`m+;{T<84b>!Kr|G_4qe65eU7yBf0 z!2U^fYX-S)H*|(gMFCg6YNFifPF=EOBwlk!d${0 tW-el%XYNJ5p@z8u`w`}V{krO;yynV1GgiFugu?Tre4l*}ak)jR{R8OYZ`=R? literal 0 HcmV?d00001 From d1087af63c1600ff71b799408ee3f9bb89d558eb Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 23 Apr 2026 15:46:35 -0400 Subject: [PATCH 2/6] fix: lint --- src/lib/exporter/gifExporter.browser.test.ts | 30 +++++++++++++++++++ .../exporter/videoExporter.browser.test.ts | 30 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/lib/exporter/gifExporter.browser.test.ts b/src/lib/exporter/gifExporter.browser.test.ts index db9b144..1a7b638 100644 --- a/src/lib/exporter/gifExporter.browser.test.ts +++ b/src/lib/exporter/gifExporter.browser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import inflatedDurationVideoUrl from "../../../tests/fixtures/sample-inflated-duration.webm?url"; import { GifExporter } from "./gifExporter"; import type { ExportProgress } from "./types"; @@ -40,4 +41,33 @@ describe("GifExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); + + it("exports successfully when container duration is inflated beyond actual content", async () => { + const exporter = new GifExporter({ + videoUrl: inflatedDurationVideoUrl, + width: 320, + height: 180, + frameRate: 15, + loop: true, + sizePreset: "medium", + wallpaper: "#1a1a2e", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + onProgress: () => { + /**noop**/ + }, + }); + + const result = await exporter.export(); + + expect(result.success, result.error).toBe(true); + expect(result.blob).toBeInstanceOf(Blob); + + const buf = await result.blob!.arrayBuffer(); + const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6)); + expect(header).toMatch(/^GIF8[79]a/); + }); }); diff --git a/src/lib/exporter/videoExporter.browser.test.ts b/src/lib/exporter/videoExporter.browser.test.ts index ec2b0f6..4607111 100644 --- a/src/lib/exporter/videoExporter.browser.test.ts +++ b/src/lib/exporter/videoExporter.browser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import inflatedDurationVideoUrl from "../../../tests/fixtures/sample-inflated-duration.webm?url"; import type { ExportProgress } from "./types"; import { VideoExporter } from "./videoExporter"; @@ -40,4 +41,33 @@ describe("VideoExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); + + it("exports successfully when container duration is inflated beyond actual content", async () => { + const exporter = new VideoExporter({ + videoUrl: inflatedDurationVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "#1a1a2e", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + onProgress: () => { + /**noop**/ + }, + }); + + const result = await exporter.export(); + + expect(result.success, result.error).toBe(true); + expect(result.blob).toBeInstanceOf(Blob); + + const buf = await result.blob!.arrayBuffer(); + const bytes = new Uint8Array(buf); + const ftyp = new TextDecoder().decode(bytes.slice(4, 8)); + expect(ftyp).toBe("ftyp"); + }); }); From cffca5f2ffa62924bdc66cfaf53ff671c3291760 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 23 Apr 2026 17:37:08 -0400 Subject: [PATCH 3/6] fix: just use one test --- package-lock.json | 12 ++++--- src/lib/exporter/gifExporter.browser.test.ts | 30 ------------------ src/lib/exporter/streamingDecoder.test.ts | 14 ++++++++ src/lib/exporter/streamingDecoder.ts | 19 ++++------- .../exporter/videoExporter.browser.test.ts | 30 ------------------ tests/fixtures/sample-inflated-duration.webm | Bin 1252 -> 0 bytes vitest.config.ts | 3 +- 7 files changed, 29 insertions(+), 79 deletions(-) delete mode 100644 tests/fixtures/sample-inflated-duration.webm diff --git a/package-lock.json b/package-lock.json index ba40beb..ed0c9af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7222,13 +7222,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", - "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, - "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcrypt-pbkdf": { diff --git a/src/lib/exporter/gifExporter.browser.test.ts b/src/lib/exporter/gifExporter.browser.test.ts index 1a7b638..db9b144 100644 --- a/src/lib/exporter/gifExporter.browser.test.ts +++ b/src/lib/exporter/gifExporter.browser.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -import inflatedDurationVideoUrl from "../../../tests/fixtures/sample-inflated-duration.webm?url"; import { GifExporter } from "./gifExporter"; import type { ExportProgress } from "./types"; @@ -41,33 +40,4 @@ describe("GifExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); - - it("exports successfully when container duration is inflated beyond actual content", async () => { - const exporter = new GifExporter({ - videoUrl: inflatedDurationVideoUrl, - width: 320, - height: 180, - frameRate: 15, - loop: true, - sizePreset: "medium", - wallpaper: "#1a1a2e", - zoomRegions: [], - showShadow: false, - shadowIntensity: 0, - showBlur: false, - cropRegion: { x: 0, y: 0, width: 1, height: 1 }, - onProgress: () => { - /**noop**/ - }, - }); - - const result = await exporter.export(); - - expect(result.success, result.error).toBe(true); - expect(result.blob).toBeInstanceOf(Blob); - - const buf = await result.blob!.arrayBuffer(); - const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6)); - expect(header).toMatch(/^GIF8[79]a/); - }); }); diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index 55b9123..45be92b 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -83,4 +83,18 @@ describe("shouldFailDecodeEndedEarly", () => { }), ).toBe(true); }); + + it("does not fail when decoder reached stream end but container tail is large (inflated metadata)", () => { + // Real case: ~20min video where container reports 1234s but actual stream + // ends at 1226s. Decoder correctly stops at 1226s (= streamDurationSec). + // The 8s tail is container metadata inflation, not a real decode failure. + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + lastDecodedFrameSec: 1226, + requiredEndSec: 1234, + streamDurationSec: 1226, + }), + ).toBe(false); + }); }); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index c9a9597..b64c5ab 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -129,11 +129,8 @@ export function shouldFailDecodeEndedEarly({ const decodedNearStreamEnd = Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; - if ( - decodedNearStreamEnd && - metadataTailSec > 0 && - metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC - ) { + const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); + if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { return false; } @@ -246,13 +243,14 @@ export class StreamingVideoDecoder { const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); const scanEndSec = hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; - let maxPacketTimestampUs = 0; + let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { while (true) { const { done, value } = await scanReader.read(); if (done || !value) break; - if (value.timestamp > maxPacketTimestampUs) maxPacketTimestampUs = value.timestamp; + const endUs = value.timestamp + (value.duration ?? 0); + if (endUs > maxPacketEndUs) maxPacketEndUs = endUs; } } finally { try { @@ -261,12 +259,7 @@ export class StreamingVideoDecoder { /* already closed */ } } - // Use last frame's timestamp + one frame duration. The `duration` field on the last - // packet in a WebM container is frequently inflated by the demuxer to match the - // container's declared end, causing validateDuration to see no divergence and - // propagate a duration that the decoder can never actually reach. - const typicalFrameDurationUs = Math.ceil(1_000_000 / frameRate); - const scannedDuration = (maxPacketTimestampUs + typicalFrameDurationUs) / 1_000_000; + const scannedDuration = maxPacketEndUs / 1_000_000; const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); this.metadata = { diff --git a/src/lib/exporter/videoExporter.browser.test.ts b/src/lib/exporter/videoExporter.browser.test.ts index 4607111..ec2b0f6 100644 --- a/src/lib/exporter/videoExporter.browser.test.ts +++ b/src/lib/exporter/videoExporter.browser.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -import inflatedDurationVideoUrl from "../../../tests/fixtures/sample-inflated-duration.webm?url"; import type { ExportProgress } from "./types"; import { VideoExporter } from "./videoExporter"; @@ -41,33 +40,4 @@ describe("VideoExporter (real browser)", () => { expect(finalizing.length).toBeGreaterThan(0); expect(finalizing.at(-1)!.percentage).toBe(100); }); - - it("exports successfully when container duration is inflated beyond actual content", async () => { - const exporter = new VideoExporter({ - videoUrl: inflatedDurationVideoUrl, - width: 320, - height: 180, - frameRate: 15, - bitrate: 1_000_000, - wallpaper: "#1a1a2e", - zoomRegions: [], - showShadow: false, - shadowIntensity: 0, - showBlur: false, - cropRegion: { x: 0, y: 0, width: 1, height: 1 }, - onProgress: () => { - /**noop**/ - }, - }); - - const result = await exporter.export(); - - expect(result.success, result.error).toBe(true); - expect(result.blob).toBeInstanceOf(Blob); - - const buf = await result.blob!.arrayBuffer(); - const bytes = new Uint8Array(buf); - const ftyp = new TextDecoder().decode(bytes.slice(4, 8)); - expect(ftyp).toBe("ftyp"); - }); }); diff --git a/tests/fixtures/sample-inflated-duration.webm b/tests/fixtures/sample-inflated-duration.webm deleted file mode 100644 index 6322840dba15e3d790d34e6e656c054f7e772bbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1252 zcmcK4O=uHA6ae72Nz_6Q{t+!wXt2dY#gJ55tf$E)QKP1MfTH5>`Vn_oy8H?!-(FI>vv7l ziE6aXoxdPQZ=A(Z`D}}OPH;_!xTb1Y?<&fNA>D(o)jDu;wcP*Ml&%WR3y;DZdEyjD zkM;)p+ggl!jb@YSxCMo_YY8%!SnWY+{$*b6jVw^kKcCpO-0&)w37G3sw|7`O zZT4?zqLc`m+;{T<84b>!Kr|G_4qe65eU7yBf0 z!2U^fYX-S)H*|(gMFCg6YNFifPF=EOBwlk!d${0 tW-el%XYNJ5p@z8u`w`}V{krO;yynV1GgiFugu?Tre4l*}ak)jR{R8OYZ`=R? diff --git a/vitest.config.ts b/vitest.config.ts index ea60216..8ad92d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,8 +4,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: "jsdom", + environment: "node", include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + exclude: ["src/**/*.browser.test.{ts,tsx}", "node_modules"], }, resolve: { alias: { From 0768c449d756cc9dd1f36467021e3531a40f3a18 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Wed, 29 Apr 2026 22:36:49 -0400 Subject: [PATCH 4/6] 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 From 786165208ff2a4c5588f5e8d8cb5144634aa51bf Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Wed, 29 Apr 2026 22:45:41 -0400 Subject: [PATCH 5/6] misc: remove misc changes --- package-lock.json | 12 ++--- ...owser.test.ts => useCameraDevices.test.ts} | 46 ++++++++++++------- .../tutorialHelpTranslations.test.ts | 2 - src/lib/blurEffects.test.ts | 2 +- src/lib/exporter/streamingDecoder.test.ts | 14 ------ vitest.config.ts | 3 +- 6 files changed, 36 insertions(+), 43 deletions(-) rename src/hooks/{useCameraDevices.browser.test.ts => useCameraDevices.test.ts} (72%) diff --git a/package-lock.json b/package-lock.json index ed0c9af..ba40beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7222,15 +7222,13 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", - "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "version": "2.8.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", + "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", "dev": true, + "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" + "baseline-browser-mapping": "dist/cli.js" } }, "node_modules/bcrypt-pbkdf": { diff --git a/src/hooks/useCameraDevices.browser.test.ts b/src/hooks/useCameraDevices.test.ts similarity index 72% rename from src/hooks/useCameraDevices.browser.test.ts rename to src/hooks/useCameraDevices.test.ts index 71709fd..5ca21bc 100644 --- a/src/hooks/useCameraDevices.browser.test.ts +++ b/src/hooks/useCameraDevices.test.ts @@ -2,26 +2,40 @@ 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.spyOn(navigator.mediaDevices, "enumerateDevices").mockResolvedValue( - mockDevices as MediaDeviceInfo[], - ); - vi.spyOn(navigator.mediaDevices, "getUserMedia").mockResolvedValue({ + vi.clearAllMocks(); + mockEnumerateDevices.mockResolvedValue(mockDevices); + mockGetUserMedia.mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream); - vi.spyOn(navigator.mediaDevices, "addEventListener"); - vi.spyOn(navigator.mediaDevices, "removeEventListener"); + }); }); afterEach(() => { - vi.restoreAllMocks(); + vi.resetAllMocks(); }); it("should list video input devices", async () => { @@ -44,9 +58,9 @@ describe("useCameraDevices", () => { }); it("should use device ID as fallback label when label is missing", async () => { - vi.mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValueOnce([ + mockEnumerateDevices.mockResolvedValueOnce([ { kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" }, - ] as MediaDeviceInfo[]); + ]); const { result } = renderHook(() => useCameraDevices(true)); @@ -54,13 +68,11 @@ describe("useCameraDevices", () => { expect(result.current.devices[0]?.label).toBe("Camera cam1abc1"); }); - expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled(); + expect(mockGetUserMedia).not.toHaveBeenCalled(); }); it("should set error state when enumeration fails", async () => { - vi.mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValueOnce( - new Error("Permission denied"), - ); + mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied")); const { result } = renderHook(() => useCameraDevices(true)); @@ -79,13 +91,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" }, ]; - vi.mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValueOnce( - cam2Only as MediaDeviceInfo[], - ); + mockEnumerateDevices.mockResolvedValueOnce(cam2Only); + // 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 ac45daa..fcfa9d3 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -6,7 +6,6 @@ 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", @@ -36,7 +35,6 @@ 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 1a6a9c9..4797e69 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.56)"); + ).toBe("rgba(0, 0, 0, 0.18)"); }); }); diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index 45be92b..55b9123 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -83,18 +83,4 @@ describe("shouldFailDecodeEndedEarly", () => { }), ).toBe(true); }); - - it("does not fail when decoder reached stream end but container tail is large (inflated metadata)", () => { - // Real case: ~20min video where container reports 1234s but actual stream - // ends at 1226s. Decoder correctly stops at 1226s (= streamDurationSec). - // The 8s tail is container metadata inflation, not a real decode failure. - expect( - shouldFailDecodeEndedEarly({ - cancelled: false, - lastDecodedFrameSec: 1226, - requiredEndSec: 1234, - streamDurationSec: 1226, - }), - ).toBe(false); - }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 8ad92d3..ea60216 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,9 +4,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: "node", + environment: "jsdom", include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - exclude: ["src/**/*.browser.test.{ts,tsx}", "node_modules"], }, resolve: { alias: { From 93466fdda1df6963a7b6ee3255a904afa6bb37dc Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Wed, 29 Apr 2026 22:52:15 -0400 Subject: [PATCH 6/6] fix: add max duration --- src/lib/exporter/streamingDecoder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index f6f5411..0506881 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -68,7 +68,7 @@ type EarlyDecodeEndCheck = { }; const EARLY_DECODE_END_THRESHOLD_SEC = 1; -const METADATA_TAIL_TOLERANCE_SEC = 1.5; +const METADATA_TAIL_TOLERANCE_SEC = 2; const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5; // Fallback upper bound for the packet scan when no reliable duration hint is