Add webcam recording overlay support

This commit is contained in:
Marcus Schiesser
2026-03-17 19:09:34 +08:00
parent 881acdb26f
commit 2fb5b3b574
18 changed files with 1048 additions and 186 deletions
+24 -1
View File
@@ -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<number | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -233,6 +246,16 @@ export function LaunchWindow() {
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setWebcamEnabled(!webcamEnabled)}
disabled={recording}
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
+68 -16
View File
@@ -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<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [webcamVideoPath, setWebcamVideoPath] = useState<string | null>(null);
const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -111,6 +115,19 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
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() {
}}
>
<VideoPlayback
key={videoPath || "no-video"}
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
+117 -1
View File
@@ -19,6 +19,7 @@ import {
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -55,6 +56,7 @@ import {
interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
@@ -98,6 +100,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
(
{
videoPath,
webcamVideoPath,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -129,7 +132,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
@@ -139,6 +144,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [videoReady, setVideoReady] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -901,6 +911,90 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(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<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={stageRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -1008,12 +1103,33 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="absolute object-cover pointer-events-none"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
muted
preload="metadata"
playsInline
/>
)}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none" }}
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
} from "./projectPersistence";
describe("projectPersistence media compatibility", () => {
it("accepts legacy projects with a single videoPath", () => {
const project = {
version: 1,
videoPath: "/tmp/screen.webm",
editor: {},
};
expect(validateProjectData(project)).toBe(true);
expect(resolveProjectMedia(project)).toEqual({
screenVideoPath: "/tmp/screen.webm",
});
});
it("creates version 2 projects with explicit media", () => {
const project = createProjectData(
{
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
},
{
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
gifLoop: true,
gifSizePreset: "medium",
},
);
expect(project.version).toBe(PROJECT_VERSION);
expect(project.media).toEqual({
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
});
expect(validateProjectData(project)).toBe(true);
});
});
@@ -1,4 +1,6 @@
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
@@ -22,7 +24,7 @@ export const WALLPAPER_PATHS = Array.from(
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const PROJECT_VERSION = 1;
export const PROJECT_VERSION = 2;
export interface ProjectEditorState {
wallpaper: string;
@@ -46,8 +48,9 @@ export interface ProjectEditorState {
export interface EditorProjectData {
version: number;
videoPath: string;
media?: ProjectMedia;
editor: ProjectEditorState;
videoPath?: string;
}
function isFiniteNumber(value: unknown): value is number {
@@ -139,11 +142,26 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!resolveProjectMedia(project)) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
}
export function resolveProjectMedia(
candidate: Partial<EditorProjectData> | { media?: unknown; videoPath?: unknown },
): ProjectMedia | null {
const media = normalizeProjectMedia(candidate.media);
if (media) {
return media;
}
if (typeof candidate.videoPath === "string" && candidate.videoPath.trim()) {
return { screenVideoPath: candidate.videoPath };
}
return null;
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
@@ -346,12 +364,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
}
export function createProjectData(
videoPath: string,
media: ProjectMedia,
editor: ProjectEditorState,
): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
media,
editor,
};
}