From ca826d90880685bf08a30bba4021f7f3e925b3d0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 22 May 2026 09:53:22 +0200 Subject: [PATCH 1/4] Fix native Windows recording pause --- electron/electron-env.d.ts | 8 ++ electron/ipc/handlers.ts | 75 +++++++++++ .../wgc-capture/src/audio_sample_utils.cpp | 23 +++- .../wgc-capture/src/audio_sample_utils.h | 2 + electron/native/wgc-capture/src/main.cpp | 121 +++++++++++++----- electron/preload.ts | 6 + scripts/build-windows-wgc-helper.mjs | 21 ++- src/hooks/useScreenRecorder.ts | 39 +++++- 8 files changed, 257 insertions(+), 38 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index abb688d..1b82992 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -117,6 +117,14 @@ interface Window { discarded?: boolean; error?: string; }>; + pauseNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 009ade6..669f1dd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -384,6 +384,10 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeWindowsCursorRecordingStartMs = 0; +let nativeWindowsPauseStartedAtMs: number | null = null; +let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeWindowsIsPaused = false; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; let nativeMacCaptureOutput = ""; @@ -873,6 +877,18 @@ function completeNativeMacCursorPauseRange(endMs = Date.now()) { nativeMacPauseStartedAtMs = null; } +function completeNativeWindowsCursorPauseRange(endMs = Date.now()) { + if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) { + return; + } + + nativeWindowsPauseRanges.push({ + startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs), + }); + nativeWindowsPauseStartedAtMs = null; +} + function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1583,9 +1599,14 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = recordingId; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = cursorCaptureMode; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { + nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs); console.info("[native-wgc] cursor sampler ready", { cursorStartTimeMs, @@ -1635,6 +1656,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; await stopCursorRecording(); return { success: false, error: String(error) }; } @@ -1836,6 +1861,50 @@ export function registerIpcHandlers( } }); + ipcMain.handle("pause-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeWindowsIsPaused = true; + nativeWindowsPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (!nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeWindowsCursorPauseRange(); + nativeWindowsIsPaused = false; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => { const proc = nativeWindowsCaptureProcess; const preferredPath = nativeWindowsCaptureTargetPath; @@ -1848,6 +1917,7 @@ export function registerIpcHandlers( } try { + completeNativeWindowsCursorPauseRange(); const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); proc.stdin.write("stop\n"); const stoppedPath = await stoppedPathPromise; @@ -1872,6 +1942,7 @@ export function registerIpcHandlers( } if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges); shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); await writePendingCursorTelemetry(screenVideoPath); } @@ -1913,6 +1984,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index c816fea..6b50325 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -279,6 +279,7 @@ bool AudioMixer::start() { stopRequested_ = false; emittedFrames_ = 0; timelineStarted_ = false; + paused_ = false; thread_ = std::thread([this] { mixLoop(); }); @@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() { cv_.notify_all(); } +void AudioMixer::setPaused(bool paused) { + { + std::scoped_lock lock(mutex_); + paused_ = paused; + if (paused_) { + systemQueue_.clear(); + microphoneQueue_.clear(); + } + } + cv_.notify_all(); +} + void AudioMixer::stop() { stopRequested_ = true; cv_.notify_all(); @@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(systemQueue_, data, byteCount, systemFormat_, 1.0); } cv_.notify_all(); @@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_); } cv_.notify_all(); @@ -371,13 +390,13 @@ void AudioMixer::mixLoop() { const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes; const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty(); return stopRequested_.load() || - (timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource); + (timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource); }); if (stopRequested_) { break; } - if (!timelineStarted_) { + if (!timelineStarted_ || paused_) { continue; } diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 4e61252..0bdbc08 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -52,6 +52,7 @@ public: bool start(); void beginTimeline(); + void setPaused(bool paused); void stop(); void pushSystem(const BYTE* data, DWORD byteCount); void pushMicrophone(const BYTE* data, DWORD byteCount); @@ -81,5 +82,6 @@ private: std::thread thread_; std::atomic stopRequested_ = false; bool timelineStarted_ = false; + bool paused_ = false; uint64_t emittedFrames_ = 0; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index f8f56cd..24bab84 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,37 @@ struct CaptureConfig { int webcamFps = 0; }; +struct CaptureControl { + std::atomic stopRequested = false; + std::atomic paused = false; + std::mutex mutex; + std::condition_variable cv; + std::chrono::steady_clock::time_point pauseStartedAt; + std::chrono::steady_clock::duration totalPausedDuration{}; + + int64_t pausedDurationHns() { + std::scoped_lock lock(mutex); + auto total = totalPausedDuration; + if (paused.load()) { + total += std::chrono::steady_clock::now() - pauseStartedAt; + } + return std::chrono::duration_cast(total).count() / 100; + } + + void setPaused(bool nextPaused) { + std::scoped_lock lock(mutex); + if (nextPaused == paused.load()) { + return; + } + if (nextPaused) { + pauseStartedAt = std::chrono::steady_clock::now(); + } else { + totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt; + } + paused = nextPaused; + } +}; + std::wstring utf8ToWide(const std::string& value) { if (value.empty()) { return {}; @@ -317,17 +349,31 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { return true; } -void readStopCommands(std::atomic& stopRequested, std::condition_variable& cv) { +void readCaptureCommands(CaptureControl& control, const std::function& onPauseChanged) { std::string line; while (std::getline(std::cin, line)) { if (line == "stop" || line == "q" || line == "quit") { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } + if (line == "pause") { + control.setPaused(true); + onPauseChanged(true); + std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } + if (line == "resume") { + control.setPaused(false); + onPauseChanged(false); + std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } } - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); } } // namespace @@ -467,8 +513,7 @@ int main(int argc, char* argv[]) { } std::mutex mutex; - std::condition_variable cv; - std::atomic stopRequested = false; + CaptureControl control; std::atomic firstFrameWritten = false; std::atomic encodeFailed = false; Microsoft::WRL::ComPtr latestFrameTexture; @@ -480,7 +525,7 @@ int main(int argc, char* argv[]) { bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { - if (stopRequested) { + if (control.stopRequested || control.paused) { return; } @@ -493,8 +538,8 @@ int main(int argc, char* argv[]) { desc.MiscFlags = 0; if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } } @@ -502,18 +547,27 @@ int main(int argc, char* argv[]) { session.context()->CopyResource(latestFrameTexture.Get(), texture); latestFrameTimestampHns = timestampHns; if (!firstFrameWritten.exchange(true)) { - cv.notify_all(); + control.cv.notify_all(); } }); auto writeVideoFrames = [&]() { - const auto startedAt = std::chrono::steady_clock::now(); + const auto frameDuration = std::chrono::duration_cast( + std::chrono::duration(1.0 / config.fps)); uint64_t frameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; - while (!stopRequested && !encodeFailed) { + while (!control.stopRequested && !encodeFailed) { { - std::scoped_lock lock(mutex); + std::unique_lock lock(mutex); + control.cv.wait(lock, [&] { + return control.stopRequested.load() || + encodeFailed.load() || + (!control.paused.load() && latestFrameTexture); + }); + if (control.stopRequested || encodeFailed) { + break; + } if (webcamActive) { std::vector candidateWebcamFrame; int candidateWebcamWidth = 0; @@ -539,7 +593,9 @@ int main(int argc, char* argv[]) { firstFrameTimestampHns = sourceTimestampHns; } int64_t frameTimestampHns = - std::max(0, sourceTimestampHns - firstFrameTimestampHns); + std::max( + 0, + sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns()); if (lastEncodedVideoTimestampHns >= 0 && frameTimestampHns <= lastEncodedVideoTimestampHns) { frameTimestampHns = @@ -550,8 +606,8 @@ int main(int argc, char* argv[]) { frameTimestampHns, webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } if (latestFrameTexture) { @@ -560,10 +616,7 @@ int main(int argc, char* argv[]) { } frameIndex += 1; - const auto nextDeadline = startedAt + - std::chrono::duration_cast( - std::chrono::duration(static_cast(frameIndex) / config.fps)); - std::this_thread::sleep_until(nextDeadline); + std::this_thread::sleep_for(frameDuration); } }; @@ -595,8 +648,8 @@ int main(int argc, char* argv[]) { [&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return false; } return true; @@ -611,7 +664,7 @@ int main(int argc, char* argv[]) { if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -627,7 +680,7 @@ int main(int argc, char* argv[]) { if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -689,16 +742,20 @@ int main(int argc, char* argv[]) { return 1; } - std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv)); + std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) { + if (audioMixer) { + audioMixer->setPaused(isPaused); + } + }); { std::unique_lock lock(mutex); - const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] { - return firstFrameWritten.load() || stopRequested.load(); + const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] { + return firstFrameWritten.load() || control.stopRequested.load(); }); if (!started || !firstFrameWritten) { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); if (stdinThread.joinable()) { stdinThread.detach(); } @@ -724,8 +781,8 @@ int main(int argc, char* argv[]) { { std::unique_lock lock(mutex); - cv.wait(lock, [&] { - return stopRequested.load(); + control.cv.wait(lock, [&] { + return control.stopRequested.load(); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 361eb18..933ce9d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -87,6 +87,12 @@ contextBridge.exposeInMainWorld("electronAPI", { stopNativeWindowsRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-windows-recording", discard); }, + pauseNativeWindowsRecording: () => { + return ipcRenderer.invoke("pause-native-windows-recording"); + }, + resumeNativeWindowsRecording: () => { + return ipcRenderer.invoke("resume-native-windows-recording"); + }, startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 46a1f05..29df4d8 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -37,6 +37,21 @@ function findVcVarsAll() { return null; } +function findWindowsSdkUmLibDir() { + const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib"; + if (!fs.existsSync(sdkLibRoot)) { + return null; + } + + return fs + .readdirSync(sdkLibRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) + .sort() + .at(-1); +} + function run(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -64,6 +79,8 @@ async function runInVsEnv(command) { ); } + const sdkUmLibDir = findWindowsSdkUmLibDir(); + const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`); fs.writeFileSync( cmdPath, @@ -72,9 +89,9 @@ async function runInVsEnv(command) { `call "${vcvarsAll}" x64`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", - `set "LIB=${COMPAT_LIB_DIR};%LIB%"`, + `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, "exit /b %errorlevel%", "", diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9941dc4..7abed3a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -84,6 +84,7 @@ type RecorderHandle = { type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; + paused: boolean; }; type NativeMacRecordingHandle = { @@ -149,9 +150,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamAcquireId = useRef(0); const canPauseRecording = recording && - !nativeWindowsRecording.current && Boolean( - (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || + (nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) || + (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || (screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"), ); @@ -795,6 +796,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, + paused: false, }; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); @@ -1321,6 +1323,39 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const togglePaused = () => { + const activeNativeWindowsRecording = nativeWindowsRecording.current; + if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) { + void (async () => { + try { + if (activeNativeWindowsRecording.paused) { + const result = await window.electronAPI.resumeNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to resume native Windows recording"); + } + activeNativeWindowsRecording.paused = false; + segmentStartedAt.current = Date.now(); + setPaused(false); + return; + } + + const pausedAtMs = getRecordingDurationMs(); + const result = await window.electronAPI.pauseNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to pause native Windows recording"); + } + activeNativeWindowsRecording.paused = true; + accumulatedDurationMs.current = pausedAtMs; + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + setPaused(true); + } catch (error) { + console.error("Failed to toggle native Windows pause state:", error); + toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); + } + })(); + return; + } + const activeNativeMacRecording = nativeMacRecording.current; if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) { void (async () => { From b36a32d44be103fd5c822dd3c97e08c25ddce81a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 18 May 2026 14:45:33 +0200 Subject: [PATCH 2/4] refactor: centralize editor defaults --- src/components/video-editor/CropControl.tsx | 5 +- src/components/video-editor/SettingsPanel.tsx | 45 +++++---- src/components/video-editor/VideoEditor.tsx | 75 +++++++++------ src/components/video-editor/VideoPlayback.tsx | 25 ++--- .../video-editor/editorDefaults.test.ts | 55 +++++++++++ src/components/video-editor/editorDefaults.ts | 95 +++++++++++++++++++ .../video-editor/projectPersistence.ts | 72 ++++++++------ src/components/video-editor/types.ts | 1 + src/hooks/useEditorHistory.ts | 36 ++++--- src/lib/userPreferences.ts | 14 ++- 10 files changed, 307 insertions(+), 116 deletions(-) create mode 100644 src/components/video-editor/editorDefaults.test.ts create mode 100644 src/components/video-editor/editorDefaults.ts diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 07e769d..faedf1c 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults"; interface CropRegion { x: number; // 0-1 normalized @@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; - canvas.width = videoElement.videoWidth || 1920; - canvas.height = videoElement.videoHeight || 1080; + canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const draw = () => { if (videoElement.readyState >= 2) { diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b0b46df..b69350a 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,14 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -70,7 +78,6 @@ import type { ZoomFocusMode, } from "./types"; import { - DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, @@ -382,24 +389,24 @@ export function SettingsPanel({ borderRadius = 0, onBorderRadiusChange, onBorderRadiusCommit, - padding = 50, + padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, onPaddingChange, onPaddingCommit, cropRegion, onCropChange, aspectRatio, videoElement, - exportQuality = "good", + exportQuality = DEFAULT_EXPORT_SETTINGS.quality, onExportQualityChange, - exportFormat = "mp4", + exportFormat = DEFAULT_EXPORT_SETTINGS.format, onExportFormatChange, - gifFrameRate = 15, + gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate, onGifFrameRateChange, - gifLoop = true, + gifLoop = DEFAULT_GIF_SETTINGS.loop, onGifLoopChange, - gifSizePreset = "medium", + gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset, onGifSizePresetChange, - gifOutputDimensions = { width: 1280, height: 720 }, + gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions, onExport, unsavedExport, onSaveUnsavedExport, @@ -421,25 +428,25 @@ export function SettingsPanel({ onSpeedChange, onSpeedDelete, hasWebcam = false, - webcamLayoutPreset = "picture-in-picture", + webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset, onWebcamLayoutPresetChange, - webcamMaskShape = "rectangle", + webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape, onWebcamMaskShapeChange, - webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, + webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset, onWebcamSizePresetChange, onWebcamSizePresetCommit, onSaveDiagnostic, - showCursor = true, + showCursor = DEFAULT_CURSOR_SETTINGS.show, onShowCursorChange, - cursorSize = 3.0, + cursorSize = DEFAULT_CURSOR_SETTINGS.size, onCursorSizeChange, - cursorSmoothing = 0.67, + cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, onCursorSmoothingChange, - cursorMotionBlur = 0.35, + cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, onCursorMotionBlurChange, - cursorClickBounce = 2.5, + cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, onCursorClickBounceChange, - cursorClipToBounds = false, + cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, onCursorClipToBoundsChange, hasCursorData = false, showCursorSettings = true, @@ -478,8 +485,8 @@ export function SettingsPanel({ const [cropAspectRatio, setCropAspectRatio] = useState(""); const isPortraitCanvas = isPortraitAspectRatio(aspectRatio); - const videoWidth = videoElement?.videoWidth || 1920; - const videoHeight = videoElement?.videoHeight || 1080; + const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const handleCropNumericChange = useCallback( (field: "x" | "y" | "width" | "height", pixelValue: number) => { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ce6314f..b44d9b6 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -49,6 +49,12 @@ import { isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, +} from "./editorDefaults"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, @@ -71,11 +77,6 @@ import { DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_BLUR_DATA, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_CLIP_TO_BOUNDS, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -203,11 +204,15 @@ export default function VideoEditor() { const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); - const [exportQuality, setExportQuality] = useState("good"); - const [exportFormat, setExportFormat] = useState("mp4"); - const [gifFrameRate, setGifFrameRate] = useState(15); - const [gifLoop, setGifLoop] = useState(true); - const [gifSizePreset, setGifSizePreset] = useState("medium"); + const [exportQuality, setExportQuality] = useState( + DEFAULT_EXPORT_SETTINGS.quality, + ); + const [exportFormat, setExportFormat] = useState(DEFAULT_EXPORT_SETTINGS.format); + const [gifFrameRate, setGifFrameRate] = useState(DEFAULT_GIF_SETTINGS.frameRate); + const [gifLoop, setGifLoop] = useState(DEFAULT_GIF_SETTINGS.loop); + const [gifSizePreset, setGifSizePreset] = useState( + DEFAULT_GIF_SETTINGS.sizePreset, + ); const [exportedFilePath, setExportedFilePath] = useState(null); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const [unsavedExport, setUnsavedExport] = useState<{ @@ -238,12 +243,14 @@ export default function VideoEditor() { }, [cursorRecordingData, cursorTelemetry]); // Cursor & motion blur visual settings (non-undoable preferences) - const [showCursor, setShowCursor] = useState(true); - const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE); - const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); - const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); - const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); - const [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS); + const [showCursor, setShowCursor] = useState(DEFAULT_CURSOR_SETTINGS.show); + const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SETTINGS.size); + const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SETTINGS.smoothing); + const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_SETTINGS.motionBlur); + const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_SETTINGS.clickBounce); + const [cursorClipToBounds, setCursorClipToBounds] = useState( + DEFAULT_CURSOR_SETTINGS.clipToBounds, + ); const [nativePlatform, setNativePlatform] = useState(null); const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = useState(null); @@ -1575,8 +1582,8 @@ export default function VideoEditor() { videoPlaybackRef.current?.pause(); } - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; + const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, @@ -1590,8 +1597,8 @@ export default function VideoEditor() { // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; const containerElement = playbackRef?.containerRef?.current; - const previewWidth = containerElement?.clientWidth || 1920; - const previewHeight = containerElement?.clientHeight || 1080; + const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const previewHeight = containerElement?.clientHeight || DEFAULT_SOURCE_DIMENSIONS.height; if (settings.format === "gif" && settings.gifConfig) { // GIF Export @@ -1845,8 +1852,8 @@ export default function VideoEditor() { } // Build export settings from current state - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; + const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, @@ -2050,8 +2057,10 @@ export default function VideoEditor() { aspectRatio: aspectRatio === "native" ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : getAspectRatioValue(aspectRatio), @@ -2217,21 +2226,27 @@ export default function VideoEditor() { onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( calculateEffectiveSourceDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ).width, calculateEffectiveSourceDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ).height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : getAspectRatioValue(aspectRatio), diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 45ca9df..5b25ced 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -49,15 +49,16 @@ import { getNativeAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, +} from "./editorDefaults"; import { type AnnotationRegion, type BlurData, type CursorTelemetryPoint, computeRotation3DContainScale, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_ROTATION_3D, isRotation3DIdentity, lerpRotation3D, @@ -244,7 +245,7 @@ const VideoPlayback = forwardRef( showBlur, motionBlurAmount = 0, borderRadius = 0, - padding = 50, + padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion, trimRegions = [], speedRegions = [], @@ -265,11 +266,11 @@ const VideoPlayback = forwardRef( cursorTelemetry = [], cursorClickTimestamps = [], showCursor = false, - cursorSize = DEFAULT_CURSOR_SIZE, - cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, - cursorClipToBounds = false, + cursorSize = DEFAULT_CURSOR_SETTINGS.size, + cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, + cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, + cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, + cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, }, ref, ) => { @@ -1824,8 +1825,8 @@ const VideoPlayback = forwardRef( aspectRatio, aspectRatio === "native" ? getNativeAspectRatioValue( - lockedVideoDimensionsRef.current?.width || 1920, - lockedVideoDimensionsRef.current?.height || 1080, + lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width, + lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : undefined, diff --git a/src/components/video-editor/editorDefaults.test.ts b/src/components/video-editor/editorDefaults.test.ts new file mode 100644 index 0000000..8b515f1 --- /dev/null +++ b/src/components/video-editor/editorDefaults.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory"; +import { DEFAULT_PREFS } from "@/lib/userPreferences"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; +import { normalizeProjectEditor } from "./projectPersistence"; + +describe("editor defaults SSOT", () => { + it("keeps history defaults aligned with editor defaults", () => { + expect(INITIAL_EDITOR_STATE).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + }); + }); + + it("keeps user preference defaults aligned with editor and export defaults", () => { + expect(DEFAULT_PREFS).toMatchObject({ + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + }); + }); + + it("keeps project fallback normalization aligned with editor defaults", () => { + expect(normalizeProjectEditor({})).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: DEFAULT_GIF_SETTINGS.loop, + gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset, + }); + }); +}); diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts new file mode 100644 index 0000000..ac6ab61 --- /dev/null +++ b/src/components/video-editor/editorDefaults.ts @@ -0,0 +1,95 @@ +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { + type CursorVisualSettings, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLIP_TO_BOUNDS, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_WEBCAM_LAYOUT_PRESET, + DEFAULT_WEBCAM_MASK_SHAPE, + DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, + type WebcamLayoutPreset, + type WebcamMaskShape, + type WebcamPosition, + type WebcamSizePreset, +} from "./types"; + +export const DEFAULT_SOURCE_DIMENSIONS = { + width: 1920, + height: 1080, +} as const; + +export const DEFAULT_GIF_OUTPUT_DIMENSIONS = { + width: 1280, + height: 720, +} as const; + +export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { + shadowIntensity: number; + showBlur: boolean; + motionBlurAmount: number; + borderRadius: number; +} = { + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, +}; + +export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { + padding: number; + aspectRatio: AspectRatio; + cropRegion: typeof DEFAULT_CROP_REGION; + wallpaper: string; +} = { + padding: 50, + aspectRatio: "16:9", + cropRegion: DEFAULT_CROP_REGION, + wallpaper: DEFAULT_WALLPAPER, +}; + +export const DEFAULT_WEBCAM_SETTINGS = { + layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, + maskShape: DEFAULT_WEBCAM_MASK_SHAPE, + sizePreset: DEFAULT_WEBCAM_SIZE_PRESET, + position: DEFAULT_WEBCAM_POSITION, +} as const satisfies { + layoutPreset: WebcamLayoutPreset; + maskShape: WebcamMaskShape; + sizePreset: WebcamSizePreset; + position: WebcamPosition | null; +}; + +export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = { + show: true, + size: DEFAULT_CURSOR_SIZE, + smoothing: DEFAULT_CURSOR_SMOOTHING, + motionBlur: DEFAULT_CURSOR_MOTION_BLUR, + clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, + clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, +}; + +export const DEFAULT_EXPORT_SETTINGS: { + quality: ExportQuality; + format: ExportFormat; +} = { + quality: "good", + format: "mp4", +}; + +export const DEFAULT_GIF_SETTINGS: { + frameRate: GifFrameRate; + loop: boolean; + sizePreset: GifSizePreset; + outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS; +} = { + frameRate: 15, + loop: true, + sizePreset: "medium", + outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS, +}; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7360142..f16d29e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; import { type AnnotationRegion, type CropRegion, @@ -15,14 +22,10 @@ import { DEFAULT_BLUR_DATA, DEFAULT_BLUR_FREEHAND_POINTS, DEFAULT_BLUR_INTENSITY, - DEFAULT_CROP_REGION, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + DEFAULT_ZOOM_MOTION_BLUR, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -104,13 +107,13 @@ function computeNormalizedWebcamLayoutPreset( case "vertical-stack": return isPortraitAspectRatio(normalizedAspectRatio) ? webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET; + : DEFAULT_WEBCAM_SETTINGS.layoutPreset; case "dual-frame": return isPortraitAspectRatio(normalizedAspectRatio) - ? DEFAULT_WEBCAM_LAYOUT_PRESET + ? DEFAULT_WEBCAM_SETTINGS.layoutPreset : webcamLayoutPreset; default: - return DEFAULT_WEBCAM_LAYOUT_PRESET; + return DEFAULT_WEBCAM_SETTINGS.layoutPreset; } } @@ -211,7 +214,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio as AspectRatio, ) ? (editor.aspectRatio as AspectRatio) - : "16:9"; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio; const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( editor.webcamLayoutPreset, normalizedAspectRatio, @@ -226,7 +229,7 @@ export function normalizeProjectEditor(editor: Partial): Pro cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1), cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1), } - : DEFAULT_WEBCAM_POSITION; + : DEFAULT_WEBCAM_SETTINGS.position; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -413,16 +416,16 @@ export function normalizeProjectEditor(editor: Partial): Pro const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x - : DEFAULT_CROP_REGION.x; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x; const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y - : DEFAULT_CROP_REGION.y; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y; const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width - : DEFAULT_CROP_REGION.width; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width; const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) ? editor.cropRegion.height - : DEFAULT_CROP_REGION.height; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height; const cropX = clamp(rawCropX, 0, 1); const cropY = clamp(rawCropY, 0, 1); @@ -433,18 +436,29 @@ export function normalizeProjectEditor(editor: Partial): Pro wallpaper: typeof editor.wallpaper === "string" ? normalizeWallpaperValue(editor.wallpaper) - : DEFAULT_WALLPAPER, - shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, - showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, + : DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: + typeof editor.shadowIntensity === "number" + ? editor.shadowIntensity + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: + typeof editor.showBlur === "boolean" + ? editor.showBlur + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" ? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled - ? 0.35 - : 0 - : 0, - borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0, - padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, + ? DEFAULT_ZOOM_MOTION_BLUR + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: + typeof editor.borderRadius === "number" + ? editor.borderRadius + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: isFiniteNumber(editor.padding) + ? clamp(editor.padding, 0, 100) + : DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion: { x: cropX, y: cropY, @@ -463,31 +477,31 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "square" || editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape - : DEFAULT_WEBCAM_MASK_SHAPE, + : DEFAULT_WEBCAM_SETTINGS.maskShape, webcamSizePreset: typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) ? Math.max(10, Math.min(50, editor.webcamSizePreset)) - : DEFAULT_WEBCAM_SIZE_PRESET, + : DEFAULT_WEBCAM_SETTINGS.sizePreset, webcamPosition: normalizedWebcamPosition, exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality - : "good", - exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", + : DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format, gifFrameRate: editor.gifFrameRate === 15 || editor.gifFrameRate === 20 || editor.gifFrameRate === 25 || editor.gifFrameRate === 30 ? editor.gifFrameRate - : 15, - gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, + : DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop, gifSizePreset: editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original" ? editor.gifSizePreset - : "medium", + : DEFAULT_GIF_SETTINGS.sizePreset, }; } diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index cf426d0..fce4a19 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -188,6 +188,7 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; + clipToBounds: boolean; } export const DEFAULT_CURSOR_SIZE = 3.0; diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410da..b6525c1 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -1,4 +1,9 @@ import { useCallback, useRef, useState } from "react"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { AnnotationRegion, CropRegion, @@ -10,14 +15,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; -import { - DEFAULT_CROP_REGION, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, -} from "@/components/video-editor/types"; -import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; // Undoable state — selection IDs are intentionally excluded (undoing a @@ -47,17 +45,17 @@ export const INITIAL_EDITOR_STATE: EditorState = { speedRegions: [], annotationRegions: [], cropRegion: DEFAULT_CROP_REGION, - wallpaper: DEFAULT_WALLPAPER, - shadowIntensity: 0, - showBlur: false, - motionBlurAmount: 0, - borderRadius: 0, - padding: 50, - aspectRatio: "16:9", - webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, - webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, - webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, - webcamPosition: DEFAULT_WEBCAM_POSITION, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, }; type StateUpdate = Partial | ((prev: EditorState) => Partial); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 5b7bc86..28a4506 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { ExportFormat, ExportQuality } from "@/lib/exporter"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -27,11 +31,11 @@ export interface UserPreferences { exportFolder: string | null; } -const DEFAULT_PREFS: UserPreferences = { - padding: 50, - aspectRatio: "16:9", - exportQuality: "good", - exportFormat: "mp4", +export const DEFAULT_PREFS: UserPreferences = { + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, exportFolder: null, }; From 84b523df8353ff6ba679f5c31b17b24edd5aedd3 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 22 May 2026 19:54:13 -0700 Subject: [PATCH 3/4] fix: drop unused imports and reorder in SettingsPanel Removes MAX_PLAYBACK_SPEED and DEFAULT_WEBCAM_SIZE_PRESET (TS6133) and runs biome's organize-imports to satisfy the Lint check. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/video-editor/SettingsPanel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7163568..571b587 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { DEFAULT_CURSOR_SETTINGS, DEFAULT_EDITOR_LAYOUT_SETTINGS, @@ -61,7 +62,6 @@ import { DEFAULT_SOURCE_DIMENSIONS, DEFAULT_WEBCAM_SETTINGS, } from "./editorDefaults"; -import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -79,8 +79,6 @@ import type { ZoomFocusMode, } from "./types"; import { - MAX_PLAYBACK_SPEED, - DEFAULT_WEBCAM_SIZE_PRESET, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, From 9eaae72af1ede47cc98fa2e765a789d403b00561 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 22 May 2026 20:02:25 -0700 Subject: [PATCH 4/4] fix: drop removed WEBCAM_TARGET width/height refs after main merge PR #600 (now on main) removed WEBCAM_TARGET_WIDTH/HEIGHT and switched this call site to width/height: 0 so the native helper picks the camera's native dimensions. Align this branch with that so CI's fresh PR-merge stops erroring on the undeclared identifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useScreenRecorder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 08d062c..4ad11c6 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -989,8 +989,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: webcamEnabled, deviceId: webcamDeviceId, deviceName: webcamDeviceName, - width: WEBCAM_TARGET_WIDTH, - height: WEBCAM_TARGET_HEIGHT, + width: 0, + height: 0, fps: WEBCAM_TARGET_FRAME_RATE, }, cursor: {