diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4654f55c..303dd72a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -193,7 +193,7 @@ interface Window { getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; setHasUnsavedChanges: (hasChanges: boolean) => void; - onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; muxNativeWindowsRecording: () => Promise<{ success: boolean; diff --git a/electron/main.ts b/electron/main.ts index 09414938..156f0663 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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', () => { diff --git a/electron/preload.ts b/electron/preload.ts index a41cf5a6..8f5cf2a4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -210,10 +210,15 @@ contextBridge.exposeInMainWorld("electronAPI", { setHasUnsavedChanges: (hasChanges: boolean) => { ipcRenderer.send("set-has-unsaved-changes", hasChanges); }, - onRequestSaveBeforeClose: (callback: () => Promise) => { + onRequestSaveBeforeClose: (callback: () => Promise) => { 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); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b98950c3..0fd5b0cf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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?.(); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index ee29c934..af67c482 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); + const lastWebcamSyncTimeRef = useRef(null); const zoomMotionBlurRef = useRef(zoomMotionBlur); const connectZoomsRef = useRef(connectZooms); const videoReadyRafRef = useRef(null); @@ -736,8 +738,24 @@ const VideoPlayback = forwardRef( 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( } else { webcamVideo.pause(); } + + lastWebcamSyncTimeRef.current = targetTime; }, [currentTime, isPlaying, webcam, webcamVideoPath]); + useEffect(() => { + lastWebcamSyncTimeRef.current = null; + }, [webcamVideoPath]); + useEffect(() => { const overlayEl = overlayRef.current; if (!overlayEl) return; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2fab8d5d..ee848fa3 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -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 | null>(null); const webcamStopPromise = useRef | null>(null); const webcamStopResolver = useRef<((path: string | null) => void) | null>(null); + const accumulatedPausedDurationMs = useRef(0); + const pauseStartedAtMs = useRef(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) { diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 0bd7c07e..7fb85ba6 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 1e9e9deb..7305c457 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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 | 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 { 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 { + 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((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; } } diff --git a/src/lib/mediaTiming.test.ts b/src/lib/mediaTiming.test.ts new file mode 100644 index 00000000..762d65d0 --- /dev/null +++ b/src/lib/mediaTiming.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/lib/mediaTiming.ts b/src/lib/mediaTiming.ts new file mode 100644 index 00000000..869ae43c --- /dev/null +++ b/src/lib/mediaTiming.ts @@ -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, + ); +} \ No newline at end of file