mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
Fix webcam sync and app close flow
This commit is contained in:
Vendored
+1
-1
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user