Fix webcam sync and app close flow

This commit is contained in:
webadderall
2026-03-20 11:37:37 +11:00
parent c64c088748
commit 2b5783ab00
10 changed files with 426 additions and 71 deletions
+1 -1
View File
@@ -193,7 +193,7 @@ interface Window {
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean>) => () => void;
isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>;
muxNativeWindowsRecording: () => Promise<{
success: boolean;
+7 -3
View File
@@ -327,8 +327,10 @@ function createEditorWindowWrapper() {
if (choice === 0) {
mainWindow!.webContents.send('request-save-before-close')
ipcMain.once('save-before-close-done', () => {
closeEditorWindowBypassingUnsavedPrompt(mainWindow)
ipcMain.once('save-before-close-done', (_event, saved: boolean) => {
if (saved) {
closeEditorWindowBypassingUnsavedPrompt(mainWindow)
}
})
} else if (choice === 1) {
closeEditorWindowBypassingUnsavedPrompt(mainWindow)
@@ -352,7 +354,9 @@ app.on('before-quit', () => {
})
app.on('window-all-closed', () => {
// Keep app running (macOS behavior)
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
+8 -3
View File
@@ -210,10 +210,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
onRequestSaveBeforeClose: (callback: () => Promise<boolean>) => {
const listener = async () => {
await callback();
ipcRenderer.send("save-before-close-done");
let saved = false;
try {
saved = await callback();
} catch {
saved = false;
}
ipcRenderer.send("save-before-close-done", saved);
};
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
+6 -19
View File
@@ -755,13 +755,13 @@ export default function VideoEditor() {
async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error("No video loaded");
return;
return false;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error("Unable to determine source video path");
return;
return false;
}
const projectData = createProjectData(
@@ -812,12 +812,12 @@ export default function VideoEditor() {
if (result.canceled) {
toast.info("Project save canceled");
return;
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return;
return false;
}
if (result.path) {
@@ -826,6 +826,7 @@ export default function VideoEditor() {
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
return true;
},
[
videoPath,
@@ -863,27 +864,13 @@ export default function VideoEditor() {
],
);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasUnsavedChanges]);
useEffect(() => {
window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges]);
useEffect(() => {
const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => {
await saveProject(false);
return saveProject(false);
});
return () => cleanup?.();
+26 -2
View File
@@ -9,6 +9,7 @@ import {
useCallback,
} from "react";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
@@ -249,6 +250,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
const videoReadyRafRef = useRef<number | null>(null);
@@ -736,8 +738,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
const targetTime = Math.max(0, currentTime);
if (Math.abs(webcamVideo.currentTime - targetTime) > (isPlaying ? 0.1 : 0.01)) {
const targetTime = clampMediaTimeToDuration(
currentTime,
Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
);
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
if (Math.abs(webcamVideo.playbackRate - targetPlaybackRate) > 0.001) {
webcamVideo.playbackRate = targetPlaybackRate;
}
const previousTimelineTime = lastWebcamSyncTimeRef.current;
const timelineJumped =
previousTimelineTime === null || Math.abs(targetTime - previousTimelineTime) > 0.25;
const driftThreshold = isPlaying ? 0.35 : 0.01;
if (timelineJumped || Math.abs(webcamVideo.currentTime - targetTime) > driftThreshold) {
try {
webcamVideo.currentTime = targetTime;
} catch {
@@ -753,8 +771,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
} else {
webcamVideo.pause();
}
lastWebcamSyncTimeRef.current = targetTime;
}, [currentTime, isPlaying, webcam, webcamVideoPath]);
useEffect(() => {
lastWebcamSyncTimeRef.current = null;
}, [webcamVideoPath]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!overlayEl) return;
+51 -9
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { toast } from "sonner";
import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming";
const TARGET_FRAME_RATE = 60;
const TARGET_WIDTH = 3840;
@@ -88,6 +89,41 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const pendingWebcamPathPromise = useRef<Promise<string | null> | null>(null);
const webcamStopPromise = useRef<Promise<string | null> | null>(null);
const webcamStopResolver = useRef<((path: string | null) => void) | null>(null);
const accumulatedPausedDurationMs = useRef(0);
const pauseStartedAtMs = useRef<number | null>(null);
const resetRecordingClock = useCallback((startedAt: number) => {
startTime.current = startedAt;
accumulatedPausedDurationMs.current = 0;
pauseStartedAtMs.current = null;
}, []);
const markRecordingPaused = useCallback((pausedAt: number) => {
if (pauseStartedAtMs.current === null) {
pauseStartedAtMs.current = pausedAt;
}
}, []);
const markRecordingResumed = useCallback((resumedAt: number) => {
if (pauseStartedAtMs.current === null) {
return;
}
accumulatedPausedDurationMs.current += Math.max(
0,
resumedAt - pauseStartedAtMs.current,
);
pauseStartedAtMs.current = null;
}, []);
const getRecordingDurationMs = useCallback((endedAt: number) => {
return getEffectiveRecordingDurationMs({
startTimeMs: startTime.current,
endTimeMs: endedAt,
accumulatedPausedDurationMs: accumulatedPausedDurationMs.current,
pauseStartedAtMs: pauseStartedAtMs.current,
});
}, []);
const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => {
const platform = await window.electronAPI.getPlatform();
@@ -270,7 +306,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
const duration = Date.now() - startTime.current;
const duration = getRecordingDurationMs(Date.now());
const webcamBlob = new Blob(webcamChunks.current, { type: mimeType });
webcamChunks.current = [];
const fixedBlob = await fixWebmDuration(webcamBlob, duration);
@@ -301,7 +337,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamStream.current = null;
}
}
}, [webcamDeviceId, webcamEnabled]);
}, [getRecordingDurationMs, webcamDeviceId, webcamEnabled]);
const stopRecording = useRef(() => {
setPaused(false);
@@ -338,6 +374,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const recorderState = recorder?.state;
if (recorder && (recorderState === "recording" || recorderState === "paused")) {
if (recorderState === "paused") {
markRecordingResumed(Date.now());
recorder.resume();
}
pendingWebcamPathPromise.current = stopWebcamRecorder();
@@ -441,7 +478,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
recordingSessionTimestamp.current = Date.now();
startTime.current = recordingSessionTimestamp.current;
resetRecordingClock(recordingSessionTimestamp.current);
await startWebcamRecorder();
const platform = await window.electronAPI.getPlatform();
@@ -516,7 +553,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeResult.success) {
nativeScreenRecording.current = true;
nativeWindowsRecording.current = useNativeWindowsCapture;
startTime.current = Date.now();
resetRecordingClock(Date.now());
setRecording(true);
window.electronAPI?.setRecordingState(true);
@@ -707,7 +744,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia();
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const duration = getRecordingDurationMs(Date.now());
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
chunks.current = [];
@@ -735,7 +772,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(false);
};
recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
resetRecordingClock(Date.now());
setRecording(true);
window.electronAPI?.setRecordingState(true);
} catch (error) {
@@ -763,6 +800,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
markRecordingPaused(Date.now());
setPaused(true);
})();
return;
@@ -772,9 +810,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
markRecordingPaused(Date.now());
setPaused(true);
}
}, [recording, paused]);
}, [markRecordingPaused, paused, recording]);
const resumeRecording = useCallback(() => {
if (!recording || !paused) return;
@@ -789,6 +828,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
markRecordingResumed(Date.now());
setPaused(false);
})();
return;
@@ -798,13 +838,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
markRecordingResumed(Date.now());
setPaused(false);
}
}, [recording, paused]);
}, [markRecordingResumed, paused, recording]);
const cancelRecording = useCallback(() => {
if (!recording) return;
setPaused(false);
markRecordingResumed(Date.now());
// Discard webcam recording regardless of recording mode
webcamChunks.current = [];
@@ -843,7 +885,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(false);
window.electronAPI?.setRecordingState(false);
}
}, [recording, cleanupCapturedMedia]);
}, [cleanupCapturedMedia, markRecordingResumed, recording]);
const toggleRecording = async () => {
if (starting || countdownActive) {
+70 -1
View File
@@ -206,6 +206,7 @@ describe('FrameRenderer webcam export path', () => {
callback(0);
return 1;
},
cancelAnimationFrame: vi.fn(),
HTMLMediaElement: {
HAVE_CURRENT_DATA: 2,
},
@@ -235,6 +236,25 @@ describe('FrameRenderer webcam export path', () => {
expect(renderer.webcamSeekPromise).toBeNull();
});
it('falls back to animation frame when requestVideoFrameCallback does not fire', async () => {
const renderer = createRenderer() as any;
const webcamVideo = new FakeVideoElement({ duration: 4.5, currentTime: 0.25 }) as FakeVideoElement & {
requestVideoFrameCallback?: (callback: () => void) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
webcamVideo.requestVideoFrameCallback = vi.fn(() => 7);
webcamVideo.cancelVideoFrameCallback = vi.fn();
renderer.webcamVideoElement = webcamVideo;
await renderer.syncWebcamFrame(1.5);
expect(webcamVideo.currentTime).toBe(1.5);
expect(renderer.lastSyncedWebcamTime).toBe(1.5);
expect(webcamVideo.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
expect(webcamVideo.cancelVideoFrameCallback).not.toHaveBeenCalled();
expect(renderer.webcamSeekPromise).toBeNull();
});
it('uses the cached webcam frame when the live video is out of sync', () => {
const renderer = createRenderer() as any;
const outputContext = createMockContext();
@@ -263,6 +283,34 @@ describe('FrameRenderer webcam export path', () => {
expect((outputContext.drawImage as any).mock.calls[0][0]).toBe(bubbleCanvas);
});
it('keeps drawing the cached webcam frame when the live element temporarily has no current data', () => {
const renderer = createRenderer() as any;
const outputContext = createMockContext();
const webcamVideo = new FakeVideoElement({
currentTime: 2,
readyState: 0,
videoWidth: 640,
videoHeight: 360,
});
const cachedFrameCanvas = createMockCanvas();
cachedFrameCanvas.width = 640;
cachedFrameCanvas.height = 360;
renderer.webcamVideoElement = webcamVideo;
renderer.webcamFrameCacheCanvas = cachedFrameCanvas;
renderer.webcamFrameCacheCtx = cachedFrameCanvas.getContext('2d');
renderer.lastSyncedWebcamTime = 2;
renderer.currentVideoTime = 2;
renderer.animationState.appliedScale = 1;
renderer.drawWebcamOverlay(outputContext, 1280, 720);
const bubbleCanvas = createdCanvases[0];
expect(bubbleCanvas).toBeDefined();
expect((bubbleCanvas.context.drawImage as any).mock.calls[0][0]).toBe(cachedFrameCanvas);
expect((outputContext.drawImage as any).mock.calls[0][0]).toBe(bubbleCanvas);
});
it('uses the live webcam frame and refreshes the cache when the video is synchronized', () => {
const renderer = createRenderer() as any;
const outputContext = createMockContext();
@@ -284,7 +332,28 @@ describe('FrameRenderer webcam export path', () => {
const cacheCanvas = createdCanvases[1];
expect(cacheCanvas).toBeDefined();
expect((cacheCanvas.context.drawImage as any).mock.calls[0][0]).toBe(webcamVideo);
expect((bubbleCanvas.context.drawImage as any).mock.calls[0][0]).toBe(webcamVideo);
expect((bubbleCanvas.context.drawImage as any).mock.calls[0][0]).toBe(cacheCanvas);
expect((outputContext.drawImage as any).mock.calls[0][0]).toBe(bubbleCanvas);
});
it('reuses the webcam bubble canvas across frames', () => {
const renderer = createRenderer() as any;
const outputContext = createMockContext();
const webcamVideo = new FakeVideoElement({
currentTime: 2,
readyState: 2,
videoWidth: 800,
videoHeight: 600,
});
renderer.webcamVideoElement = webcamVideo;
renderer.lastSyncedWebcamTime = 2;
renderer.currentVideoTime = 2;
renderer.animationState.appliedScale = 1;
renderer.drawWebcamOverlay(outputContext, 1280, 720);
renderer.drawWebcamOverlay(outputContext, 1280, 720);
expect(createdCanvases).toHaveLength(2);
});
});
+174 -33
View File
@@ -36,7 +36,9 @@ import {
DEFAULT_CURSOR_CONFIG,
preloadCursorAssets,
} from "@/components/video-editor/videoPlayback/cursorRenderer";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import { getWebcamOverlaySizePx } from "@/components/video-editor/webcamOverlay";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
interface FrameRenderConfig {
@@ -115,10 +117,14 @@ export class FrameRenderer {
private currentVideoTime = 0;
private lastMotionVector = { x: 0, y: 0 };
private cursorOverlay: PixiCursorOverlay | null = null;
private webcamForwardFrameSource: ForwardFrameSource | null = null;
private webcamDecodedFrame: VideoFrame | null = null;
private webcamVideoElement: HTMLVideoElement | null = null;
private webcamSeekPromise: Promise<void> | null = null;
private webcamFrameCacheCanvas: HTMLCanvasElement | null = null;
private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null;
private webcamBubbleCanvas: HTMLCanvasElement | null = null;
private webcamBubbleCtx: CanvasRenderingContext2D | null = null;
private lastSyncedWebcamTime: number | null = null;
private cleanupWebcamSource: (() => void) | null = null;
@@ -427,6 +433,10 @@ export class FrameRenderer {
private async setupWebcamSource(): Promise<void> {
const webcamUrl = this.config.webcamUrl;
if (!this.config.webcam?.enabled || !webcamUrl) {
this.webcamForwardFrameSource?.cancel();
void this.webcamForwardFrameSource?.destroy();
this.webcamForwardFrameSource = null;
this.closeWebcamDecodedFrame();
this.cleanupWebcamSource?.();
this.cleanupWebcamSource = null;
this.webcamVideoElement = null;
@@ -436,7 +446,30 @@ export class FrameRenderer {
return;
}
this.webcamForwardFrameSource?.cancel();
void this.webcamForwardFrameSource?.destroy();
this.webcamForwardFrameSource = null;
this.closeWebcamDecodedFrame();
this.cleanupWebcamSource?.();
this.cleanupWebcamSource = null;
try {
const frameSource = new ForwardFrameSource();
await frameSource.initialize(webcamUrl);
this.webcamForwardFrameSource = frameSource;
this.webcamVideoElement = null;
this.webcamSeekPromise = null;
this.webcamFrameCacheCanvas = null;
this.webcamFrameCacheCtx = null;
this.lastSyncedWebcamTime = null;
return;
} catch (error) {
console.warn(
"[FrameRenderer] Decoder-backed webcam source unavailable during export; falling back to media element sync:",
error,
);
}
const webcamSource = await resolveMediaElementSource(webcamUrl);
this.cleanupWebcamSource = webcamSource.revoke;
@@ -490,15 +523,26 @@ export class FrameRenderer {
}
private async syncWebcamFrame(targetTime: number): Promise<void> {
if (this.webcamForwardFrameSource) {
const clampedTime = clampMediaTimeToDuration(targetTime, null);
const decodedFrame = await this.webcamForwardFrameSource.getFrameAtTime(clampedTime);
this.closeWebcamDecodedFrame();
this.webcamDecodedFrame = decodedFrame;
if (decodedFrame) {
this.lastSyncedWebcamTime = clampedTime;
}
return;
}
const webcamVideo = this.webcamVideoElement;
if (!webcamVideo) {
return;
}
const duration = Number.isFinite(webcamVideo.duration)
? webcamVideo.duration
: targetTime;
const clampedTime = Math.max(0, Math.min(targetTime, duration || targetTime));
const clampedTime = clampMediaTimeToDuration(
targetTime,
Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
);
if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.008) {
this.lastSyncedWebcamTime = clampedTime;
@@ -512,10 +556,37 @@ export class FrameRenderer {
this.webcamSeekPromise = new Promise<void>((resolve) => {
let settled = false;
let fallbackTimeout: number | null = null;
let animationFrameRequestId: number | null = null;
let videoFrameRequestId: number | null = null;
const waitForPresentedFrame = () => {
requestAnimationFrame(() => {
finish();
});
const requestVideoFrameCallback = (
webcamVideo as HTMLVideoElement & {
requestVideoFrameCallback?: (
callback: (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) => void,
) => number;
cancelVideoFrameCallback?: (handle: number) => void;
}
).requestVideoFrameCallback;
const scheduleAnimationFrameFinish = () => {
animationFrameRequestId = requestAnimationFrame(() => {
animationFrameRequestId = null;
finish();
});
};
scheduleAnimationFrameFinish();
if (typeof requestVideoFrameCallback === "function") {
videoFrameRequestId = requestVideoFrameCallback.call(
webcamVideo,
() => {
videoFrameRequestId = null;
finish();
},
);
return;
}
};
const finish = () => {
if (settled) {
@@ -542,6 +613,25 @@ export class FrameRenderer {
webcamVideo.removeEventListener("loadeddata", handleMediaReady);
webcamVideo.removeEventListener("canplay", handleMediaReady);
webcamVideo.removeEventListener("error", finish);
if (animationFrameRequestId !== null) {
cancelAnimationFrame(animationFrameRequestId);
animationFrameRequestId = null;
}
if (
videoFrameRequestId !== null &&
typeof (
webcamVideo as HTMLVideoElement & {
cancelVideoFrameCallback?: (handle: number) => void;
}
).cancelVideoFrameCallback === "function"
) {
(
webcamVideo as HTMLVideoElement & {
cancelVideoFrameCallback: (handle: number) => void;
}
).cancelVideoFrameCallback(videoFrameRequestId);
videoFrameRequestId = null;
}
if (fallbackTimeout !== null) {
window.clearTimeout(fallbackTimeout);
}
@@ -561,7 +651,7 @@ export class FrameRenderer {
});
fallbackTimeout = window.setTimeout(() => {
finish();
}, 250);
}, 50);
try {
webcamVideo.currentTime = clampedTime;
@@ -593,7 +683,7 @@ export class FrameRenderer {
this.currentVideoTime = timestamp / 1000000;
if (this.webcamVideoElement) {
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
const targetTime = Math.max(0, this.currentVideoTime);
await this.syncWebcamFrame(targetTime);
}
@@ -956,8 +1046,27 @@ export class FrameRenderer {
height: number,
): void {
const webcam = this.config.webcam;
const webcamDecodedFrame = this.webcamDecodedFrame;
const webcamVideo = this.webcamVideoElement;
if (!webcam?.enabled || !webcamVideo || webcamVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
if (!webcam?.enabled || (!webcamDecodedFrame && !webcamVideo)) {
return;
}
const hasCachedWebcamFrame = Boolean(
this.webcamFrameCacheCanvas &&
this.webcamFrameCacheCanvas.width > 0 &&
this.webcamFrameCacheCanvas.height > 0,
);
const hasLiveWebcamFrame =
webcamDecodedFrame
? webcamDecodedFrame.displayWidth > 0 && webcamDecodedFrame.displayHeight > 0
: Boolean(
webcamVideo &&
webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
webcamVideo.videoWidth > 0 &&
webcamVideo.videoHeight > 0,
);
if (!hasLiveWebcamFrame && !hasCachedWebcamFrame) {
return;
}
@@ -974,32 +1083,45 @@ export class FrameRenderer {
const y = webcam.corner.startsWith("bottom") ? height - size - margin : margin;
const radius = Math.max(0, webcam.cornerRadius ?? 18);
const bubbleCanvas = document.createElement("canvas");
bubbleCanvas.width = Math.ceil(size);
bubbleCanvas.height = Math.ceil(size);
const bubbleCtx = bubbleCanvas.getContext("2d");
const bubbleCanvas = this.webcamBubbleCanvas ?? document.createElement("canvas");
const bubbleSize = Math.max(1, Math.ceil(size));
if (bubbleCanvas.width !== bubbleSize || bubbleCanvas.height !== bubbleSize) {
bubbleCanvas.width = bubbleSize;
bubbleCanvas.height = bubbleSize;
}
this.webcamBubbleCanvas = bubbleCanvas;
const bubbleCtx = this.webcamBubbleCtx ?? bubbleCanvas.getContext("2d");
if (!bubbleCtx) {
return;
}
this.webcamBubbleCtx = bubbleCtx;
bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height);
const canRefreshCache =
webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
!webcamVideo.seeking &&
hasLiveWebcamFrame &&
this.lastSyncedWebcamTime !== null &&
Math.abs(this.lastSyncedWebcamTime - this.currentVideoTime) <= 0.02 &&
Math.abs(webcamVideo.currentTime - this.currentVideoTime) <= 0.02 &&
webcamVideo.videoWidth > 0 &&
webcamVideo.videoHeight > 0;
(webcamDecodedFrame
? true
: Boolean(
webcamVideo &&
!webcamVideo.seeking &&
Math.abs(webcamVideo.currentTime - this.currentVideoTime) <= 0.02 &&
webcamVideo.videoWidth > 0 &&
webcamVideo.videoHeight > 0,
));
if (canRefreshCache) {
const liveFrameWidth = webcamDecodedFrame?.displayWidth ?? webcamVideo?.videoWidth ?? 0;
const liveFrameHeight = webcamDecodedFrame?.displayHeight ?? webcamVideo?.videoHeight ?? 0;
if (
!this.webcamFrameCacheCanvas ||
this.webcamFrameCacheCanvas.width !== webcamVideo.videoWidth ||
this.webcamFrameCacheCanvas.height !== webcamVideo.videoHeight
this.webcamFrameCacheCanvas.width !== liveFrameWidth ||
this.webcamFrameCacheCanvas.height !== liveFrameHeight
) {
this.webcamFrameCacheCanvas = document.createElement("canvas");
this.webcamFrameCacheCanvas.width = webcamVideo.videoWidth;
this.webcamFrameCacheCanvas.height = webcamVideo.videoHeight;
this.webcamFrameCacheCanvas.width = liveFrameWidth;
this.webcamFrameCacheCanvas.height = liveFrameHeight;
this.webcamFrameCacheCtx = this.webcamFrameCacheCanvas.getContext("2d");
}
@@ -1010,7 +1132,7 @@ export class FrameRenderer {
this.webcamFrameCacheCanvas!.height,
);
this.webcamFrameCacheCtx?.drawImage(
webcamVideo,
webcamDecodedFrame ?? webcamVideo!,
0,
0,
this.webcamFrameCacheCanvas!.width,
@@ -1018,27 +1140,30 @@ export class FrameRenderer {
);
}
const webcamFrameSource = canRefreshCache
? webcamVideo
: this.webcamFrameCacheCanvas;
const webcamFrameSource = this.webcamFrameCacheCanvas ?? (hasLiveWebcamFrame ? (webcamDecodedFrame ?? webcamVideo) : null);
if (!webcamFrameSource) {
return;
}
const sourceWidth =
("videoWidth" in webcamFrameSource
? webcamFrameSource.videoWidth
: webcamFrameSource.width) || size;
("displayWidth" in webcamFrameSource
? webcamFrameSource.displayWidth
: "videoWidth" in webcamFrameSource
? webcamFrameSource.videoWidth
: webcamFrameSource.width) || size;
const sourceHeight =
("videoHeight" in webcamFrameSource
? webcamFrameSource.videoHeight
: webcamFrameSource.height) || size;
("displayHeight" in webcamFrameSource
? webcamFrameSource.displayHeight
: "videoHeight" in webcamFrameSource
? webcamFrameSource.videoHeight
: webcamFrameSource.height) || size;
const coverScale = Math.max(size / sourceWidth, size / sourceHeight);
const drawWidth = sourceWidth * coverScale;
const drawHeight = sourceHeight * coverScale;
const drawX = (size - drawWidth) / 2;
const drawY = (size - drawHeight) / 2;
bubbleCtx.save();
bubbleCtx.beginPath();
bubbleCtx.roundRect(0, 0, size, size, radius);
bubbleCtx.clip();
@@ -1051,6 +1176,7 @@ export class FrameRenderer {
} else {
bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight);
}
bubbleCtx.restore();
if ((webcam.shadow ?? 0) > 0) {
const shadow = Math.max(0, Math.min(1, webcam.shadow));
@@ -1064,6 +1190,15 @@ export class FrameRenderer {
ctx.drawImage(bubbleCanvas, x, y, size, size);
}
private closeWebcamDecodedFrame(): void {
if (!this.webcamDecodedFrame) {
return;
}
this.webcamDecodedFrame.close();
this.webcamDecodedFrame = null;
}
getCanvas(): HTMLCanvasElement {
if (!this.compositeCanvas) {
throw new Error("Renderer not initialized");
@@ -1106,10 +1241,16 @@ export class FrameRenderer {
this.webcamVideoElement.load();
this.webcamVideoElement = null;
}
this.webcamForwardFrameSource?.cancel();
void this.webcamForwardFrameSource?.destroy();
this.webcamForwardFrameSource = null;
this.closeWebcamDecodedFrame();
this.cleanupWebcamSource?.();
this.cleanupWebcamSource = null;
this.webcamFrameCacheCanvas = null;
this.webcamFrameCacheCtx = null;
this.webcamBubbleCanvas = null;
this.webcamBubbleCtx = null;
this.lastSyncedWebcamTime = null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
clampMediaTimeToDuration,
getEffectiveRecordingDurationMs,
} from "./mediaTiming";
describe("clampMediaTimeToDuration", () => {
it("clamps playback time to known media duration", () => {
expect(clampMediaTimeToDuration(12, 4.5)).toBe(4.5);
expect(clampMediaTimeToDuration(-1, 4.5)).toBe(0);
});
it("leaves playback time unchanged when duration is unknown", () => {
expect(clampMediaTimeToDuration(12, null)).toBe(12);
expect(clampMediaTimeToDuration(12, Number.NaN)).toBe(12);
});
});
describe("getEffectiveRecordingDurationMs", () => {
it("subtracts accumulated paused time", () => {
expect(
getEffectiveRecordingDurationMs({
startTimeMs: 1_000,
endTimeMs: 11_000,
accumulatedPausedDurationMs: 2_500,
}),
).toBe(7_500);
});
it("subtracts an active pause interval", () => {
expect(
getEffectiveRecordingDurationMs({
startTimeMs: 1_000,
endTimeMs: 11_000,
accumulatedPausedDurationMs: 2_000,
pauseStartedAtMs: 9_000,
}),
).toBe(6_000);
});
});
+42
View File
@@ -0,0 +1,42 @@
export function clampMediaTimeToDuration(
targetTime: number,
duration?: number | null,
): number {
const safeTargetTime = Math.max(0, targetTime);
if (!Number.isFinite(duration) || duration === null || duration === undefined) {
return safeTargetTime;
}
return Math.max(0, Math.min(safeTargetTime, Math.max(0, duration)));
}
export function getEffectiveRecordingDurationMs({
startTimeMs,
endTimeMs,
accumulatedPausedDurationMs = 0,
pauseStartedAtMs = null,
}: {
startTimeMs: number;
endTimeMs: number;
accumulatedPausedDurationMs?: number;
pauseStartedAtMs?: number | null;
}): number {
if (!Number.isFinite(startTimeMs) || !Number.isFinite(endTimeMs)) {
return 0;
}
const safeStartTime = Math.max(0, startTimeMs);
const safeEndTime = Math.max(safeStartTime, endTimeMs);
const activePauseDuration =
Number.isFinite(pauseStartedAtMs) && pauseStartedAtMs !== null
? Math.max(0, safeEndTime - pauseStartedAtMs)
: 0;
return Math.max(
0,
safeEndTime -
safeStartTime -
Math.max(0, accumulatedPausedDurationMs) -
activePauseDuration,
);
}