feat: all changes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ExportResult {
|
||||
success: boolean;
|
||||
blob?: Blob;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface VideoFrameData {
|
||||
|
||||
@@ -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();
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user