mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
feat: add webcam overlay editing and export support
This commit is contained in:
Vendored
+8
@@ -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
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableMicrophone": "禁用麦克风",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"disableWebcam": "禁用摄像头叠加",
|
||||
"enableWebcam": "启用摄像头叠加",
|
||||
"countdownDelay": "倒计时延迟",
|
||||
"noDelay": "无延迟",
|
||||
"record": "录制",
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"cursorMotionBlur": "光标运动模糊",
|
||||
"cursorClickBounce": "光标点击弹跳",
|
||||
"cursorSway": "光标摆动",
|
||||
"webcam": "摄像头叠加",
|
||||
"webcamSize": "摄像头大小",
|
||||
"webcamReactToZoom": "摄像头随缩放变化",
|
||||
"webcamRoundness": "摄像头圆角",
|
||||
"webcamShadow": "摄像头阴影",
|
||||
"shadow": "阴影",
|
||||
"roundness": "圆角",
|
||||
"padding": "内边距",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user