feat: all changes

This commit is contained in:
Marc Diaz
2026-04-29 22:36:49 -04:00
parent cffca5f2ff
commit 0768c449d7
9 changed files with 48 additions and 50 deletions
@@ -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) {
@@ -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<typeof vi.fn>
).mock.calls[0]?.[1] as (() => void) | undefined;
@@ -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,
+1 -1
View File
@@ -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)");
});
});
+6 -1
View File
@@ -117,6 +117,9 @@ export class GifExporter {
async export(): Promise<ExportResult> {
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 {
+4 -18
View File
@@ -292,6 +292,7 @@ export class StreamingVideoDecoder {
trimRegions: TrimRegion[] | undefined,
speedRegions: SpeedRegion[] | undefined,
onFrame: OnFrameCallback,
onWarning?: (message: string) => void,
): Promise<void> {
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);
}
}
+1
View File
@@ -19,6 +19,7 @@ export interface ExportResult {
success: boolean;
blob?: Blob;
error?: string;
warnings?: string[];
}
export interface VideoFrameData {
+5 -1
View File
@@ -107,6 +107,8 @@ export class VideoExporter {
let webcamDecodeError: Error | null = null;
let webcamDecodePromise: Promise<void> | 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();
Binary file not shown.