diff --git a/electron-builder.json5 b/electron-builder.json5 index 18127a9..a8f1dc1 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -37,12 +37,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true - } + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSCameraUseContinuityCameraDeviceType": true, + "com.apple.security.device.audio-input": true + } }, "linux": { "target": [ diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f66af16..17fd20f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -33,8 +33,28 @@ interface Window { storeRecordedVideo: ( videoData: ArrayBuffer, fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string }>; - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + ) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + error?: string; + }>; + storeRecordedSession: ( + payload: import("../src/lib/recordingSession").StoreRecordedSessionInput, + ) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + error?: string; + }>; + getRecordedVideoPath: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; setRecordingState: (recording: boolean) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; @@ -50,7 +70,17 @@ interface Window { ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentRecordingSession: ( + session: import("../src/lib/recordingSession").RecordingSession | null, + ) => Promise<{ + success: boolean; + session?: import("../src/lib/recordingSession").RecordingSession; + }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + getCurrentRecordingSession: () => Promise<{ + success: boolean; + session?: import("../src/lib/recordingSession").RecordingSession; + }>; readBinaryFile: (filePath: string) => Promise<{ success: boolean; data?: ArrayBuffer; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5665cd7..2a6c406 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2,10 +2,17 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron"; +import { + normalizeProjectMedia, + normalizeRecordingSession, + type RecordingSession, + type StoreRecordedSessionInput, +} from "../../src/lib/recordingSession"; import { RECORDINGS_DIR } from "../main"; const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); +const RECORDING_SESSION_SUFFIX = ".session.json"; type SelectedSource = { name: string; @@ -14,6 +21,7 @@ type SelectedSource = { let selectedSource: SelectedSource | null = null; let currentProjectPath: string | null = null; +let currentRecordingSession: RecordingSession | null = null; function normalizePath(filePath: string) { return path.resolve(filePath); @@ -47,6 +55,54 @@ function isTrustedProjectPath(filePath?: string | null) { return normalizePath(filePath) === normalizePath(currentProjectPath); } +function setCurrentRecordingSessionState(session: RecordingSession | null) { + currentRecordingSession = session; +} + +async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { + const createdAt = + typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt) + ? payload.createdAt + : Date.now(); + const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName); + await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + + let webcamVideoPath: string | undefined; + if (payload.webcam) { + webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName); + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } + + const session: RecordingSession = webcamVideoPath + ? { screenVideoPath, webcamVideoPath, createdAt } + : { screenVideoPath, createdAt }; + setCurrentRecordingSessionState(session); + currentProjectPath = null; + + const telemetryPath = `${screenVideoPath}.cursor.json`; + if (pendingCursorSamples.length > 0) { + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + "utf-8", + ); + } + pendingCursorSamples = []; + + const sessionManifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + + return { + success: true, + path: screenVideoPath, + session, + message: "Recording session stored successfully", + }; +} + const CURSOR_TELEMETRY_VERSION = 1; const CURSOR_SAMPLE_INTERVAL_MS = 100; const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz @@ -146,36 +202,30 @@ export function registerIpcHandlers( createEditorWindow(); }); - ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => { + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { - const videoPath = path.join(RECORDINGS_DIR, fileName); - await fs.writeFile(videoPath, Buffer.from(videoData)); - currentProjectPath = null; - - const telemetryPath = `${videoPath}.cursor.json`; - if (pendingCursorSamples.length > 0) { - await fs.writeFile( - telemetryPath, - JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, - null, - 2, - ), - "utf-8", - ); - } - pendingCursorSamples = []; - - return { - success: true, - path: videoPath, - message: "Video stored successfully", - }; + return await storeRecordedSessionFiles(payload); } catch (error) { - console.error("Failed to store video:", error); + console.error("Failed to store recording session:", error); return { success: false, - message: "Failed to store video", + message: "Failed to store recording session", + error: String(error), + }; + } + }); + + ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => { + try { + return await storeRecordedSessionFiles({ + screen: { videoData, fileName }, + createdAt: Date.now(), + }); + } catch (error) { + console.error("Failed to store recorded video:", error); + return { + success: false, + message: "Failed to store recorded video", error: String(error), }; } @@ -183,8 +233,14 @@ export function registerIpcHandlers( ipcMain.handle("get-recorded-video-path", async () => { try { + if (currentRecordingSession?.screenVideoPath) { + return { success: true, path: currentRecordingSession.screenVideoPath }; + } + const files = await fs.readdir(RECORDINGS_DIR); - const videoFiles = files.filter((file) => file.endsWith(".webm")); + const videoFiles = files.filter( + (file) => file.endsWith(".webm") && !file.endsWith("-webcam.webm"), + ); if (videoFiles.length === 0) { return { success: false, message: "No recorded video found" }; @@ -244,7 +300,9 @@ export function registerIpcHandlers( }); ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { - const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + const targetVideoPath = normalizeVideoSourcePath( + videoPath ?? currentRecordingSession?.screenVideoPath, + ); if (!targetVideoPath) { return { success: true, samples: [] }; } @@ -416,7 +474,6 @@ export function registerIpcHandlers( } }); - let currentVideoPath: string | null = null; ipcMain.handle( "save-project-file", async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { @@ -502,8 +559,17 @@ export function registerIpcHandlers( const content = await fs.readFile(filePath, "utf-8"); const project = JSON.parse(content); currentProjectPath = filePath; - if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; + if (project && typeof project === "object") { + const rawProject = project as { media?: unknown; videoPath?: unknown }; + const media = + normalizeProjectMedia(rawProject.media) ?? + (typeof rawProject.videoPath === "string" + ? { + screenVideoPath: + normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath, + } + : null); + setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null); } return { @@ -529,8 +595,17 @@ export function registerIpcHandlers( const content = await fs.readFile(currentProjectPath, "utf-8"); const project = JSON.parse(content); - if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; + if (project && typeof project === "object") { + const rawProject = project as { media?: unknown; videoPath?: unknown }; + const media = + normalizeProjectMedia(rawProject.media) ?? + (typeof rawProject.videoPath === "string" + ? { + screenVideoPath: + normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath, + } + : null); + setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null); } return { success: true, @@ -546,18 +621,36 @@ export function registerIpcHandlers( }; } }); + ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => { + const normalized = normalizeRecordingSession(session); + setCurrentRecordingSessionState(normalized); + currentProjectPath = null; + return { success: true, session: normalized ?? undefined }; + }); + + ipcMain.handle("get-current-recording-session", () => { + return currentRecordingSession + ? { success: true, session: currentRecordingSession } + : { success: false }; + }); + ipcMain.handle("set-current-video-path", (_, path: string) => { - currentVideoPath = normalizeVideoSourcePath(path) ?? path; + setCurrentRecordingSessionState({ + screenVideoPath: normalizeVideoSourcePath(path) ?? path, + createdAt: Date.now(), + }); currentProjectPath = null; return { success: true }; }); ipcMain.handle("get-current-video-path", () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + return currentRecordingSession?.screenVideoPath + ? { success: true, path: currentRecordingSession.screenVideoPath } + : { success: false }; }); ipcMain.handle("clear-current-video-path", () => { - currentVideoPath = null; + setCurrentRecordingSessionState(null); return { success: true }; }); diff --git a/electron/preload.ts b/electron/preload.ts index acdec4f..b918377 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; contextBridge.exposeInMainWorld("electronAPI", { hudOverlayHide: () => { @@ -30,6 +31,9 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, + storeRecordedSession: (payload: StoreRecordedSessionInput) => { + return ipcRenderer.invoke("store-recorded-session", payload); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); @@ -57,9 +61,15 @@ contextBridge.exposeInMainWorld("electronAPI", { setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, + setCurrentRecordingSession: (session: RecordingSession | null) => { + return ipcRenderer.invoke("set-current-recording-session", session); + }, getCurrentVideoPath: () => { return ipcRenderer.invoke("get-current-video-path"); }, + getCurrentRecordingSession: () => { + return ipcRenderer.invoke("get-current-recording-session"); + }, readBinaryFile: (filePath: string) => { return ipcRenderer.invoke("read-binary-file", filePath); }, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b456dd6..bca5d4a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -4,7 +4,16 @@ import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; -import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; +import { + MdMic, + MdMicOff, + MdMonitor, + MdVideocam, + MdVideocamOff, + MdVideoFile, + MdVolumeOff, + MdVolumeUp, +} from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -23,6 +32,8 @@ const ICON_CONFIG = { volumeOff: { icon: MdVolumeOff, size: ICON_SIZE }, micOn: { icon: MdMic, size: ICON_SIZE }, micOff: { icon: MdMicOff, size: ICON_SIZE }, + webcamOn: { icon: MdVideocam, size: ICON_SIZE }, + webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, @@ -57,6 +68,8 @@ export function LaunchWindow() { setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, } = useScreenRecorder(); const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); @@ -233,6 +246,16 @@ export function LaunchWindow() { ? getIcon("micOn", "text-green-400") : getIcon("micOff", "text-white/40")} + {/* Record/Stop group */} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b3bdb8d..a350985 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -17,6 +17,7 @@ import { type GifSizePreset, VideoExporter, } from "@/lib/exporter"; +import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; @@ -26,6 +27,7 @@ import { deriveNextId, fromFileUrl, normalizeProjectEditor, + resolveProjectMedia, toFileUrl, validateProjectData, } from "./projectPersistence"; @@ -79,6 +81,8 @@ export default function VideoEditor() { // ── Non-undoable state const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); + const [webcamVideoPath, setWebcamVideoPath] = useState(null); + const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -111,6 +115,19 @@ export default function VideoEditor() { const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); + const currentProjectMedia = useMemo(() => { + const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + if (!screenVideoPath) { + return null; + } + + const webcamSourcePath = + webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null); + return webcamSourcePath + ? { screenVideoPath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath }; + }, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]); + const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { if (!validateProjectData(candidate)) { @@ -118,7 +135,12 @@ export default function VideoEditor() { } const project = candidate; - const sourcePath = fromFileUrl(project.videoPath); + const media = resolveProjectMedia(project); + if (!media) { + return false; + } + const sourcePath = fromFileUrl(media.screenVideoPath); + const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null; const normalizedEditor = normalizeProjectEditor(project.editor); try { @@ -133,6 +155,8 @@ export default function VideoEditor() { setError(null); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(webcamSourcePath); + setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(path ?? null); pushState({ @@ -182,19 +206,27 @@ export default function VideoEditor() { 0, ) + 1; - setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); + setLastSavedSnapshot( + JSON.stringify( + createProjectData( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + normalizedEditor, + ), + ), + ); return true; }, [pushState], ); const currentProjectSnapshot = useMemo(() => { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); - if (!sourcePath) { + if (!currentProjectMedia) { return null; } return JSON.stringify( - createProjectData(sourcePath, { + createProjectData(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -215,8 +247,7 @@ export default function VideoEditor() { }), ); }, [ - videoPath, - videoSourcePath, + currentProjectMedia, wallpaper, shadowIntensity, showBlur, @@ -257,11 +288,29 @@ export default function VideoEditor() { } } + const currentSessionResult = await window.electronAPI.getCurrentRecordingSession(); + if (currentSessionResult.success && currentSessionResult.session) { + const session = currentSessionResult.session; + const sourcePath = fromFileUrl(session.screenVideoPath); + const webcamSourcePath = session.webcamVideoPath + ? fromFileUrl(session.webcamVideoPath) + : null; + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(webcamSourcePath); + setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + return; + } + const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const sourcePath = fromFileUrl(result.path); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(null); + setWebcamVideoPath(null); setCurrentProjectPath(null); setLastSavedSnapshot(null); } else { @@ -284,13 +333,12 @@ export default function VideoEditor() { return false; } - const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); - if (!sourcePath) { + if (!currentProjectMedia) { toast.error("Unable to determine source video path"); return false; } - const projectData = createProjectData(sourcePath, { + const projectData = createProjectData(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -311,7 +359,7 @@ export default function VideoEditor() { }); const fileNameBase = - sourcePath + currentProjectMedia.screenVideoPath .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; @@ -341,8 +389,7 @@ export default function VideoEditor() { return true; }, [ - videoPath, - videoSourcePath, + currentProjectMedia, currentProjectPath, wallpaper, shadowIntensity, @@ -361,6 +408,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + videoPath, ], ); @@ -420,7 +468,7 @@ export default function VideoEditor() { let mounted = true; async function loadCursorTelemetry() { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + const sourcePath = currentProjectMedia?.screenVideoPath ?? null; if (!sourcePath) { if (mounted) { @@ -447,7 +495,7 @@ export default function VideoEditor() { return () => { mounted = false; }; - }, [videoPath, videoSourcePath]); + }, [currentProjectMedia]); function togglePlayPause() { const playback = videoPlaybackRef.current; @@ -921,6 +969,7 @@ export default function VideoEditor() { // GIF Export const gifExporter = new GifExporter({ videoUrl: videoPath, + webcamVideoUrl: webcamVideoPath || undefined, width: settings.gifConfig.width, height: settings.gifConfig.height, frameRate: settings.gifConfig.frameRate, @@ -1048,6 +1097,7 @@ export default function VideoEditor() { const exporter = new VideoExporter({ videoUrl: videoPath, + webcamVideoUrl: webcamVideoPath || undefined, width: exportWidth, height: exportHeight, frameRate: 60, @@ -1115,6 +1165,7 @@ export default function VideoEditor() { }, [ videoPath, + webcamVideoPath, wallpaper, zoomRegions, trimRegions, @@ -1251,10 +1302,11 @@ export default function VideoEditor() { }} > void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -98,6 +100,7 @@ const VideoPlayback = forwardRef( ( { videoPath, + webcamVideoPath, onDurationChange, onTimeUpdate, currentTime, @@ -129,7 +132,9 @@ const VideoPlayback = forwardRef( ref, ) => { const videoRef = useRef(null); + const webcamVideoRef = useRef(null); const containerRef = useRef(null); + const stageRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); const videoContainerRef = useRef(null); @@ -139,6 +144,11 @@ const VideoPlayback = forwardRef( const [videoReady, setVideoReady] = useState(false); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); + const [webcamLayout, setWebcamLayout] = useState(null); + const [webcamDimensions, setWebcamDimensions] = useState<{ + width: number; + height: number; + } | null>(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -901,6 +911,90 @@ const VideoPlayback = forwardRef( const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + setWebcamDimensions(null); + return; + } + + const handleLoadedMetadata = () => { + if (webcamVideo.videoWidth > 0 && webcamVideo.videoHeight > 0) { + setWebcamDimensions({ + width: webcamVideo.videoWidth, + height: webcamVideo.videoHeight, + }); + } + }; + + webcamVideo.addEventListener("loadedmetadata", handleLoadedMetadata); + handleLoadedMetadata(); + return () => { + webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + }, [webcamVideoPath]); + + useEffect(() => { + const stage = stageRef.current; + if (!stage || !webcamDimensions) { + setWebcamLayout(null); + return; + } + + const updateLayout = () => { + const layout = computeWebcamOverlayLayout({ + stageWidth: stage.clientWidth, + stageHeight: stage.clientHeight, + videoWidth: webcamDimensions.width, + videoHeight: webcamDimensions.height, + }); + setWebcamLayout(layout); + }; + + updateLayout(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(updateLayout); + observer.observe(stage); + return () => observer.disconnect(); + }, [webcamDimensions]); + + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + return; + } + + if (!isPlaying) { + webcamVideo.pause(); + if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) { + webcamVideo.currentTime = currentTime; + } + return; + } + + if (Math.abs(webcamVideo.currentTime - currentTime) > 0.15) { + webcamVideo.currentTime = currentTime; + } + + webcamVideo.play().catch(() => { + // Ignore webcam autoplay restoration failures. + }); + }, [currentTime, isPlaying, webcamVideoPath]); + + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + return; + } + + webcamVideo.pause(); + webcamVideo.currentTime = 0; + }, [webcamVideoPath]); + useEffect(() => { let mounted = true; (async () => { @@ -975,6 +1069,7 @@ const VideoPlayback = forwardRef( return (
( : "none", }} /> + {webcamVideoPath && ( +