diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 94610648..2929d39b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -110,6 +110,14 @@ interface Window { openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentRecordingSession: (session: { + videoPath: string; + webcamPath?: string | null; + }) => Promise<{ success: boolean }>; + getCurrentRecordingSession: () => Promise<{ + success: boolean; + session?: { videoPath: string; webcamPath?: string | null }; + }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; saveProjectFile: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 00f0da6e..b4338c4b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -23,6 +23,7 @@ const AUTO_RECORDING_PREFIX = 'recording-' const AUTO_RECORDING_RETENTION_COUNT = 20 const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env['VITE_DEV_SERVER_URL']) +const RECORDING_SESSION_MANIFEST_SUFFIX = '.recordly-session.json' function getScreen() { return nodeRequire('electron').screen as typeof import('electron').screen @@ -52,10 +53,22 @@ type WindowBounds = { height: number } +type RecordingSessionData = { + videoPath: string + webcamPath?: string | null +} + +type RecordingSessionManifest = { + version: 1 + videoFileName: string + webcamFileName?: string | null +} + let selectedSource: SelectedSource | null = null let currentProjectPath: string | null = null let nativeScreenRecordingActive = false let currentVideoPath: string | null = null +let currentRecordingSession: RecordingSessionData | null = null let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null let nativeCaptureOutputBuffer = '' let nativeCaptureTargetPath: string | null = null @@ -209,6 +222,124 @@ function normalizeVideoSourcePath(videoPath?: string | null): string | null { return trimmed } +function getRecordingSessionManifestPath(videoPath: string) { + const extension = path.extname(videoPath) + const baseName = path.basename(videoPath, extension) + return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`) +} + +async function persistRecordingSessionManifest(session: RecordingSessionData): Promise { + const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) + if (!normalizedVideoPath) { + return + } + + const normalizedWebcamPath = normalizeVideoSourcePath(session.webcamPath ?? null) + const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath) + + if (!normalizedWebcamPath) { + await fs.rm(manifestPath, { force: true }) + return + } + + const manifest: RecordingSessionManifest = { + version: 1, + videoFileName: path.basename(normalizedVideoPath), + webcamFileName: path.basename(normalizedWebcamPath), + } + + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8') +} + +async function resolveRecordingSessionManifest(videoPath?: string | null): Promise { + const normalizedVideoPath = normalizeVideoSourcePath(videoPath) + if (!normalizedVideoPath) { + return null + } + + const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath) + + try { + const content = await fs.readFile(manifestPath, 'utf-8') + const parsed = JSON.parse(content) as Partial + if (parsed.version !== 1) { + return null + } + + const webcamFileName = typeof parsed.webcamFileName === 'string' && parsed.webcamFileName.trim() + ? parsed.webcamFileName.trim() + : null + + if (!webcamFileName) { + return { + videoPath: normalizedVideoPath, + webcamPath: null, + } + } + + const webcamPath = path.join(path.dirname(normalizedVideoPath), webcamFileName) + await fs.access(webcamPath, fsConstants.F_OK) + + return { + videoPath: normalizedVideoPath, + webcamPath, + } + } catch { + return null + } +} + +async function resolveLinkedWebcamPath(videoPath?: string | null): Promise { + const normalizedVideoPath = normalizeVideoSourcePath(videoPath) + if (!normalizedVideoPath) { + return null + } + + const extension = path.extname(normalizedVideoPath) + const baseName = path.basename(normalizedVideoPath, extension) + if (!baseName || baseName.endsWith('-webcam')) { + return null + } + + const candidateExtensions = Array.from( + new Set([extension, '.webm', '.mp4', '.mov', '.mkv', '.avi'].filter(Boolean)), + ) + + for (const candidateExtension of candidateExtensions) { + const candidatePath = path.join( + path.dirname(normalizedVideoPath), + `${baseName}-webcam${candidateExtension}`, + ) + + try { + await fs.access(candidatePath, fsConstants.F_OK) + return candidatePath + } catch { + continue + } + } + + return null +} + +async function resolveRecordingSession(videoPath?: string | null): Promise { + const manifestSession = await resolveRecordingSessionManifest(videoPath) + if (manifestSession) { + return manifestSession + } + + const normalizedVideoPath = normalizeVideoSourcePath(videoPath) + if (!normalizedVideoPath) { + return null + } + + const linkedWebcamPath = await resolveLinkedWebcamPath(normalizedVideoPath) + return { + videoPath: normalizedVideoPath, + webcamPath: linkedWebcamPath, + } +} + async function hasSiblingProjectFile(videoPath: string) { const baseName = path.basename(videoPath, path.extname(videoPath)) const candidateExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] @@ -2724,7 +2855,18 @@ export function registerIpcHandlers( const project = JSON.parse(content) currentProjectPath = filePath if (project && typeof project === 'object' && typeof project.videoPath === 'string') { - currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath + const normalizedVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath + currentVideoPath = normalizedVideoPath + const webcamPath = + typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam + ?.sourcePath === 'string' + ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam + ?.sourcePath ?? null) + : null + currentRecordingSession = { + videoPath: normalizedVideoPath, + webcamPath, + } } return { @@ -2751,7 +2893,18 @@ 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 + const normalizedVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath + currentVideoPath = normalizedVideoPath + const webcamPath = + typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam + ?.sourcePath === 'string' + ? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam + ?.sourcePath ?? null) + : null + currentRecordingSession = { + videoPath: normalizedVideoPath, + webcamPath, + } } return { success: true, @@ -2767,18 +2920,54 @@ export function registerIpcHandlers( } } }) - ipcMain.handle('set-current-video-path', (_, path: string) => { + ipcMain.handle('set-current-video-path', async (_, path: string) => { currentVideoPath = normalizeVideoSourcePath(path) ?? path + const resolvedSession = await resolveRecordingSession(currentVideoPath) + ?? { + videoPath: currentVideoPath, + webcamPath: null, + } + + currentRecordingSession = resolvedSession + + if (resolvedSession.webcamPath) { + await persistRecordingSessionManifest(resolvedSession) + } + currentProjectPath = null + return { success: true, webcamPath: resolvedSession.webcamPath ?? null } + }) + + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null }) => { + const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath + currentVideoPath = normalizedVideoPath + currentRecordingSession = { + videoPath: normalizedVideoPath, + webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), + } + currentProjectPath = null + await persistRecordingSessionManifest(currentRecordingSession) return { success: true } }) + ipcMain.handle('get-current-recording-session', () => { + if (!currentRecordingSession) { + return { success: false } + } + + return { + success: true, + session: currentRecordingSession, + } + }) + ipcMain.handle('get-current-video-path', () => { return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; }); ipcMain.handle('clear-current-video-path', () => { currentVideoPath = null; + currentRecordingSession = null; return { success: true }; }); diff --git a/electron/preload.ts b/electron/preload.ts index e56b0b85..d9c3ec1f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -135,6 +135,12 @@ contextBridge.exposeInMainWorld("electronAPI", { setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, + setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null }) => { + return ipcRenderer.invoke("set-current-recording-session", session); + }, + getCurrentRecordingSession: () => { + return ipcRenderer.invoke("get-current-recording-session"); + }, getCurrentVideoPath: () => { return ipcRenderer.invoke("get-current-video-path"); }, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0ee828af..d178b4dc 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,10 +1,19 @@ import { Eye, EyeOff, Languages, Timer } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; 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, + MdOutlineVideocam, + MdOutlineVideocamOff, + MdVideoFile, + MdVolumeOff, + MdVolumeUp, +} from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n } from "@/contexts/I18nContext"; import type { AppLocale } from "@/i18n/config"; @@ -13,6 +22,7 @@ import { useScopedT } from "../../contexts/I18nContext"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useVideoDevices } from "../../hooks/useVideoDevices"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; import { ContentClamp } from "../ui/content-clamp"; @@ -39,14 +49,25 @@ export function LaunchWindow() { setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, countdownDelay, setCountdownDelay, } = useScreenRecorder(); const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); + const webcamPreviewRef = useRef(null); const showMicControls = microphoneEnabled && !recording; + const showWebcamControls = webcamEnabled && !recording; const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled); + const { + devices: videoDevices, + selectedDeviceId: selectedVideoDeviceId, + setSelectedDeviceId: setSelectedVideoDeviceId, + } = useVideoDevices(webcamEnabled); const { level } = useAudioLevelMeter({ enabled: showMicControls, deviceId: microphoneDeviceId, @@ -58,6 +79,65 @@ export function LaunchWindow() { } }, [selectedDeviceId, setMicrophoneDeviceId]); + useEffect(() => { + if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { + setWebcamDeviceId(selectedVideoDeviceId); + } + }, [selectedVideoDeviceId, setWebcamDeviceId]); + + useEffect(() => { + let mounted = true; + let previewStream: MediaStream | null = null; + + const startPreview = async () => { + if (!showWebcamControls || !webcamPreviewRef.current) { + return; + } + + try { + previewStream = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + } + : { + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + }, + audio: false, + }); + + if (!mounted || !webcamPreviewRef.current) { + previewStream.getTracks().forEach((track) => track.stop()); + return; + } + + webcamPreviewRef.current.srcObject = previewStream; + const playPromise = webcamPreviewRef.current.play(); + if (playPromise) { + playPromise.catch(() => {}); + } + } catch (error) { + console.warn("Failed to start live webcam preview:", error); + } + }; + + void startPreview(); + + return () => { + mounted = false; + if (webcamPreviewRef.current) { + webcamPreviewRef.current.pause(); + webcamPreviewRef.current.srcObject = null; + } + previewStream?.getTracks().forEach((track) => track.stop()); + }; + }, [showWebcamControls, webcamDeviceId]); + useEffect(() => { let timer: NodeJS.Timeout | null = null; if (recording) { @@ -262,6 +342,36 @@ export function LaunchWindow() { )} + {showWebcamControls && ( +
+
+
+ +
+ )} +
)} +
+
+ updateWebcam({ size: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, ""))} + /> +
+
+ updateWebcam({ cornerRadius: v })} + formatValue={(v) => `${Math.round(v)}px`} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} + /> +
+
+ updateWebcam({ shadow: v })} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} + /> +
+
+
+ {tSettings("effects.webcamReactToZoom")} +
+ updateWebcam({ reactToZoom })} + className="data-[state=checked]:bg-[#2563EB] scale-90" + /> +
+
+
+ {tSettings("effects.webcam")} +
+ updateWebcam({ enabled })} + className="data-[state=checked]:bg-[#2563EB] scale-90" + /> +
{tSettings("effects.removeBackground")}
{ - if (checked) { - removeBackgroundStateRef.current = { - aspectRatio, - padding, - }; - onAspectRatioChange?.('native'); - onPaddingChange?.(0); - } - }} + checked={removeBackgroundEnabled} + onCheckedChange={handleRemoveBackgroundToggle} className="data-[state=checked]:bg-[#2563EB] scale-90" />
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 116e37de..81bbb76a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -52,11 +52,13 @@ import { DEFAULT_ANNOTATION_STYLE, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, + DEFAULT_WEBCAM_OVERLAY, DEFAULT_ZOOM_DEPTH, type FigureData, type PlaybackSpeed, type SpeedRegion, type TrimRegion, + type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, type ZoomRegion, @@ -141,6 +143,9 @@ export default function VideoEditor() { const [borderRadius, setBorderRadius] = useState(initialEditorPreferences.borderRadius); const [padding, setPadding] = useState(initialEditorPreferences.padding); const [cropRegion, setCropRegion] = useState(initialEditorPreferences.cropRegion); + const [webcam, setWebcam] = useState( + initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, + ); const [zoomRegions, setZoomRegions] = useState([]); const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); @@ -335,6 +340,7 @@ export default function VideoEditor() { setBorderRadius(normalizedEditor.borderRadius); setPadding(normalizedEditor.padding); setCropRegion(normalizedEditor.cropRegion); + setWebcam(normalizedEditor.webcam); setZoomRegions(normalizedEditor.zoomRegions); setTrimRegions(normalizedEditor.trimRegions); setSpeedRegions(normalizedEditor.speedRegions); @@ -403,6 +409,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, zoomRegions, trimRegions, speedRegions, @@ -434,6 +441,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, zoomRegions, trimRegions, speedRegions, @@ -496,6 +504,21 @@ export default function VideoEditor() { } } + const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); + if (sessionResult?.success && sessionResult.session?.videoPath) { + const sourcePath = fromFileUrl(sessionResult.session.videoPath); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + setWebcam((prev) => ({ + ...prev, + enabled: Boolean(sessionResult.session?.webcamPath), + sourcePath: sessionResult.session?.webcamPath ?? null, + })); + return; + } + const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const sourcePath = fromFileUrl(result.path); @@ -503,6 +526,11 @@ export default function VideoEditor() { setVideoPath(toFileUrl(sourcePath)); setCurrentProjectPath(null); setLastSavedSnapshot(null); + setWebcam((prev) => ({ + ...prev, + enabled: false, + sourcePath: null, + })); } else { setError("No video to load. Please record or select a video."); } @@ -533,6 +561,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, aspectRatio, exportQuality, exportFormat, @@ -556,6 +585,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, aspectRatio, exportQuality, exportFormat, @@ -593,6 +623,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, zoomRegions, trimRegions, speedRegions, @@ -654,6 +685,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, zoomRegions, trimRegions, speedRegions, @@ -1572,6 +1604,8 @@ export default function VideoEditor() { padding, videoPadding: padding, cropRegion, + webcam, + webcamUrl: webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null, annotationRegions, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, @@ -1716,6 +1750,8 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, + webcamUrl: webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null, annotationRegions, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, @@ -1805,6 +1841,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + webcam, annotationRegions, isPlaying, aspectRatio, @@ -2040,6 +2077,8 @@ export default function VideoEditor() { borderRadius={borderRadius} padding={padding} cropRegion={cropRegion} + webcam={webcam} + webcamVideoPath={webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null} trimRegions={trimRegions} speedRegions={speedRegions} annotationRegions={annotationRegions} @@ -2167,6 +2206,8 @@ export default function VideoEditor() { onCursorSwayChange={setCursorSway} borderRadius={borderRadius} onBorderRadiusChange={setBorderRadius} + webcam={webcam} + onWebcamChange={setWebcam} padding={padding} onPaddingChange={setPadding} cropRegion={cropRegion} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 421b3833..de3c58a0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -32,6 +32,7 @@ import { type SpeedRegion, type AnnotationRegion, type CursorTelemetryPoint, + type WebcamOverlaySettings, } from "./types"; import { DEFAULT_FOCUS, @@ -68,6 +69,7 @@ import { DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_SWAY, } from "./types"; +import { getWebcamOverlaySizePx } from "./webcamOverlay"; type PlaybackAnimationState = { scale: number; @@ -112,6 +114,8 @@ interface VideoPlaybackProps { borderRadius?: number; padding?: number; cropRegion?: import("./types").CropRegion; + webcam?: WebcamOverlaySettings; + webcamVideoPath?: string | null; trimRegions?: TrimRegion[]; speedRegions?: SpeedRegion[]; aspectRatio: AspectRatio; @@ -169,6 +173,8 @@ const VideoPlayback = forwardRef( borderRadius = 0, padding = 50, cropRegion, + webcam, + webcamVideoPath, trimRegions = [], speedRegions = [], aspectRatio, @@ -199,6 +205,8 @@ const VideoPlayback = forwardRef( const [videoReady, setVideoReady] = useState(false); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); + const webcamVideoRef = useRef(null); + const webcamBubbleRef = useRef(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -238,6 +246,43 @@ const VideoPlayback = forwardRef( const cursorSwayRef = useRef(cursorSway); const motionBlurStateRef = useRef(createMotionBlurState()); + const applyWebcamBubbleLayout = useCallback((zoomScale: number) => { + const bubble = webcamBubbleRef.current; + const overlay = overlayRef.current; + if (!bubble || !overlay || !webcam?.enabled || !webcamVideoPath) { + if (bubble) { + bubble.style.display = "none"; + } + return; + } + + const margin = webcam.margin ?? 24; + const scaledSize = getWebcamOverlaySizePx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + sizePercent: webcam.size ?? 50, + margin, + zoomScale, + reactToZoom: webcam.reactToZoom ?? true, + }); + const x = webcam.corner.endsWith("right") + ? overlay.clientWidth - scaledSize - margin + : margin; + const y = webcam.corner.startsWith("bottom") + ? overlay.clientHeight - scaledSize - margin + : margin; + + bubble.style.display = "block"; + bubble.style.left = `${x}px`; + bubble.style.top = `${y}px`; + bubble.style.width = `${scaledSize}px`; + bubble.style.height = `${scaledSize}px`; + bubble.style.borderRadius = `${webcam.cornerRadius ?? 18}px`; + bubble.style.boxShadow = `0 ${Math.round(scaledSize * 0.06)}px ${Math.round( + scaledSize * 0.22, + )}px rgba(0, 0, 0, ${webcam.shadow ?? 0.35})`; + }, [webcam, webcamVideoPath]); + const clampFocusToStage = useCallback( (focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); @@ -336,8 +381,9 @@ const VideoPlayback = forwardRef( : null; updateOverlayForRegion(activeRegion); + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); } - }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); + }, [updateOverlayForRegion, cropRegion, borderRadius, padding, applyWebcamBubbleLayout]); useEffect(() => { layoutVideoContentRef.current = layoutVideoContent; @@ -649,6 +695,36 @@ const VideoPlayback = forwardRef( updateOverlayForRegion(selectedZoom); }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); + useEffect(() => { + if (!pixiReady || !videoReady) return; + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + }, [applyWebcamBubbleLayout, pixiReady, videoReady, webcam, webcamVideoPath]); + + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcam?.enabled || !webcamVideoPath) { + return; + } + + const targetTime = Math.max(0, currentTime); + if (Math.abs(webcamVideo.currentTime - targetTime) > (isPlaying ? 0.1 : 0.01)) { + try { + webcamVideo.currentTime = targetTime; + } catch { + // no-op + } + } + + if (isPlaying) { + const playPromise = webcamVideo.play(); + if (playPromise) { + playPromise.catch(() => {}); + } + } else { + webcamVideo.pause(); + } + }, [currentTime, isPlaying, webcam, webcamVideoPath]); + useEffect(() => { const overlayEl = overlayRef.current; if (!overlayEl) return; @@ -1043,6 +1119,7 @@ const VideoPlayback = forwardRef( motionIntensity, motionVector, ); + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); // Update cursor overlay const cursorOverlay = cursorOverlayRef.current; @@ -1064,7 +1141,7 @@ const VideoPlayback = forwardRef( app.ticker.remove(ticker); } }; - }, [pixiReady, videoReady, clampFocusToStage]); + }, [pixiReady, videoReady, clampFocusToStage, applyWebcamBubbleLayout]); useEffect(() => { const overlay = cursorOverlayRef.current; @@ -1243,6 +1320,26 @@ const VideoPlayback = forwardRef( className="absolute rounded-md border border-[#2563EB]/80 bg-[#2563EB]/20 shadow-[0_0_0_1px_rgba(37,99,235,0.35)]" style={{ display: "none", pointerEvents: "none" }} /> + {webcam && webcamVideoPath ? ( +
+
+ ) : null} {(() => { const filtered = (annotationRegions || []).filter( (annotation) => { diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 3c153e3b..8d2e89e8 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -17,6 +17,7 @@ type PersistedEditorControls = Pick< | "borderRadius" | "padding" | "cropRegion" + | "webcam" | "aspectRatio" | "exportQuality" | "exportFormat" @@ -53,6 +54,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, padding: DEFAULT_EDITOR_CONTROLS.padding, cropRegion: DEFAULT_EDITOR_CONTROLS.cropRegion, + webcam: DEFAULT_EDITOR_CONTROLS.webcam, aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality, exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat, @@ -142,6 +144,7 @@ function normalizeEditorControls( borderRadius: raw.borderRadius ?? fallback.borderRadius, padding: raw.padding ?? fallback.padding, cropRegion: normalizeCropRegion(raw.cropRegion, fallback.cropRegion), + webcam: raw.webcam ?? fallback.webcam, aspectRatio: raw.aspectRatio ?? fallback.aspectRatio, exportQuality: raw.exportQuality ?? fallback.exportQuality, exportFormat: raw.exportFormat ?? fallback.exportFormat, @@ -168,6 +171,7 @@ function normalizeEditorControls( borderRadius: normalized.borderRadius, padding: normalized.padding, cropRegion: normalized.cropRegion, + webcam: normalized.webcam, aspectRatio: normalized.aspectRatio, exportQuality: normalized.exportQuality, exportFormat: normalized.exportFormat, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index b5e03439..64eb9d91 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -14,12 +14,19 @@ import { DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_SWAY, + DEFAULT_WEBCAM_CORNER_RADIUS, + DEFAULT_WEBCAM_MARGIN, + DEFAULT_WEBCAM_OVERLAY, + DEFAULT_WEBCAM_REACT_TO_ZOOM, + DEFAULT_WEBCAM_SHADOW, + DEFAULT_WEBCAM_SIZE, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, type SpeedRegion, type TrimRegion, + type WebcamOverlaySettings, type ZoomRegion, } from "./types"; @@ -46,6 +53,7 @@ export interface ProjectEditorState { speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; audioRegions: AudioRegion[]; + webcam: WebcamOverlaySettings; aspectRatio: AspectRatio; exportQuality: ExportQuality; exportFormat: ExportFormat; @@ -347,6 +355,14 @@ export function normalizeProjectEditor(editor: Partial): Pro const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX); const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); + const webcam: Partial = + editor.webcam && typeof editor.webcam === "object" ? editor.webcam : {}; + const webcamSourcePath = typeof webcam.sourcePath === "string" ? webcam.sourcePath : null; + const legacyZoomScaleEffect = + isFiniteNumber((webcam as Partial<{ zoomScaleEffect: number }>).zoomScaleEffect) + ? (webcam as Partial<{ zoomScaleEffect: number }>).zoomScaleEffect + : null; + return { wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, @@ -383,6 +399,31 @@ export function normalizeProjectEditor(editor: Partial): Pro speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, audioRegions: normalizedAudioRegions, + webcam: { + enabled: + typeof webcam.enabled === "boolean" ? webcam.enabled : DEFAULT_WEBCAM_OVERLAY.enabled, + sourcePath: webcamSourcePath, + mirror: typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror, + corner: + webcam.corner === "top-left" || + webcam.corner === "top-right" || + webcam.corner === "bottom-left" || + webcam.corner === "bottom-right" + ? webcam.corner + : DEFAULT_WEBCAM_OVERLAY.corner, + size: isFiniteNumber(webcam.size) ? clamp(webcam.size, 10, 100) : DEFAULT_WEBCAM_SIZE, + reactToZoom: + typeof webcam.reactToZoom === "boolean" + ? webcam.reactToZoom + : legacyZoomScaleEffect !== null + ? legacyZoomScaleEffect > 0 + : DEFAULT_WEBCAM_REACT_TO_ZOOM, + cornerRadius: isFiniteNumber(webcam.cornerRadius) + ? clamp(webcam.cornerRadius, 0, 80) + : DEFAULT_WEBCAM_CORNER_RADIUS, + shadow: isFiniteNumber(webcam.shadow) ? clamp(webcam.shadow, 0, 1) : DEFAULT_WEBCAM_SHADOW, + margin: isFiniteNumber(webcam.margin) ? clamp(webcam.margin, 0, 96) : DEFAULT_WEBCAM_MARGIN, + }, aspectRatio: typeof editor.aspectRatio === "string" && (validAspectRatios.has(editor.aspectRatio as AspectRatio) || diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ccbaecb7..6a071f7b 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -44,12 +44,43 @@ export interface CursorVisualSettings { sway: number; } +export type WebcamCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +export interface WebcamOverlaySettings { + enabled: boolean; + sourcePath: string | null; + mirror: boolean; + corner: WebcamCorner; + size: number; + reactToZoom: boolean; + cornerRadius: number; + shadow: number; + margin: number; +} + export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; export const DEFAULT_CURSOR_SWAY = 0.25; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; +export const DEFAULT_WEBCAM_SIZE = 35; +export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true; +export const DEFAULT_WEBCAM_CORNER_RADIUS = 18; +export const DEFAULT_WEBCAM_SHADOW = 0.35; +export const DEFAULT_WEBCAM_MARGIN = 24; + +export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { + enabled: false, + sourcePath: null, + mirror: true, + corner: "bottom-right", + size: DEFAULT_WEBCAM_SIZE, + reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM, + cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS, + shadow: DEFAULT_WEBCAM_SHADOW, + margin: DEFAULT_WEBCAM_MARGIN, +}; export interface TrimRegion { id: string; diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts new file mode 100644 index 00000000..4e859d6b --- /dev/null +++ b/src/components/video-editor/webcamOverlay.ts @@ -0,0 +1,40 @@ +const MIN_WEBCAM_OVERLAY_SIZE_PX = 56; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function getWebcamOverlayScale( + zoomScale: number, + reactToZoom: boolean, +): number { + const safeZoomScale = Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : 1; + return reactToZoom ? 1 / safeZoomScale : 1; +} + +export function getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent, + margin, + zoomScale, + reactToZoom, +}: { + containerWidth: number; + containerHeight: number; + sizePercent: number; + margin: number; + zoomScale: number; + reactToZoom: boolean; +}): number { + const minDimension = Math.min(containerWidth, containerHeight); + const clampedSizePercent = clamp(sizePercent, 10, 100); + const safeMargin = Math.max(0, margin); + const maxSize = Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, minDimension - safeMargin * 2); + const scaledSize = + minDimension + * (clampedSizePercent / 100) + * getWebcamOverlayScale(zoomScale, reactToZoom); + + return Math.min(maxSize, Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, scaledSize)); +} \ No newline at end of file diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 57f26a91..a49a23c9 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -26,6 +26,11 @@ const VIDEO_FILE_EXTENSION = ".webm"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; +const WEBCAM_BITRATE = 8_000_000; +const WEBCAM_WIDTH = 1280; +const WEBCAM_HEIGHT = 720; +const WEBCAM_FRAME_RATE = 30; +const WEBCAM_SUFFIX = "-webcam"; type UseScreenRecorderReturn = { recording: boolean; @@ -39,6 +44,10 @@ type UseScreenRecorderReturn = { setMicrophoneDeviceId: (deviceId: string | undefined) => void; systemAudioEnabled: boolean; setSystemAudioEnabled: (enabled: boolean) => void; + webcamEnabled: boolean; + setWebcamEnabled: (enabled: boolean) => void; + webcamDeviceId: string | undefined; + setWebcamDeviceId: (deviceId: string | undefined) => void; countdownDelay: number; setCountdownDelay: (delay: number) => void; }; @@ -51,20 +60,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); + const [webcamEnabled, setWebcamEnabled] = useState(false); + const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); const [countdownDelay, setCountdownDelayState] = useState(3); const mediaRecorder = useRef(null); + const webcamRecorder = useRef(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); + const webcamStream = useRef(null); const mixingContext = useRef(null); const chunks = useRef([]); + const webcamChunks = useRef([]); const startTime = useRef(0); + const recordingSessionTimestamp = useRef(null); const nativeScreenRecording = useRef(false); const wgcRecording = useRef(false); const startInFlight = useRef(false); const hasPromptedForReselect = useRef(false); const hasShownWgcFallbackToast = useRef(false); const countdownDelayLoaded = useRef(false); + const pendingWebcamPathPromise = useRef | null>(null); + const webcamStopPromise = useRef | null>(null); + const webcamStopResolver = useRef<((path: string | null) => void) | null>(null); const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => { const platform = await window.electronAPI.getPlatform(); @@ -151,18 +169,142 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current = null; } + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + if (mixingContext.current) { mixingContext.current.close().catch(() => {}); mixingContext.current = null; } }, []); + const finalizeRecordingSession = useCallback(async (videoPath: string, webcamPath: string | null) => { + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession({ + videoPath, + webcamPath, + }); + } else { + await window.electronAPI.setCurrentVideoPath(videoPath); + } + + await window.electronAPI.switchToEditor(); + }, []); + + const stopWebcamRecorder = useCallback(async () => { + const recorder = webcamRecorder.current; + const pending = webcamStopPromise.current; + + if (!recorder) { + return null; + } + + if (recorder.state !== "inactive") { + recorder.stop(); + } + + const result = pending ? await pending : null; + pendingWebcamPathPromise.current = null; + return result; + }, []); + + const startWebcamRecorder = useCallback(async () => { + if (!webcamEnabled) { + pendingWebcamPathPromise.current = Promise.resolve(null); + return; + } + + try { + webcamStream.current = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + } + : { + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + }, + audio: false, + }); + + const mimeType = selectMimeType(); + webcamChunks.current = []; + webcamStopPromise.current = new Promise((resolve) => { + webcamStopResolver.current = resolve; + }); + pendingWebcamPathPromise.current = webcamStopPromise.current; + + const recorder = new MediaRecorder(webcamStream.current, { + mimeType, + videoBitsPerSecond: WEBCAM_BITRATE, + }); + + webcamRecorder.current = recorder; + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + webcamChunks.current.push(event.data); + } + }; + recorder.onerror = () => { + webcamStopResolver.current?.(null); + webcamStopResolver.current = null; + }; + recorder.onstop = async () => { + const sessionTimestamp = recordingSessionTimestamp.current ?? Date.now(); + const webcamFileName = `${RECORDING_FILE_PREFIX}${sessionTimestamp}${WEBCAM_SUFFIX}${VIDEO_FILE_EXTENSION}`; + + try { + if (webcamChunks.current.length === 0) { + webcamStopResolver.current?.(null); + return; + } + + const duration = Date.now() - startTime.current; + const webcamBlob = new Blob(webcamChunks.current, { type: mimeType }); + webcamChunks.current = []; + const fixedBlob = await fixWebmDuration(webcamBlob, duration); + const arrayBuffer = await fixedBlob.arrayBuffer(); + const result = await window.electronAPI.storeRecordedVideo(arrayBuffer, webcamFileName); + webcamStopResolver.current?.(result.success ? result.path ?? null : null); + } catch (error) { + console.error("Error saving webcam recording:", error); + webcamStopResolver.current?.(null); + } finally { + webcamStopResolver.current = null; + webcamRecorder.current = null; + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + } + }; + + recorder.start(RECORDER_TIMESLICE_MS); + } catch (error) { + console.warn("Failed to start webcam recording; continuing without webcam layer:", error); + pendingWebcamPathPromise.current = Promise.resolve(null); + webcamStopPromise.current = Promise.resolve(null); + webcamRecorder.current = null; + if (webcamStream.current) { + webcamStream.current.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + } + }, [webcamDeviceId, webcamEnabled]); + const stopRecording = useRef(() => { if (nativeScreenRecording.current) { nativeScreenRecording.current = false; setRecording(false); void (async () => { + const webcamPath = await stopWebcamRecorder(); const isWgc = wgcRecording.current; wgcRecording.current = false; @@ -181,13 +323,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalPath = muxResult?.path ?? result.path; } - await window.electronAPI.setCurrentVideoPath(finalPath); - await window.electronAPI.switchToEditor(); + await finalizeRecordingSession(finalPath, webcamPath); })(); return; } if (mediaRecorder.current?.state === "recording") { + pendingWebcamPathPromise.current = stopWebcamRecorder(); cleanupCapturedMedia(); mediaRecorder.current.stop(); setRecording(false); @@ -286,6 +428,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + recordingSessionTimestamp.current = Date.now(); + startTime.current = recordingSessionTimestamp.current; + await startWebcamRecorder(); + const platform = await window.electronAPI.getPlatform(); const useNativeMacScreenCapture = platform === "darwin" && @@ -552,7 +698,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const recordedChunks = chunks.current; const buggyBlob = new Blob(recordedChunks, { type: mimeType }); chunks.current = []; - const timestamp = Date.now(); + const timestamp = recordingSessionTimestamp.current ?? Date.now(); const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`; try { @@ -565,10 +711,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (videoResult.path) { - await window.electronAPI.setCurrentVideoPath(videoResult.path); + const webcamPath = await (pendingWebcamPathPromise.current ?? Promise.resolve(null)); + await finalizeRecordingSession(videoResult.path, webcamPath); } - - await window.electronAPI.switchToEditor(); } catch (error) { console.error("Error saving recording:", error); } @@ -585,6 +730,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { alert(error instanceof Error ? `Failed to start recording: ${error.message}` : "Failed to start recording"); setRecording(false); cleanupCapturedMedia(); + await stopWebcamRecorder(); } finally { startInFlight.current = false; setStarting(false); @@ -629,6 +775,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, countdownDelay, setCountdownDelay, }; diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts new file mode 100644 index 00000000..0571e4f7 --- /dev/null +++ b/src/hooks/useVideoDevices.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' + +export interface VideoDevice { + deviceId: string + label: string + groupId: string +} + +export function useVideoDevices(enabled: boolean = true) { + const [devices, setDevices] = useState([]) + const [selectedDeviceId, setSelectedDeviceId] = useState('default') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled) { + return + } + + let mounted = true + + const loadDevices = async () => { + try { + setIsLoading(true) + setError(null) + + const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) + const allDevices = await navigator.mediaDevices.enumerateDevices() + const videoInputs = allDevices + .filter((device) => device.kind === 'videoinput') + .map((device, index) => ({ + deviceId: device.deviceId, + label: device.label || `Camera ${index + 1}`, + groupId: device.groupId, + })) + + stream.getTracks().forEach((track) => track.stop()) + + if (mounted) { + setDevices(videoInputs) + if (selectedDeviceId === 'default' && videoInputs.length > 0) { + setSelectedDeviceId(videoInputs[0].deviceId) + } + setIsLoading(false) + } + } catch (error) { + if (mounted) { + const message = error instanceof Error ? error.message : 'Failed to enumerate video devices' + setError(message) + setIsLoading(false) + console.error('Error loading video devices:', error) + } + } + } + + void loadDevices() + + const handleDeviceChange = () => { + void loadDevices() + } + + navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange) + + return () => { + mounted = false + navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange) + } + }, [enabled, selectedDeviceId]) + + return { + devices, + selectedDeviceId, + setSelectedDeviceId, + isLoading, + error, + } +} diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 7d90b4dd..9d3a9faa 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -4,6 +4,8 @@ "enableSystemAudio": "Enable system audio", "disableMicrophone": "Disable microphone", "enableMicrophone": "Enable microphone", + "disableWebcam": "Disable webcam overlay", + "enableWebcam": "Enable webcam overlay", "countdownDelay": "Countdown delay", "noDelay": "No delay", "record": "Record", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index c8f1e34b..7cd90cb4 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -25,6 +25,11 @@ "cursorMotionBlur": "Cursor Motion Blur", "cursorClickBounce": "Cursor Click Bounce", "cursorSway": "Cursor Sway", + "webcam": "Webcam Overlay", + "webcamSize": "Webcam Size", + "webcamReactToZoom": "Webcam Reacts To Zoom", + "webcamRoundness": "Webcam Roundness", + "webcamShadow": "Webcam Shadow", "shadow": "Shadow", "roundness": "Roundness", "padding": "Padding", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 3d339297..d8a12baa 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -4,6 +4,8 @@ "enableSystemAudio": "Activar audio del sistema", "disableMicrophone": "Desactivar micrófono", "enableMicrophone": "Activar micrófono", + "disableWebcam": "Desactivar superposición de cámara", + "enableWebcam": "Activar superposición de cámara", "countdownDelay": "Retraso de cuenta regresiva", "noDelay": "Sin retraso", "record": "Grabar", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 4b61a525..f41b385b 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -25,6 +25,11 @@ "cursorMotionBlur": "Desenfoque de movimiento del cursor", "cursorClickBounce": "Rebote de clic del cursor", "cursorSway": "Balanceo del cursor", + "webcam": "Superposición de cámara", + "webcamSize": "Tamaño de cámara", + "webcamReactToZoom": "La cámara reacciona al zoom", + "webcamRoundness": "Redondez de cámara", + "webcamShadow": "Sombra de cámara", "shadow": "Sombra", "roundness": "Redondez", "padding": "Relleno", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 015d80b3..6c3d91d3 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -4,6 +4,8 @@ "enableSystemAudio": "启用系统音频", "disableMicrophone": "禁用麦克风", "enableMicrophone": "启用麦克风", + "disableWebcam": "禁用摄像头叠加", + "enableWebcam": "启用摄像头叠加", "countdownDelay": "倒计时延迟", "noDelay": "无延迟", "record": "录制", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 12919e21..7db0c6ad 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -25,6 +25,11 @@ "cursorMotionBlur": "光标运动模糊", "cursorClickBounce": "光标点击弹跳", "cursorSway": "光标摆动", + "webcam": "摄像头叠加", + "webcamSize": "摄像头大小", + "webcamReactToZoom": "摄像头随缩放变化", + "webcamRoundness": "摄像头圆角", + "webcamShadow": "摄像头阴影", "shadow": "阴影", "roundness": "圆角", "padding": "内边距", diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index c88ffee3..a8cff4d1 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -13,6 +13,7 @@ import type { AnnotationRegion, SpeedRegion, CursorTelemetryPoint, + WebcamOverlaySettings, } from "@/components/video-editor/types"; import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; @@ -35,6 +36,7 @@ import { DEFAULT_CURSOR_CONFIG, preloadCursorAssets, } from "@/components/video-editor/videoPlayback/cursorRenderer"; +import { getWebcamOverlaySizePx } from "@/components/video-editor/webcamOverlay"; interface FrameRenderConfig { width: number; @@ -49,6 +51,8 @@ interface FrameRenderConfig { borderRadius?: number; padding?: number; cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; videoWidth: number; videoHeight: number; annotationRegions?: AnnotationRegion[]; @@ -109,6 +113,11 @@ export class FrameRenderer { private currentVideoTime = 0; private lastMotionVector = { x: 0, y: 0 }; private cursorOverlay: PixiCursorOverlay | null = null; + private webcamVideoElement: HTMLVideoElement | null = null; + private webcamSeekPromise: Promise | null = null; + private webcamFrameCacheCanvas: HTMLCanvasElement | null = null; + private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null; + private lastSyncedWebcamTime: number | null = null; constructor(config: FrameRenderConfig) { this.config = config; @@ -182,6 +191,7 @@ export class FrameRenderer { // Setup background (render separately, not in PixiJS) await this.setupBackground(); + await this.setupWebcamSource(); // Setup blur filter for video container this.blurFilter = new BlurFilter(); @@ -409,6 +419,162 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private async setupWebcamSource(): Promise { + const webcamUrl = this.config.webcamUrl; + if (!this.config.webcam?.enabled || !webcamUrl) { + this.webcamVideoElement = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + return; + } + + const video = document.createElement("video"); + video.src = webcamUrl; + video.muted = true; + video.preload = "auto"; + video.playsInline = true; + video.load(); + + await new Promise((resolve, reject) => { + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("Failed to load webcam source for export")); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("canplaythrough", onReady); + video.removeEventListener("error", onError); + }; + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + resolve(); + return; + } + video.addEventListener("loadeddata", onReady, { once: true }); + video.addEventListener("canplay", onReady, { once: true }); + video.addEventListener("canplaythrough", onReady, { once: true }); + video.addEventListener("error", onError, { once: true }); + }).catch((error) => { + console.warn("[FrameRenderer] Webcam overlay unavailable during export:", error); + this.webcamVideoElement = null; + }); + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + this.webcamVideoElement = video; + return; + } + + this.webcamVideoElement = null; + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; + } + + private async syncWebcamFrame(targetTime: number): Promise { + 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)); + + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.008) { + this.lastSyncedWebcamTime = clampedTime; + return; + } + + if (this.webcamSeekPromise) { + await this.webcamSeekPromise; + } + + this.webcamSeekPromise = new Promise((resolve) => { + let settled = false; + let fallbackTimeout: number | null = null; + const waitForPresentedFrame = () => { + requestAnimationFrame(() => { + finish(); + }); + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + if (Math.abs(webcamVideo.currentTime - clampedTime) <= 0.02) { + this.lastSyncedWebcamTime = clampedTime; + } + cleanup(); + resolve(); + }; + const handleMediaReady = () => { + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.01 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }; + const cleanup = () => { + webcamVideo.removeEventListener("seeked", waitForPresentedFrame); + webcamVideo.removeEventListener("loadeddata", handleMediaReady); + webcamVideo.removeEventListener("canplay", handleMediaReady); + webcamVideo.removeEventListener("error", finish); + if (fallbackTimeout !== null) { + window.clearTimeout(fallbackTimeout); + } + }; + + webcamVideo.addEventListener("seeked", waitForPresentedFrame, { + once: true, + }); + webcamVideo.addEventListener("loadeddata", handleMediaReady, { + once: true, + }); + webcamVideo.addEventListener("canplay", handleMediaReady, { + once: true, + }); + webcamVideo.addEventListener("error", finish, { + once: true, + }); + fallbackTimeout = window.setTimeout(() => { + finish(); + }, 250); + + try { + webcamVideo.currentTime = clampedTime; + } catch { + finish(); + return; + } + + if ( + !webcamVideo.seeking && + Math.abs(webcamVideo.currentTime - clampedTime) <= 0.001 && + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + waitForPresentedFrame(); + } + }); + + try { + await this.webcamSeekPromise; + } finally { + this.webcamSeekPromise = null; + } + } + async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { if (!this.app || !this.videoContainer || !this.cameraContainer) { throw new Error("Renderer not initialized"); @@ -416,6 +582,11 @@ export class FrameRenderer { this.currentVideoTime = timestamp / 1000000; + if (this.webcamVideoElement) { + const targetTime = Math.max(0, this.currentVideoTime); + await this.syncWebcamFrame(targetTime); + } + // Create or update video sprite from VideoFrame if (!this.videoSprite) { const texture = Texture.from(videoFrame as any); @@ -763,6 +934,122 @@ export class FrameRenderer { } else { ctx.drawImage(videoCanvas, 0, 0, w, h); } + + this.drawWebcamOverlay(ctx, w, h); + } + + private drawWebcamOverlay( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + ): void { + const webcam = this.config.webcam; + const webcamVideo = this.webcamVideoElement; + if (!webcam?.enabled || !webcamVideo || webcamVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + + const margin = webcam.margin ?? 24; + const size = getWebcamOverlaySizePx({ + containerWidth: width, + containerHeight: height, + sizePercent: webcam.size ?? 50, + margin, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const x = webcam.corner.endsWith("right") ? width - size - margin : margin; + 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"); + if (!bubbleCtx) { + return; + } + + const canRefreshCache = + webcamVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + !webcamVideo.seeking && + 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; + + if (canRefreshCache) { + if ( + !this.webcamFrameCacheCanvas || + this.webcamFrameCacheCanvas.width !== webcamVideo.videoWidth || + this.webcamFrameCacheCanvas.height !== webcamVideo.videoHeight + ) { + this.webcamFrameCacheCanvas = document.createElement("canvas"); + this.webcamFrameCacheCanvas.width = webcamVideo.videoWidth; + this.webcamFrameCacheCanvas.height = webcamVideo.videoHeight; + this.webcamFrameCacheCtx = this.webcamFrameCacheCanvas.getContext("2d"); + } + + this.webcamFrameCacheCtx?.clearRect( + 0, + 0, + this.webcamFrameCacheCanvas!.width, + this.webcamFrameCacheCanvas!.height, + ); + this.webcamFrameCacheCtx?.drawImage( + webcamVideo, + 0, + 0, + this.webcamFrameCacheCanvas!.width, + this.webcamFrameCacheCanvas!.height, + ); + } + + const webcamFrameSource = canRefreshCache + ? webcamVideo + : this.webcamFrameCacheCanvas; + if (!webcamFrameSource) { + return; + } + + const sourceWidth = + ("videoWidth" in webcamFrameSource + ? webcamFrameSource.videoWidth + : webcamFrameSource.width) || size; + const sourceHeight = + ("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.beginPath(); + bubbleCtx.roundRect(0, 0, size, size, radius); + bubbleCtx.clip(); + if (webcam.mirror) { + bubbleCtx.save(); + bubbleCtx.translate(size, 0); + bubbleCtx.scale(-1, 1); + bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight); + bubbleCtx.restore(); + } else { + bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight); + } + + if ((webcam.shadow ?? 0) > 0) { + const shadow = Math.max(0, Math.min(1, webcam.shadow)); + ctx.save(); + ctx.filter = `drop-shadow(0 ${Math.round(size * 0.06)}px ${Math.round(size * 0.22)}px rgba(0,0,0,${shadow}))`; + ctx.drawImage(bubbleCanvas, x, y, size, size); + ctx.restore(); + return; + } + + ctx.drawImage(bubbleCanvas, x, y, size, size); } getCanvas(): HTMLCanvasElement { @@ -801,5 +1088,14 @@ export class FrameRenderer { this.shadowCtx = null; this.compositeCanvas = null; this.compositeCtx = null; + if (this.webcamVideoElement) { + this.webcamVideoElement.pause(); + this.webcamVideoElement.src = ""; + this.webcamVideoElement.load(); + this.webcamVideoElement = null; + } + this.webcamFrameCacheCanvas = null; + this.webcamFrameCacheCtx = null; + this.lastSyncedWebcamTime = null; } } diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 9961f4ca..bd69c330 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -15,6 +15,7 @@ import type { AnnotationRegion, SpeedRegion, CursorTelemetryPoint, + WebcamOverlaySettings, } from "@/components/video-editor/types"; const GIF_WORKER_URL = new URL( @@ -42,6 +43,8 @@ interface GifExporterConfig { padding?: number; videoPadding?: number; cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; annotationRegions?: AnnotationRegion[]; cursorTelemetry?: CursorTelemetryPoint[]; showCursor?: boolean; @@ -125,6 +128,8 @@ export class GifExporter { borderRadius: this.config.borderRadius, padding: this.config.padding, cropRegion: this.config.cropRegion, + webcam: this.config.webcam, + webcamUrl: this.config.webcamUrl, videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions, diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 4c070007..0c592a5e 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -5,6 +5,7 @@ import type { CursorTelemetryPoint, SpeedRegion, TrimRegion, + WebcamOverlaySettings, ZoomRegion, } from "@/components/video-editor/types"; import { AudioProcessor } from "./audioEncoder"; @@ -28,6 +29,8 @@ interface VideoExporterConfig extends ExportConfig { padding?: number; videoPadding?: number; cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; annotationRegions?: AnnotationRegion[]; cursorTelemetry?: CursorTelemetryPoint[]; showCursor?: boolean; @@ -86,6 +89,8 @@ export class VideoExporter { borderRadius: this.config.borderRadius, padding: this.config.padding, cropRegion: this.config.cropRegion, + webcam: this.config.webcam, + webcamUrl: this.config.webcamUrl, videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions,