feat: add webcam overlay editing and export support

This commit is contained in:
webadderall
2026-03-18 14:26:42 +11:00
parent d6239af7d2
commit 8eed3bbee1
22 changed files with 1246 additions and 24 deletions
+8
View File
@@ -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: (
+192 -3
View File
@@ -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<void> {
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<RecordingSessionData | null> {
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<RecordingSessionManifest>
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<string | null> {
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<RecordingSessionData | null> {
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 };
});
+6
View File
@@ -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");
},
+130 -2
View File
@@ -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<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(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() {
</div>
)}
{showWebcamControls && (
<div
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
>
<div className="h-9 w-9 overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
<video
ref={webcamPreviewRef}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
<select
value={webcamDeviceId || selectedVideoDeviceId}
onChange={(event) => {
setSelectedVideoDeviceId(event.target.value);
setWebcamDeviceId(event.target.value);
}}
className="max-w-[230px] rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs text-white outline-none"
>
{videoDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</div>
)}
<div
className={`mx-auto inline-flex max-w-full items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
style={{
@@ -329,6 +439,24 @@ export function LaunchWindow() {
<MdVolumeOff size={16} className="text-white/35" />
)}
</Button>
<Button
variant="link"
size="icon"
onClick={() => !recording && setWebcamEnabled(!webcamEnabled)}
disabled={recording}
title={
webcamEnabled
? t("recording.disableWebcam")
: t("recording.enableWebcam")
}
className="text-white/80 hover:bg-transparent"
>
{webcamEnabled ? (
<MdOutlineVideocam size={16} className="text-[#2563EB]" />
) : (
<MdOutlineVideocamOff size={16} className="text-white/35" />
)}
</Button>
<Button
variant="link"
size="icon"
+94 -11
View File
@@ -43,9 +43,14 @@ import type {
CropRegion,
FigureData,
PlaybackSpeed,
WebcamOverlaySettings,
ZoomDepth,
} from "./types";
import {
DEFAULT_WEBCAM_CORNER_RADIUS,
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_SHADOW,
DEFAULT_WEBCAM_SIZE,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
@@ -134,6 +139,8 @@ interface SettingsPanelProps {
onCursorSwayChange?: (amount: number) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
webcam?: WebcamOverlaySettings;
onWebcamChange?: (webcam: WebcamOverlaySettings) => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
cropRegion?: CropRegion;
@@ -213,6 +220,8 @@ export function SettingsPanel({
onCursorSwayChange,
borderRadius = 12.5,
onBorderRadiusChange,
webcam,
onWebcamChange,
padding = 50,
onPaddingChange,
cropRegion,
@@ -302,6 +311,7 @@ export function SettingsPanel({
const [gradient, setGradient] = useState<string>(
GRADIENTS.includes(selected) ? selected : GRADIENTS[0],
);
const removeBackgroundEnabled = aspectRatio === "native" && padding === 0;
const [backgroundTab, setBackgroundTab] = useState<BackgroundTab>(() =>
getBackgroundTabForWallpaper(selected),
);
@@ -328,6 +338,24 @@ export function SettingsPanel({
saveEditorPreferences({ customWallpapers: customImages });
}, [customImages]);
const handleRemoveBackgroundToggle = (checked: boolean) => {
if (checked) {
removeBackgroundStateRef.current = {
aspectRatio,
padding,
};
onAspectRatioChange?.("native");
onPaddingChange?.(0);
return;
}
if (removeBackgroundStateRef.current) {
onAspectRatioChange?.(removeBackgroundStateRef.current.aspectRatio);
onPaddingChange?.(removeBackgroundStateRef.current.padding);
removeBackgroundStateRef.current = null;
}
};
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
@@ -357,6 +385,11 @@ export function SettingsPanel({
setShowCropModal(false);
};
const updateWebcam = (patch: Partial<WebcamOverlaySettings>) => {
if (!webcam || !onWebcamChange) return;
onWebcamChange({ ...webcam, ...patch });
};
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
@@ -724,6 +757,65 @@ export function SettingsPanel({
parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100}
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<SliderControl
label={tSettings("effects.webcamSize")}
value={webcam?.size ?? DEFAULT_WEBCAM_SIZE}
defaultValue={DEFAULT_WEBCAM_SIZE}
min={10}
max={100}
step={1}
onChange={(v) => updateWebcam({ size: v })}
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(t) => parseFloat(t.replace(/%$/, ""))}
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<SliderControl
label={tSettings("effects.webcamRoundness")}
value={webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS}
defaultValue={DEFAULT_WEBCAM_CORNER_RADIUS}
min={0}
max={80}
step={1}
onChange={(v) => updateWebcam({ cornerRadius: v })}
formatValue={(v) => `${Math.round(v)}px`}
parseInput={(t) => parseFloat(t.replace(/px$/, ""))}
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<SliderControl
label={tSettings("effects.webcamShadow")}
value={webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW}
defaultValue={DEFAULT_WEBCAM_SHADOW}
min={0}
max={1}
step={0.01}
onChange={(v) => updateWebcam({ shadow: v })}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">
{tSettings("effects.webcamReactToZoom")}
</div>
<Switch
checked={webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM}
onCheckedChange={(reactToZoom) => updateWebcam({ reactToZoom })}
className="data-[state=checked]:bg-[#2563EB] scale-90"
/>
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">
{tSettings("effects.webcam")}
</div>
<Switch
checked={webcam?.enabled ?? false}
onCheckedChange={(enabled) => updateWebcam({ enabled })}
className="data-[state=checked]:bg-[#2563EB] scale-90"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<SliderControl
label={tSettings("effects.roundness")}
@@ -753,17 +845,8 @@ export function SettingsPanel({
<div className="col-span-2 flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">{tSettings("effects.removeBackground")}</div>
<Switch
checked={aspectRatio === 'native' && padding === 0}
onCheckedChange={(checked) => {
if (checked) {
removeBackgroundStateRef.current = {
aspectRatio,
padding,
};
onAspectRatioChange?.('native');
onPaddingChange?.(0);
}
}}
checked={removeBackgroundEnabled}
onCheckedChange={handleRemoveBackgroundToggle}
className="data-[state=checked]:bg-[#2563EB] scale-90"
/>
</div>
@@ -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<CropRegion>(initialEditorPreferences.cropRegion);
const [webcam, setWebcam] = useState<WebcamOverlaySettings>(
initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY,
);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(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}
+99 -2
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
borderRadius = 0,
padding = 50,
cropRegion,
webcam,
webcamVideoPath,
trimRegions = [],
speedRegions = [],
aspectRatio,
@@ -199,6 +205,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [videoReady, setVideoReady] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -238,6 +246,43 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cursorSwayRef = useRef(cursorSway);
const motionBlurStateRef = useRef<MotionBlurState>(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<VideoPlaybackRef, VideoPlaybackProps>(
: 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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
motionIntensity,
motionVector,
);
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
// Update cursor overlay
const cursorOverlay = cursorOverlayRef.current;
@@ -1064,7 +1141,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.remove(ticker);
}
};
}, [pixiReady, videoReady, clampFocusToStage]);
}, [pixiReady, videoReady, clampFocusToStage, applyWebcamBubbleLayout]);
useEffect(() => {
const overlay = cursorOverlayRef.current;
@@ -1243,6 +1320,26 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 ? (
<div
ref={webcamBubbleRef}
className="absolute overflow-hidden bg-black/80"
style={{
display: webcam.enabled ? "block" : "none",
pointerEvents: "none",
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="h-full w-full object-cover"
muted
playsInline
preload="auto"
style={{ transform: webcam.mirror ? "scaleX(-1)" : undefined }}
/>
</div>
) : null}
{(() => {
const filtered = (annotationRegions || []).filter(
(annotation) => {
@@ -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,
@@ -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<ProjectEditorState>): Pro
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
const webcam: Partial<WebcamOverlaySettings> =
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<ProjectEditorState>): 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) ||
+31
View File
@@ -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;
@@ -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));
}
+156 -6
View File
@@ -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<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabled] = useState(false);
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
const [countdownDelay, setCountdownDelayState] = useState(3);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const webcamRecorder = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const chunks = useRef<Blob[]>([]);
const webcamChunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
const recordingSessionTimestamp = useRef<number | null>(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<Promise<string | null> | null>(null);
const webcamStopPromise = useRef<Promise<string | null> | 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,
};
+77
View File
@@ -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<VideoDevice[]>([])
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(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,
}
}
+2
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -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",
+5
View File
@@ -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",
+2
View File
@@ -4,6 +4,8 @@
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
"enableMicrophone": "启用麦克风",
"disableWebcam": "禁用摄像头叠加",
"enableWebcam": "启用摄像头叠加",
"countdownDelay": "倒计时延迟",
"noDelay": "无延迟",
"record": "录制",
+5
View File
@@ -25,6 +25,11 @@
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
"cursorSway": "光标摆动",
"webcam": "摄像头叠加",
"webcamSize": "摄像头大小",
"webcamReactToZoom": "摄像头随缩放变化",
"webcamRoundness": "摄像头圆角",
"webcamShadow": "摄像头阴影",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距",
+296
View File
@@ -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<void> | 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<void> {
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<void>((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<void> {
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<void>((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<void> {
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;
}
}
+5
View File
@@ -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,
+5
View File
@@ -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,