From 3895ca985f845dd99c9f0af83af7d5aec3c9da20 Mon Sep 17 00:00:00 2001 From: Ishan Panta Date: Fri, 3 Apr 2026 08:37:16 +0545 Subject: [PATCH 01/48] [add] extend speed options with higher presets and custom speed input add 3x, 4x, 5x speed presets and a custom playback speed input field that accepts any integer value up to 16x. change PlaybackSpeed type from a fixed union to number with min/max constants and clamp utility. update project persistence to validate any speed in range instead of exact value matching. add i18n keys for en, es, zh-CN. closes #252 --- src/components/video-editor/SettingsPanel.tsx | 90 ++++++++++++++++++- .../video-editor/projectPersistence.ts | 15 ++-- src/components/video-editor/types.ts | 14 ++- src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/es/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 4 +- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f5afe35..cdb00ce 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,7 +53,70 @@ import type { WebcamLayoutPreset, ZoomDepth, } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; + +function CustomSpeedInput({ + value, + onChange, + onError, +}: { + value: number; + onChange: (val: number) => void; + onError: () => void; +}) { + const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); + const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [isFocused, setIsFocused] = useState(false); + + const prevValue = useRef(value); + if (!isFocused && prevValue.current !== value) { + prevValue.current = value; + setDraft(isPreset ? "" : String(Math.round(value))); + } + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, ""); + if (digits === "") { + setDraft(""); + return; + } + const num = Number(digits); + if (num > MAX_PLAYBACK_SPEED) { + onError(); + return; + } + setDraft(digits); + if (num >= 1) onChange(num); + }, + [onChange, onError], + ); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!draft || Number(draft) < 1) { + setDraft(isPreset ? "" : String(Math.round(value))); + } + }, [draft, isPreset, value]); + + return ( +
+ setIsFocused(true)} + onChange={handleChange} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" + /> + × +
+ ); +} const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -537,7 +600,7 @@ export function SettingsPanel({ )} -
+
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( @@ -562,6 +625,29 @@ export function SettingsPanel({ ); })}
+
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+
{!selectedSpeedId && (

{t("speed.selectRegion")}

)} diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 99f1bba..bfe6972 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, type CropRegion, + clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, @@ -14,6 +15,8 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_POSITION, DEFAULT_ZOOM_DEPTH, + MAX_PLAYBACK_SPEED, + MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, type WebcamLayoutPreset, @@ -219,14 +222,10 @@ export function normalizeProjectEditor(editor: Partial): Pro const endMs = Math.max(startMs + 1, rawEnd); const speed = - region.speed === 0.25 || - region.speed === 0.5 || - region.speed === 0.75 || - region.speed === 1.25 || - region.speed === 1.5 || - region.speed === 1.75 || - region.speed === 2 - ? region.speed + isFiniteNumber(region.speed) && + region.speed >= MIN_PLAYBACK_SPEED && + region.speed <= MAX_PLAYBACK_SPEED + ? clampPlaybackSpeed(region.speed) : DEFAULT_PLAYBACK_SPEED; return { diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ce49f8e..d52e60a 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -132,7 +132,16 @@ export const DEFAULT_CROP_REGION: CropRegion = { height: 1, }; -export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2; +export type PlaybackSpeed = number; + +export const MIN_PLAYBACK_SPEED = 0.1; +// Anything above 16x causes the playhead to stall during preview +// due to the video decoder not being able to keep up. +export const MAX_PLAYBACK_SPEED = 16; + +export function clampPlaybackSpeed(speed: number): PlaybackSpeed { + return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100; +} export interface SpeedRegion { id: string; @@ -149,6 +158,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [ { speed: 1.5, label: "1.5×" }, { speed: 1.75, label: "1.75×" }, { speed: 2, label: "2×" }, + { speed: 3, label: "3×" }, + { speed: 4, label: "4×" }, + { speed: 5, label: "5×" }, ]; export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 36b7462..ad5f308 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Playback Speed", "selectRegion": "Select a speed region to adjust", - "deleteRegion": "Delete Speed Region" + "deleteRegion": "Delete Speed Region", + "customPlaybackSpeed": "Custom Playback Speed", + "maxSpeedError": "Speed can't go higher than 16×" }, "trim": { "deleteRegion": "Delete Trim Region" diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 4674480..f4c2d42 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Velocidad de reproducción", "selectRegion": "Selecciona una región de velocidad para ajustar", - "deleteRegion": "Eliminar región de velocidad" + "deleteRegion": "Eliminar región de velocidad", + "customPlaybackSpeed": "Velocidad personalizada", + "maxSpeedError": "La velocidad no puede superar 16×" }, "trim": { "deleteRegion": "Eliminar región de recorte" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 41bf55b..d38291e 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "播放速度", "selectRegion": "选择要调整的速度区域", - "deleteRegion": "删除速度区域" + "deleteRegion": "删除速度区域", + "customPlaybackSpeed": "自定义播放速度", + "maxSpeedError": "速度不能超过 16×" }, "trim": { "deleteRegion": "删除剪辑区域" From 2b471783c084800acd67c37e8a74439c1db864a1 Mon Sep 17 00:00:00 2001 From: Adam <69064669+abres33@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:00:36 -0500 Subject: [PATCH 02/48] feat: add Cancel Recording button to HUD --- src/components/launch/LaunchWindow.tsx | 15 +++++++++++++++ src/hooks/useScreenRecorder.ts | 13 +++++++++++++ src/i18n/locales/en/launch.json | 1 + src/i18n/locales/es/launch.json | 1 + src/i18n/locales/zh-CN/launch.json | 1 + 5 files changed, 31 insertions(+) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..d1185e8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { + MdCancel, MdMic, MdMicOff, MdMonitor, @@ -43,6 +44,7 @@ const ICON_CONFIG = { webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, + cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -79,6 +81,7 @@ export function LaunchWindow() { recording, toggleRecording, restartRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -477,6 +480,18 @@ export function LaunchWindow() { )} + {/* Cancel recording */} + {recording && ( + + + + )} + {/* Open video file */} + + + + +
+ + {recording && ( + + + + )} + {/* Restart recording */} {recording && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0c418c1..8e92962 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -41,7 +41,10 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; + paused: boolean; + elapsedSeconds: number; toggleRecording: () => void; + togglePaused: () => void; restartRecording: () => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; @@ -85,6 +88,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); @@ -97,13 +102,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); - const startTime = useRef(0); const recordingId = useRef(0); + const accumulatedDurationMs = useRef(0); + const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); + const getRecordingDurationMs = useCallback(() => { + const segmentDuration = + screenRecorder.current?.recorder.state === "recording" && segmentStartedAt.current + ? Date.now() - segmentStartedAt.current + : 0; + return accumulatedDurationMs.current + segmentDuration; + }, []); + const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -202,6 +216,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { @@ -273,7 +291,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeWebcamRecorder = webcamRecorder.current; - const duration = Date.now() - startTime.current; + const duration = getRecordingDurationMs(); const activeRecordingId = recordingId.current; finalizeRecording( @@ -283,7 +301,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeRecordingId, ); - if (activeScreenRecorder.recorder.state === "recording") { + if ( + activeScreenRecorder.recorder.state === "recording" || + activeScreenRecorder.recorder.state === "paused" + ) { try { activeScreenRecorder.recorder.stop(); } catch { @@ -291,7 +312,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } if (activeWebcamRecorder) { - if (activeWebcamRecorder.recorder.state === "recording") { + if ( + activeWebcamRecorder.recorder.state === "recording" || + activeWebcamRecorder.recorder.state === "paused" + ) { try { activeWebcamRecorder.recorder.stop(); } catch { @@ -316,14 +340,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = false; discardRecordingId.current = null; - if (screenRecorder.current?.recorder.state === "recording") { + if ( + screenRecorder.current?.recorder.state === "recording" || + screenRecorder.current?.recorder.state === "paused" + ) { try { screenRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } - if (webcamRecorder.current?.recorder.state === "recording") { + if ( + webcamRecorder.current?.recorder.state === "recording" || + webcamRecorder.current?.recorder.state === "paused" + ) { try { webcamRecorder.current.recorder.stop(); } catch { @@ -518,9 +548,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } recordingId.current = Date.now(); - startTime.current = recordingId.current; + accumulatedDurationMs.current = 0; + segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); + setPaused(false); + setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); const activeScreenRecorder = screenRecorder.current; @@ -536,7 +569,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, - Math.max(0, Date.now() - startTime.current), + Math.max(0, getRecordingDurationMs()), activeRecordingId, ); }, @@ -552,12 +585,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn { toast.error(errorMsg); } setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; screenRecorder.current = null; webcamRecorder.current = null; teardownMedia(); } }; + const togglePaused = () => { + const activeScreenRecorder = screenRecorder.current?.recorder; + if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { + return; + } + + const activeWebcamRecorder = webcamRecorder.current?.recorder; + + if (activeScreenRecorder.state === "paused") { + try { + activeScreenRecorder.resume(); + if (activeWebcamRecorder?.state === "paused") { + activeWebcamRecorder.resume(); + } + segmentStartedAt.current = Date.now(); + setPaused(false); + } catch (error) { + console.error("Failed to resume recording:", error); + } + return; + } + + if (activeScreenRecorder.state !== "recording") { + return; + } + + try { + accumulatedDurationMs.current = getRecordingDurationMs(); + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + activeScreenRecorder.pause(); + if (activeWebcamRecorder?.state === "recording") { + activeWebcamRecorder.pause(); + } + setPaused(true); + } catch (error) { + console.error("Failed to pause recording:", error); + } + }; + const toggleRecording = () => { recording ? stopRecording.current() : startRecording(); }; @@ -566,7 +643,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (restarting.current) return; const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; + if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; @@ -581,7 +658,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }), ]; - if (activeWebcamRecorder?.recorder.state === "recording") { + if ( + activeWebcamRecorder?.recorder.state === "recording" || + activeWebcamRecorder?.recorder.state === "paused" + ) { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { @@ -601,9 +681,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + useEffect(() => { + if (!recording) { + setElapsedSeconds(0); + return; + } + + setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); + if (paused) { + return; + } + + const interval = window.setInterval(() => { + setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); + }, 250); + + return () => window.clearInterval(interval); + }, [getRecordingDurationMs, paused, recording]); + return { recording, + paused, + elapsedSeconds, toggleRecording, + togglePaused, restartRecording, microphoneEnabled, setMicrophoneEnabled, From 3bfcd8576b43dcb5395d109feb135724cb6ca478 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 22:44:25 +0300 Subject: [PATCH 12/48] fix: read live video.currentTime for rapid frame steps and add JSDoc - Read currentTime directly from the video element instead of the React ref so rapid arrow key presses each advance by exactly one frame - Add JSDoc docstrings to frameStep.ts exports --- src/components/video-editor/VideoEditor.tsx | 10 +++++++--- src/lib/frameStep.ts | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 3efd9ce..e83f5b0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -949,13 +949,17 @@ export default function VideoEditor() { return; } e.preventDefault(); + const video = videoPlaybackRef.current?.video; + if (!video) { + return; + } const direction = e.key === "ArrowLeft" ? "backward" : "forward"; const newTime = computeFrameStepTime( - currentTimeRef.current, - durationRef.current, + video.currentTime, + Number.isFinite(video.duration) ? video.duration : durationRef.current, direction, ); - handleSeek(newTime); + video.currentTime = newTime; return; } diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts index 7eaaf6b..dc42d78 100644 --- a/src/lib/frameStep.ts +++ b/src/lib/frameStep.ts @@ -1,5 +1,10 @@ +/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */ export const FRAME_DURATION_SEC = 1 / 60; +/** + * Compute the new playhead time after stepping one frame forward or backward. + * The result is clamped to the range [0, duration]. + */ export function computeFrameStepTime( currentTime: number, duration: number, From 97c9a73578ce547356593f03e258ce1595415549 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 23:02:12 +0300 Subject: [PATCH 13/48] fix: skip frame-step on ARIA widgets that own arrow keys Expand the arrow key guard to also skip elements with role="separator" (PanelResizeHandle), role="slider", and role="spinbutton" so keyboard panel resizing is not intercepted. --- src/components/video-editor/VideoEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e83f5b0..e3a30cf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -944,7 +944,9 @@ export default function VideoEditor() { target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) + (target instanceof HTMLElement && + (target.isContentEditable || + target.closest('[role="separator"], [role="slider"], [role="spinbutton"]'))) ) { return; } From 43ec6ee9cd74d921f5c12cfc43f0fa563862eb22 Mon Sep 17 00:00:00 2001 From: Ayush765-spec Date: Sat, 4 Apr 2026 11:51:05 +0530 Subject: [PATCH 14/48] fix(editor): localize new recording dialog and fix session clear behavior --- src/components/video-editor/VideoEditor.tsx | 24 +++++++++++---------- src/i18n/locales/en/editor.json | 6 ++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index dae009a..26bfd44 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -474,10 +474,14 @@ export default function VideoEditor() { }, [saveProject]); const handleNewRecordingConfirm = useCallback(async () => { - setShowNewRecordingDialog(false); - await window.electronAPI.clearCurrentVideoPath(); - await window.electronAPI.setCurrentRecordingSession(null); - await window.electronAPI.switchToHud(); + try { + await window.electronAPI.clearCurrentVideoPath(); + await window.electronAPI.switchToHud(); + setShowNewRecordingDialog(false); + } catch (err) { + console.error("Failed to start new recording:", err); + setError("Failed to start new recording: " + String(err)); + } }, []); const handleLoadProject = useCallback(async () => { @@ -1415,10 +1419,8 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > - New Recording - - Start a new recording? Your current recording will be discarded. - + {t("newRecording.title")} + {t("newRecording.description")} @@ -1470,7 +1472,7 @@ export default function VideoEditor() { className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium" >