From 9aba9ae318501480eaaeea64dc8a09285b81d252 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:11:17 +0500 Subject: [PATCH 01/18] feat: add Motion Animation gate helper and HUD more menu --- .../launch/popovers/MorePopover.tsx | 67 +++++++++++++++++++ .../videoPlayback/motionAnimation.test.ts | 38 +++++++++++ .../videoPlayback/motionAnimation.ts | 41 ++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/components/launch/popovers/MorePopover.tsx create mode 100644 src/components/video-editor/videoPlayback/motionAnimation.test.ts create mode 100644 src/components/video-editor/videoPlayback/motionAnimation.ts diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx new file mode 100644 index 000000000..cfd67a024 --- /dev/null +++ b/src/components/launch/popovers/MorePopover.tsx @@ -0,0 +1,67 @@ +import { Switch } from "@/components/ui/switch"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useEffect, useState, type ReactElement } from "react"; +import { + loadEditorPreferences, + saveEditorPreferences, +} from "../../video-editor/editorPreferences"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { HudPopover } from "./PopoverScaffold"; +import styles from "../LaunchWindow.module.css"; + +const POPOVER_ID = "more"; + +export function MorePopover({ trigger }: { trigger: ReactElement }) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + const [motionAnimationEnabled, setMotionAnimationEnabled] = useState(true); + + useEffect(() => { + if (!open) { + return; + } + setMotionAnimationEnabled(loadEditorPreferences().motionAnimationEnabled); + }, [open]); + + const handleMotionAnimationChange = (enabled: boolean) => { + setMotionAnimationEnabled(enabled); + saveEditorPreferences({ motionAnimationEnabled: enabled }); + }; + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="start" + > +
{t("recording.more")}
+
+
+
+ {t("recording.motionAnimation")} +
+
+ {t("recording.motionAnimationDescription")} +
+
+ +
+
+ ); +} diff --git a/src/components/video-editor/videoPlayback/motionAnimation.test.ts b/src/components/video-editor/videoPlayback/motionAnimation.test.ts new file mode 100644 index 000000000..c30c91a6f --- /dev/null +++ b/src/components/video-editor/videoPlayback/motionAnimation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + isAutoMotionAllowed, + resolveMotionAnimationPlayback, +} from "./motionAnimation"; + +describe("resolveMotionAnimationPlayback", () => { + const sample = { + cursorSway: 0.4, + cursorMotionBlur: 0.6, + zoomMotionBlur: 0.35, + zoomClassicMode: false, + cursorClickBounce: 2, + }; + + it("passes values through when motion animation is enabled", () => { + expect(resolveMotionAnimationPlayback(true, sample)).toEqual(sample); + }); + + it("zeros sway, blur, bounce and forces classic mode when disabled", () => { + expect(resolveMotionAnimationPlayback(false, sample)).toEqual({ + cursorSway: 0, + cursorMotionBlur: 0, + zoomMotionBlur: 0, + zoomClassicMode: true, + cursorClickBounce: 0, + }); + }); +}); + +describe("isAutoMotionAllowed", () => { + it("requires both the motion animation gate and auto-zoom preference", () => { + expect(isAutoMotionAllowed(true, true)).toBe(true); + expect(isAutoMotionAllowed(true, false)).toBe(false); + expect(isAutoMotionAllowed(false, true)).toBe(false); + expect(isAutoMotionAllowed(false, false)).toBe(false); + }); +}); diff --git a/src/components/video-editor/videoPlayback/motionAnimation.ts b/src/components/video-editor/videoPlayback/motionAnimation.ts new file mode 100644 index 000000000..944fdcfbe --- /dev/null +++ b/src/components/video-editor/videoPlayback/motionAnimation.ts @@ -0,0 +1,41 @@ +/** + * Global Motion Animation gate helpers. + * + * When motion animation is disabled, playback and export should match the + * capture "as displayed" by skipping sway, motion blur, spring camera motion + * (classic snap), click bounce, and auto-applied fresh-recording zooms. + * Underlying preference values stay intact so turning the feature back on + * restores the previous look. + */ + +export type MotionAnimationPlaybackFields = { + cursorSway: number; + cursorMotionBlur: number; + zoomMotionBlur: number; + zoomClassicMode: boolean; + cursorClickBounce: number; +}; + +export function resolveMotionAnimationPlayback( + motionAnimationEnabled: boolean, + values: MotionAnimationPlaybackFields, +): MotionAnimationPlaybackFields { + if (motionAnimationEnabled) { + return values; + } + + return { + cursorSway: 0, + cursorMotionBlur: 0, + zoomMotionBlur: 0, + zoomClassicMode: true, + cursorClickBounce: 0, + }; +} + +export function isAutoMotionAllowed( + motionAnimationEnabled: boolean, + autoApplyFreshRecordingAutoZooms: boolean, +): boolean { + return motionAnimationEnabled && autoApplyFreshRecordingAutoZooms; +} From 2a6c0c0c84b025a761675e44b0f353bbc17ffa29 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:13:23 +0500 Subject: [PATCH 02/18] feat: gate export motion effects with motionAnimationEnabled --- .../export/buildExportRenderOptions.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/export/buildExportRenderOptions.ts b/src/components/video-editor/export/buildExportRenderOptions.ts index ae836c5a7..45b61787a 100644 --- a/src/components/video-editor/export/buildExportRenderOptions.ts +++ b/src/components/video-editor/export/buildExportRenderOptions.ts @@ -3,6 +3,7 @@ import { toFileUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; +import { resolveMotionAnimationPlayback } from "../videoPlayback/motionAnimation"; type AppearanceState = ReturnType; type TimelineState = ReturnType; @@ -32,6 +33,14 @@ export function buildExportRenderOptions({ shadowIntensity, onProgress, }: BuildExportRenderOptionsInput) { + const motionPlayback = resolveMotionAnimationPlayback(appearance.motionAnimationEnabled, { + cursorSway: appearance.cursorSway, + cursorMotionBlur: appearance.cursorMotionBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomClassicMode: appearance.zoomClassicMode, + cursorClickBounce: appearance.cursorClickBounce, + }); + return { clipRegions: timeline.clipRegions, wallpaper: appearance.wallpaper, @@ -40,7 +49,7 @@ export function buildExportRenderOptions({ showShadow: shadowIntensity > 0, shadowIntensity, backgroundBlur: appearance.backgroundBlur, - zoomMotionBlur: appearance.zoomMotionBlur, + zoomMotionBlur: motionPlayback.zoomMotionBlur, zoomMotionBlurTuning: appearance.zoomMotionBlurTuning, connectZooms: appearance.connectZooms, zoomInDurationMs: appearance.zoomInDurationMs, @@ -74,16 +83,16 @@ export function buildExportRenderOptions({ cameraSpringDampingMultiplier: appearance.cameraSpringDampingMultiplier, cameraSpringMassMultiplier: appearance.cameraSpringMassMultiplier, zoomSmoothness: appearance.zoomSmoothness, - zoomClassicMode: appearance.zoomClassicMode, - cursorMotionBlur: appearance.cursorMotionBlur, + zoomClassicMode: motionPlayback.zoomClassicMode, + cursorMotionBlur: motionPlayback.cursorMotionBlur, cursorClickEffect: appearance.cursorClickEffect, cursorClickEffectColor: appearance.cursorClickEffectColor, cursorClickEffectScale: appearance.cursorClickEffectScale, cursorClickEffectOpacity: appearance.cursorClickEffectOpacity, cursorClickEffectDurationMs: appearance.cursorClickEffectDurationMs, - cursorClickBounce: appearance.cursorClickBounce, + cursorClickBounce: motionPlayback.cursorClickBounce, cursorClickBounceDuration: appearance.cursorClickBounceDuration, - cursorSway: appearance.cursorSway, + cursorSway: motionPlayback.cursorSway, previewWidth, previewHeight, onProgress, From 76f0e739602cbb5392a91a9f9846c9ce268a172a Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:14:20 +0500 Subject: [PATCH 03/18] feat: add Motion Animation strings to launch i18n --- src/i18n/locales/en/launch.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 5b9b9cc26..ebe8a929b 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -44,6 +44,10 @@ "stop": "Stop", "cancel": "Cancel", "more": "More", + "motionAnimation": "Motion Animation", + "motionAnimationOn": "Motion Animation on", + "motionAnimationOff": "Motion Animation off", + "motionAnimationDescription": "When off, playback matches the capture as displayed (no sway, blur, or auto-motion).", "update": { "update": "Update", "updated": "Updated", From 48ba5be52208ca2438eefd6b6177589922432e87 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:15:04 +0500 Subject: [PATCH 04/18] feat: persist motionAnimationEnabled with editor preferences --- .../video-editor/presets/useEditorPreferencesPersistence.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/video-editor/presets/useEditorPreferencesPersistence.ts b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts index 7691af804..f640e08b6 100644 --- a/src/components/video-editor/presets/useEditorPreferencesPersistence.ts +++ b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts @@ -27,6 +27,7 @@ export function useEditorPreferencesPersistence({ zoomMotionBlur: appearance.zoomMotionBlur, zoomMotionBlurTuning: appearance.zoomMotionBlurTuning, autoApplyFreshRecordingAutoZooms: appearance.autoApplyFreshRecordingAutoZooms, + motionAnimationEnabled: appearance.motionAnimationEnabled, connectZooms: appearance.connectZooms, zoomInDurationMs: appearance.zoomInDurationMs, zoomInOverlapMs: appearance.zoomInOverlapMs, @@ -79,6 +80,7 @@ export function useEditorPreferencesPersistence({ appearance.zoomMotionBlur, appearance.zoomMotionBlurTuning, appearance.autoApplyFreshRecordingAutoZooms, + appearance.motionAnimationEnabled, appearance.connectZooms, appearance.zoomInDurationMs, appearance.zoomInOverlapMs, From 0234901a36ad90381c8d85a04468fa1c807df420 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:18:19 +0500 Subject: [PATCH 05/18] feat: add motionAnimationEnabled to appearance state --- src/components/video-editor/state/useAppearanceState.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/video-editor/state/useAppearanceState.ts b/src/components/video-editor/state/useAppearanceState.ts index 6dbfd4b16..a053678b2 100644 --- a/src/components/video-editor/state/useAppearanceState.ts +++ b/src/components/video-editor/state/useAppearanceState.ts @@ -34,6 +34,9 @@ export function useAppearanceState(preferences: EditorPreferences) { const [autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms] = useState( preferences.autoApplyFreshRecordingAutoZooms, ); + const [motionAnimationEnabled, setMotionAnimationEnabled] = useState( + preferences.motionAnimationEnabled, + ); const [connectZooms, setConnectZooms] = useState(preferences.connectZooms); const [zoomInDurationMs, setZoomInDurationMs] = useState( preferences.zoomInDurationMs ?? DEFAULT_ZOOM_IN_DURATION_MS, @@ -128,6 +131,8 @@ export function useAppearanceState(preferences: EditorPreferences) { setZoomMotionBlurTuning, autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms, + motionAnimationEnabled, + setMotionAnimationEnabled, connectZooms, setConnectZooms, zoomInDurationMs, From 82510113263621f98d1a189f92589b7fe190275f Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:19:51 +0500 Subject: [PATCH 06/18] feat: gate preview motion effects with motionAnimationEnabled --- .../layout/EditorVideoPreview.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/layout/EditorVideoPreview.tsx b/src/components/video-editor/layout/EditorVideoPreview.tsx index 06ff3e1ed..c18f2aa66 100644 --- a/src/components/video-editor/layout/EditorVideoPreview.tsx +++ b/src/components/video-editor/layout/EditorVideoPreview.tsx @@ -5,6 +5,7 @@ import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; import type { CursorTelemetryPoint, ZoomRegion } from "../types"; import VideoPlayback, { type VideoPlaybackRef } from "../VideoPlayback"; +import { resolveMotionAnimationPlayback } from "../videoPlayback/motionAnimation"; type PlaybackProps = ComponentProps; type Handlers = Pick< @@ -62,6 +63,14 @@ export function EditorVideoPreview({ setError, handlers, }: Props) { + const motionPlayback = resolveMotionAnimationPlayback(appearance.motionAnimationEnabled, { + cursorSway: appearance.cursorSway, + cursorMotionBlur: appearance.cursorMotionBlur, + zoomMotionBlur: appearance.zoomMotionBlur, + zoomClassicMode: appearance.zoomClassicMode, + cursorClickBounce: appearance.cursorClickBounce, + }); + return ( Date: Thu, 24 Sep 2026 19:20:37 +0500 Subject: [PATCH 07/18] feat: gate project-open auto zooms when Motion Animation is off --- src/components/video-editor/project/useProjectOpenActions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 33ea8f3f7..f1746ba38 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -13,6 +13,7 @@ import type { useAppearanceState } from "../state/useAppearanceState"; import type { useProjectState } from "../state/useProjectState"; import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; import type { VideoPlaybackRef } from "../VideoPlayback"; +import { isAutoMotionAllowed } from "../videoPlayback/motionAnimation"; type Set = Dispatch>; @@ -141,7 +142,9 @@ export function useProjectOpenActions({ project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = - appearance.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + isAutoMotionAllowed(appearance.motionAnimationEnabled, appearance.autoApplyFreshRecordingAutoZooms) + ? sourceVideoUrl + : null; appearance.setWebcam((previous) => ({ ...previous, visibleRanges: undefined, From c763ed260cccbc036827f540b002c5c8de7e3399 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:21:29 +0500 Subject: [PATCH 08/18] feat: add Motion Animation settings i18n strings --- src/i18n/locales/en/settings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9db0e74eb..64f7bbfc7 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -56,6 +56,8 @@ "auto": "Auto", "connectZooms": "Connect Zooms", "connectZoomsDescription": "Smooth consecutive zoom regions into a continuous camera move.", + "motionAnimation": "Motion Animation", + "motionAnimationDescription": "Apply motion presets, cursor sway, motion blur, and auto-motion effects. Turn off to play back recordings as displayed.", "autoApplyFreshRecordingZooms": "Auto-apply fresh recording zooms", "autoApplyFreshRecordingZoomsDescription": "Suggest edge-recentering zooms automatically when you open a new recording.", "zoomGeneralTitle": "General", From 0a5611e867e45797442d2e03b150ff0411fdcfff Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:23:06 +0500 Subject: [PATCH 09/18] feat: wire motionAnimationEnabled into settings panel props --- .../video-editor/layout/useEditorSettingsPanelProps.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts index 4c4c8c696..f23aead2b 100644 --- a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts +++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts @@ -111,6 +111,8 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps Date: Thu, 24 Sep 2026 19:25:35 +0500 Subject: [PATCH 10/18] feat: add motionAnimationEnabled to EditorPreferences --- .../video-editor/editorPreferences.ts | 541 +----------------- 1 file changed, 1 insertion(+), 540 deletions(-) diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index f696f0a91..311c8dd06 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -1,540 +1 @@ -import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; -import { - getDefaultBorderRadiusPercent, - legacyBorderRadiusPixelsToPercent, - normalizeExportBackendPreference, - normalizeExportMp4FrameRate, - normalizeExportPipelineModel, - normalizeProjectEditor, - type ProjectEditorState, - stripPersistedDevMotionBlurSettings, -} from "./projectPersistence"; - -type PersistedEditorControls = Pick< - ProjectEditorState, - | "wallpaper" - | "shadowIntensity" - | "backgroundBlur" - | "zoomMotionBlur" - | "zoomMotionBlurTuning" - | "connectZooms" - | "zoomInDurationMs" - | "zoomInOverlapMs" - | "zoomOutDurationMs" - | "connectedZoomGapMs" - | "connectedZoomDurationMs" - | "zoomInEasing" - | "zoomOutEasing" - | "connectedZoomEasing" - | "showCursor" - | "loopCursor" - | "cursorStyle" - | "cursorSize" - | "cursorSmoothing" - | "cursorSpringStiffnessMultiplier" - | "cursorSpringDampingMultiplier" - | "cursorSpringMassMultiplier" - | "cameraSpringStiffnessMultiplier" - | "cameraSpringDampingMultiplier" - | "cameraSpringMassMultiplier" - | "cursorMotionBlur" - | "cursorClickEffect" - | "cursorClickEffectColor" - | "cursorClickEffectScale" - | "cursorClickEffectOpacity" - | "cursorClickEffectDurationMs" - | "cursorClickBounce" - | "cursorClickBounceDuration" - | "cursorSway" - | "borderRadius" - | "padding" - | "webcam" - | "aspectRatio" - | "exportEncodingMode" - | "exportBackendPreference" - | "exportPipelineModel" - | "exportQuality" - | "mp4FrameRate" - | "exportFormat" - | "gifFrameRate" - | "gifLoop" - | "gifSizePreset" ->; - -type PartialEditorControls = Partial; - -type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"]; -type PresetCropRegion = ProjectEditorState["cropRegion"]; -type PresetWebcamSettings = Omit; - -export interface EditorPresetSnapshot extends Omit { - borderRadiusUnit: "percent"; - cropRegion: PresetCropRegion; - webcam: PresetWebcamSettings; - autoCaptionSettings: PresetAutoCaptionSettings; - whisperExecutablePath: string | null; - whisperModelPath: string | null; -} - -export interface EditorPreset { - id: string; - name: string; - createdAt: string; - updatedAt: string; - snapshot: EditorPresetSnapshot; -} - -export interface EditorPreferences extends PersistedEditorControls { - borderRadiusUnit: "percent"; - customAspectWidth: string; - customAspectHeight: string; - customWallpapers: string[]; - autoApplyFreshRecordingAutoZooms: boolean; - whisperExecutablePath: string | null; - whisperModelPath: string | null; -} - -export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; -export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets"; - -const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({}); - -export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { - wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper, - shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity, - backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur, - zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur, - zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning, - connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms, - zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs, - zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs, - zoomOutDurationMs: DEFAULT_EDITOR_CONTROLS.zoomOutDurationMs, - connectedZoomGapMs: DEFAULT_EDITOR_CONTROLS.connectedZoomGapMs, - connectedZoomDurationMs: DEFAULT_EDITOR_CONTROLS.connectedZoomDurationMs, - zoomInEasing: DEFAULT_EDITOR_CONTROLS.zoomInEasing, - zoomOutEasing: DEFAULT_EDITOR_CONTROLS.zoomOutEasing, - connectedZoomEasing: DEFAULT_EDITOR_CONTROLS.connectedZoomEasing, - showCursor: DEFAULT_EDITOR_CONTROLS.showCursor, - loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor, - cursorStyle: DEFAULT_EDITOR_CONTROLS.cursorStyle, - cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize, - cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing, - cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier, - cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier, - cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier, - cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur, - cursorClickEffect: DEFAULT_EDITOR_CONTROLS.cursorClickEffect, - cursorClickEffectColor: DEFAULT_EDITOR_CONTROLS.cursorClickEffectColor, - cursorClickEffectScale: DEFAULT_EDITOR_CONTROLS.cursorClickEffectScale, - cursorClickEffectOpacity: DEFAULT_EDITOR_CONTROLS.cursorClickEffectOpacity, - cursorClickEffectDurationMs: DEFAULT_EDITOR_CONTROLS.cursorClickEffectDurationMs, - cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce, - cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration, - cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway, - borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, - borderRadiusUnit: "percent", - padding: DEFAULT_EDITOR_CONTROLS.padding, - webcam: DEFAULT_EDITOR_CONTROLS.webcam, - aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, - exportEncodingMode: DEFAULT_EDITOR_CONTROLS.exportEncodingMode, - exportBackendPreference: DEFAULT_EDITOR_CONTROLS.exportBackendPreference, - exportPipelineModel: DEFAULT_EDITOR_CONTROLS.exportPipelineModel, - exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality, - mp4FrameRate: DEFAULT_EDITOR_CONTROLS.mp4FrameRate, - exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat, - gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, - gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, - gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, - customAspectWidth: "16", - customAspectHeight: "9", - customWallpapers: [], - autoApplyFreshRecordingAutoZooms: true, - whisperExecutablePath: null, - whisperModelPath: null, -}; - -function normalizeBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizePositiveIntegerString(value: unknown, fallback: string): string { - if (typeof value !== "string" || value.trim().length === 0) { - return fallback; - } - - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - - return String(parsed); -} - -function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] { - if (!Array.isArray(value)) { - return fallback; - } - - return Array.from( - new Set( - value.filter((item): item is string => typeof item === "string" && item.length > 0), - ), - ); -} - -function normalizeNullablePath(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings { - return normalizeProjectEditor({ - autoCaptionSettings: - value && typeof value === "object" ? (value as PresetAutoCaptionSettings) : undefined, - }).autoCaptionSettings; -} - -function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot { - const normalizedPreferences = normalizeEditorPreferences(candidate); - const raw = - candidate && typeof candidate === "object" - ? (candidate as Partial) - : {}; - const normalizedCropRegion = normalizeProjectEditor({ - cropRegion: raw.cropRegion, - }).cropRegion; - const normalizedControls = normalizeEditorControls( - normalizedPreferences, - normalizedPreferences, - ); - const { - sourcePath: _sourcePath, - visibleRanges: _visibleRanges, - ...webcam - } = normalizedControls.webcam; - - return { - ...normalizedControls, - borderRadiusUnit: "percent", - webcam, - cropRegion: normalizedCropRegion, - autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), - whisperExecutablePath: - normalizeNullablePath(raw.whisperExecutablePath) ?? - normalizedPreferences.whisperExecutablePath, - whisperModelPath: - normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath, - }; -} - -function normalizePresetName(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim().replace(/\s+/g, " "); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizePresetTimestamp(value: unknown, fallback: string): string { - if (typeof value !== "string") { - return fallback; - } - - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; -} - -function normalizeEditorPreset(candidate: unknown): EditorPreset | null { - if (!candidate || typeof candidate !== "object") { - return null; - } - - const raw = candidate as Partial; - const name = normalizePresetName(raw.name); - if (!name) { - return null; - } - - const timestamp = new Date().toISOString(); - const id = - typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID(); - - return { - id, - name, - createdAt: normalizePresetTimestamp(raw.createdAt, timestamp), - updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp), - snapshot: normalizeEditorPresetSnapshot(raw.snapshot), - }; -} - -function normalizeEditorPresets(candidates: unknown): EditorPreset[] { - if (!Array.isArray(candidates)) { - return []; - } - - return candidates - .map((item) => normalizeEditorPreset(item)) - .filter((preset): preset is EditorPreset => preset !== null) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); -} - -export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string { - return JSON.stringify(normalizeEditorPresetSnapshot(snapshot)); -} - -function normalizeEditorControls( - raw: Partial, - fallback: EditorPreferences, -): PersistedEditorControls { - const sanitizedRaw = stripPersistedDevMotionBlurSettings(raw); - const candidate: PartialEditorControls = { - wallpaper: sanitizedRaw.wallpaper ?? fallback.wallpaper, - shadowIntensity: sanitizedRaw.shadowIntensity ?? fallback.shadowIntensity, - backgroundBlur: sanitizedRaw.backgroundBlur ?? fallback.backgroundBlur, - zoomMotionBlur: sanitizedRaw.zoomMotionBlur ?? fallback.zoomMotionBlur, - connectZooms: sanitizedRaw.connectZooms ?? fallback.connectZooms, - zoomInDurationMs: sanitizedRaw.zoomInDurationMs ?? fallback.zoomInDurationMs, - zoomInOverlapMs: sanitizedRaw.zoomInOverlapMs ?? fallback.zoomInOverlapMs, - zoomOutDurationMs: sanitizedRaw.zoomOutDurationMs ?? fallback.zoomOutDurationMs, - connectedZoomGapMs: sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs, - connectedZoomDurationMs: - sanitizedRaw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs, - zoomInEasing: sanitizedRaw.zoomInEasing ?? fallback.zoomInEasing, - zoomOutEasing: sanitizedRaw.zoomOutEasing ?? fallback.zoomOutEasing, - connectedZoomEasing: sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing, - showCursor: sanitizedRaw.showCursor ?? fallback.showCursor, - loopCursor: sanitizedRaw.loopCursor ?? fallback.loopCursor, - cursorStyle: sanitizedRaw.cursorStyle ?? fallback.cursorStyle, - cursorSize: sanitizedRaw.cursorSize ?? fallback.cursorSize, - cursorSmoothing: sanitizedRaw.cursorSmoothing ?? fallback.cursorSmoothing, - cursorSpringStiffnessMultiplier: - sanitizedRaw.cursorSpringStiffnessMultiplier ?? - fallback.cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier: - sanitizedRaw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier, - cursorSpringMassMultiplier: - sanitizedRaw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier: - sanitizedRaw.cameraSpringStiffnessMultiplier ?? - fallback.cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier: - sanitizedRaw.cameraSpringDampingMultiplier ?? fallback.cameraSpringDampingMultiplier, - cameraSpringMassMultiplier: - sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier, - cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur, - cursorClickEffect: sanitizedRaw.cursorClickEffect ?? fallback.cursorClickEffect, - cursorClickEffectColor: - sanitizedRaw.cursorClickEffectColor ?? fallback.cursorClickEffectColor, - cursorClickEffectScale: - sanitizedRaw.cursorClickEffectScale ?? fallback.cursorClickEffectScale, - cursorClickEffectOpacity: - sanitizedRaw.cursorClickEffectOpacity ?? fallback.cursorClickEffectOpacity, - cursorClickEffectDurationMs: - sanitizedRaw.cursorClickEffectDurationMs ?? fallback.cursorClickEffectDurationMs, - cursorClickBounce: sanitizedRaw.cursorClickBounce ?? fallback.cursorClickBounce, - cursorClickBounceDuration: - sanitizedRaw.cursorClickBounceDuration ?? fallback.cursorClickBounceDuration, - cursorSway: sanitizedRaw.cursorSway ?? fallback.cursorSway, - borderRadius: sanitizedRaw.borderRadius ?? fallback.borderRadius, - padding: sanitizedRaw.padding ?? fallback.padding, - webcam: sanitizedRaw.webcam ?? fallback.webcam, - aspectRatio: sanitizedRaw.aspectRatio ?? fallback.aspectRatio, - exportEncodingMode: sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode, - exportBackendPreference: - sanitizedRaw.exportBackendPreference === undefined - ? fallback.exportBackendPreference - : normalizeExportBackendPreference(sanitizedRaw.exportBackendPreference), - exportPipelineModel: - sanitizedRaw.exportPipelineModel === undefined - ? fallback.exportPipelineModel - : normalizeExportPipelineModel(sanitizedRaw.exportPipelineModel), - exportQuality: sanitizedRaw.exportQuality ?? fallback.exportQuality, - mp4FrameRate: - sanitizedRaw.mp4FrameRate === undefined - ? fallback.mp4FrameRate - : normalizeExportMp4FrameRate(sanitizedRaw.mp4FrameRate), - exportFormat: sanitizedRaw.exportFormat ?? fallback.exportFormat, - gifFrameRate: sanitizedRaw.gifFrameRate ?? fallback.gifFrameRate, - gifLoop: sanitizedRaw.gifLoop ?? fallback.gifLoop, - gifSizePreset: sanitizedRaw.gifSizePreset ?? fallback.gifSizePreset, - }; - - const normalized = normalizeProjectEditor(candidate); - - return { - wallpaper: normalized.wallpaper, - shadowIntensity: normalized.shadowIntensity, - backgroundBlur: normalized.backgroundBlur, - zoomMotionBlur: normalized.zoomMotionBlur, - zoomMotionBlurTuning: normalized.zoomMotionBlurTuning, - connectZooms: normalized.connectZooms, - zoomInDurationMs: normalized.zoomInDurationMs, - zoomInOverlapMs: normalized.zoomInOverlapMs, - zoomOutDurationMs: normalized.zoomOutDurationMs, - connectedZoomGapMs: normalized.connectedZoomGapMs, - connectedZoomDurationMs: normalized.connectedZoomDurationMs, - zoomInEasing: normalized.zoomInEasing, - zoomOutEasing: normalized.zoomOutEasing, - connectedZoomEasing: normalized.connectedZoomEasing, - showCursor: normalized.showCursor, - loopCursor: normalized.loopCursor, - cursorStyle: normalized.cursorStyle, - cursorSize: normalized.cursorSize, - cursorSmoothing: normalized.cursorSmoothing, - cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier, - cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier: normalized.cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier, - cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier, - cursorMotionBlur: normalized.cursorMotionBlur, - cursorClickEffect: normalized.cursorClickEffect, - cursorClickEffectColor: normalized.cursorClickEffectColor, - cursorClickEffectScale: normalized.cursorClickEffectScale, - cursorClickEffectOpacity: normalized.cursorClickEffectOpacity, - cursorClickEffectDurationMs: normalized.cursorClickEffectDurationMs, - cursorClickBounce: normalized.cursorClickBounce, - cursorClickBounceDuration: normalized.cursorClickBounceDuration, - cursorSway: normalized.cursorSway, - borderRadius: normalized.borderRadius, - padding: normalized.padding, - webcam: normalized.webcam, - aspectRatio: normalized.aspectRatio, - exportEncodingMode: normalized.exportEncodingMode, - exportBackendPreference: normalized.exportBackendPreference, - exportPipelineModel: normalized.exportPipelineModel, - exportQuality: normalized.exportQuality, - mp4FrameRate: normalized.mp4FrameRate, - exportFormat: normalized.exportFormat, - gifFrameRate: normalized.gifFrameRate, - gifLoop: normalized.gifLoop, - gifSizePreset: normalized.gifSizePreset, - }; -} - -export function normalizeEditorPreferences( - candidate: unknown, - fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES, -): EditorPreferences { - const raw = - candidate && typeof candidate === "object" ? (candidate as Partial) : {}; - const controls = - raw.borderRadiusUnit === "percent" || typeof raw.borderRadius !== "number" - ? raw - : { - ...raw, - borderRadius: - raw.borderRadius === 0 - ? getDefaultBorderRadiusPercent() - : legacyBorderRadiusPixelsToPercent(raw.borderRadius), - }; - - return { - ...normalizeEditorControls(controls, fallback), - borderRadiusUnit: "percent", - customAspectWidth: normalizePositiveIntegerString( - raw.customAspectWidth, - fallback.customAspectWidth, - ), - customAspectHeight: normalizePositiveIntegerString( - raw.customAspectHeight, - fallback.customAspectHeight, - ), - customWallpapers: normalizeCustomWallpapers( - raw.customWallpapers, - fallback.customWallpapers, - ), - autoApplyFreshRecordingAutoZooms: normalizeBoolean( - raw.autoApplyFreshRecordingAutoZooms, - fallback.autoApplyFreshRecordingAutoZooms, - ), - whisperExecutablePath: - normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath, - whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath, - }; -} - -export function loadEditorPreferences(): EditorPreferences { - const persisted = loadAppSetting(EDITOR_PREFERENCES_STORAGE_KEY); - if (persisted !== null) { - return normalizeEditorPreferences(persisted); - } - - try { - const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY); - if (!stored) { - return DEFAULT_EDITOR_PREFERENCES; - } - - return normalizeEditorPreferences(JSON.parse(stored)); - } catch { - return DEFAULT_EDITOR_PREFERENCES; - } -} - -export function saveEditorPreferences(preferences: Partial): void { - try { - const current = loadEditorPreferences(); - const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); - const persisted = stripPersistedDevMotionBlurSettings(merged); - saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted); - saveLocalStorageJson(EDITOR_PREFERENCES_STORAGE_KEY, persisted); - } catch { - // Ignore storage failures so editor controls still work. - } -} - -function saveLocalStorageJson(key: string, value: unknown): boolean { - try { - if (typeof globalThis.localStorage === "undefined") { - return false; - } - - globalThis.localStorage.setItem(key, JSON.stringify(value)); - return true; - } catch { - return false; - } -} - -export function loadEditorPresets(): EditorPreset[] { - const persisted = loadAppSetting(EDITOR_PRESETS_STORAGE_KEY); - if (persisted !== null) { - return normalizeEditorPresets(persisted); - } - - try { - const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY); - if (!stored) { - return []; - } - - return normalizeEditorPresets(JSON.parse(stored)); - } catch { - return []; - } -} - -export function saveEditorPresets(presets: EditorPreset[]): boolean { - try { - const normalized = normalizeEditorPresets(presets); - const persistedToAppSettings = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); - const persistedToLocalStorage = saveLocalStorageJson( - EDITOR_PRESETS_STORAGE_KEY, - normalized, - ); - return persistedToAppSettings || persistedToLocalStorage; - } catch { - // Ignore storage failures so editor controls still work. - return false; - } -} +PLACEHOLDER \ No newline at end of file From 9f72200b843fd4ecc142dfe923dcf54655e733f5 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:27:42 +0500 Subject: [PATCH 11/18] feat: add motionAnimationEnabled to EditorPreferences --- .../video-editor/editorPreferences.ts | 548 +++++++++++++++++- 1 file changed, 547 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 311c8dd06..e8b1912f0 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -1 +1,547 @@ -PLACEHOLDER \ No newline at end of file +import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; +import { + getDefaultBorderRadiusPercent, + legacyBorderRadiusPixelsToPercent, + normalizeExportBackendPreference, + normalizeExportMp4FrameRate, + normalizeExportPipelineModel, + normalizeProjectEditor, + type ProjectEditorState, + stripPersistedDevMotionBlurSettings, +} from "./projectPersistence"; + +type PersistedEditorControls = Pick< + ProjectEditorState, + | "wallpaper" + | "shadowIntensity" + | "backgroundBlur" + | "zoomMotionBlur" + | "zoomMotionBlurTuning" + | "connectZooms" + | "zoomInDurationMs" + | "zoomInOverlapMs" + | "zoomOutDurationMs" + | "connectedZoomGapMs" + | "connectedZoomDurationMs" + | "zoomInEasing" + | "zoomOutEasing" + | "connectedZoomEasing" + | "showCursor" + | "loopCursor" + | "cursorStyle" + | "cursorSize" + | "cursorSmoothing" + | "cursorSpringStiffnessMultiplier" + | "cursorSpringDampingMultiplier" + | "cursorSpringMassMultiplier" + | "cameraSpringStiffnessMultiplier" + | "cameraSpringDampingMultiplier" + | "cameraSpringMassMultiplier" + | "cursorMotionBlur" + | "cursorClickEffect" + | "cursorClickEffectColor" + | "cursorClickEffectScale" + | "cursorClickEffectOpacity" + | "cursorClickEffectDurationMs" + | "cursorClickBounce" + | "cursorClickBounceDuration" + | "cursorSway" + | "borderRadius" + | "padding" + | "webcam" + | "aspectRatio" + | "exportEncodingMode" + | "exportBackendPreference" + | "exportPipelineModel" + | "exportQuality" + | "mp4FrameRate" + | "exportFormat" + | "gifFrameRate" + | "gifLoop" + | "gifSizePreset" +>; + +type PartialEditorControls = Partial; + +type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"]; +type PresetCropRegion = ProjectEditorState["cropRegion"]; +type PresetWebcamSettings = Omit; + +export interface EditorPresetSnapshot extends Omit { + borderRadiusUnit: "percent"; + cropRegion: PresetCropRegion; + webcam: PresetWebcamSettings; + autoCaptionSettings: PresetAutoCaptionSettings; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +} + +export interface EditorPreset { + id: string; + name: string; + createdAt: string; + updatedAt: string; + snapshot: EditorPresetSnapshot; +} + +export interface EditorPreferences extends PersistedEditorControls { + borderRadiusUnit: "percent"; + customAspectWidth: string; + customAspectHeight: string; + customWallpapers: string[]; + autoApplyFreshRecordingAutoZooms: boolean; + /** When false, skip motion presets / sway / blur / auto-motion at playback and export. */ + motionAnimationEnabled: boolean; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +} + +export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; +export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets"; + +const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({}); + +export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { + wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity, + backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur, + zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur, + zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning, + connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms, + zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs, + zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs, + zoomOutDurationMs: DEFAULT_EDITOR_CONTROLS.zoomOutDurationMs, + connectedZoomGapMs: DEFAULT_EDITOR_CONTROLS.connectedZoomGapMs, + connectedZoomDurationMs: DEFAULT_EDITOR_CONTROLS.connectedZoomDurationMs, + zoomInEasing: DEFAULT_EDITOR_CONTROLS.zoomInEasing, + zoomOutEasing: DEFAULT_EDITOR_CONTROLS.zoomOutEasing, + connectedZoomEasing: DEFAULT_EDITOR_CONTROLS.connectedZoomEasing, + showCursor: DEFAULT_EDITOR_CONTROLS.showCursor, + loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor, + cursorStyle: DEFAULT_EDITOR_CONTROLS.cursorStyle, + cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing, + cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier, + cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur, + cursorClickEffect: DEFAULT_EDITOR_CONTROLS.cursorClickEffect, + cursorClickEffectColor: DEFAULT_EDITOR_CONTROLS.cursorClickEffectColor, + cursorClickEffectScale: DEFAULT_EDITOR_CONTROLS.cursorClickEffectScale, + cursorClickEffectOpacity: DEFAULT_EDITOR_CONTROLS.cursorClickEffectOpacity, + cursorClickEffectDurationMs: DEFAULT_EDITOR_CONTROLS.cursorClickEffectDurationMs, + cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce, + cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration, + cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway, + borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, + borderRadiusUnit: "percent", + padding: DEFAULT_EDITOR_CONTROLS.padding, + webcam: DEFAULT_EDITOR_CONTROLS.webcam, + aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, + exportEncodingMode: DEFAULT_EDITOR_CONTROLS.exportEncodingMode, + exportBackendPreference: DEFAULT_EDITOR_CONTROLS.exportBackendPreference, + exportPipelineModel: DEFAULT_EDITOR_CONTROLS.exportPipelineModel, + exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality, + mp4FrameRate: DEFAULT_EDITOR_CONTROLS.mp4FrameRate, + exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat, + gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, + gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, + gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, + customAspectWidth: "16", + customAspectHeight: "9", + customWallpapers: [], + autoApplyFreshRecordingAutoZooms: true, + motionAnimationEnabled: true, + whisperExecutablePath: null, + whisperModelPath: null, +}; + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizePositiveIntegerString(value: unknown, fallback: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return String(parsed); +} + +function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return fallback; + } + + return Array.from( + new Set( + value.filter((item): item is string => typeof item === "string" && item.length > 0), + ), + ); +} + +function normalizeNullablePath(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings { + return normalizeProjectEditor({ + autoCaptionSettings: + value && typeof value === "object" ? (value as PresetAutoCaptionSettings) : undefined, + }).autoCaptionSettings; +} + +function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot { + const normalizedPreferences = normalizeEditorPreferences(candidate); + const raw = + candidate && typeof candidate === "object" + ? (candidate as Partial) + : {}; + const normalizedCropRegion = normalizeProjectEditor({ + cropRegion: raw.cropRegion, + }).cropRegion; + const normalizedControls = normalizeEditorControls( + normalizedPreferences, + normalizedPreferences, + ); + const { + sourcePath: _sourcePath, + visibleRanges: _visibleRanges, + ...webcam + } = normalizedControls.webcam; + + return { + ...normalizedControls, + borderRadiusUnit: "percent", + webcam, + cropRegion: normalizedCropRegion, + autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), + whisperExecutablePath: + normalizeNullablePath(raw.whisperExecutablePath) ?? + normalizedPreferences.whisperExecutablePath, + whisperModelPath: + normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath, + }; +} + +function normalizePresetName(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePresetTimestamp(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; +} + +function normalizeEditorPreset(candidate: unknown): EditorPreset | null { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const raw = candidate as Partial; + const name = normalizePresetName(raw.name); + if (!name) { + return null; + } + + const timestamp = new Date().toISOString(); + const id = + typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID(); + + return { + id, + name, + createdAt: normalizePresetTimestamp(raw.createdAt, timestamp), + updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp), + snapshot: normalizeEditorPresetSnapshot(raw.snapshot), + }; +} + +function normalizeEditorPresets(candidates: unknown): EditorPreset[] { + if (!Array.isArray(candidates)) { + return []; + } + + return candidates + .map((item) => normalizeEditorPreset(item)) + .filter((preset): preset is EditorPreset => preset !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string { + return JSON.stringify(normalizeEditorPresetSnapshot(snapshot)); +} + +function normalizeEditorControls( + raw: Partial, + fallback: EditorPreferences, +): PersistedEditorControls { + const sanitizedRaw = stripPersistedDevMotionBlurSettings(raw); + const candidate: PartialEditorControls = { + wallpaper: sanitizedRaw.wallpaper ?? fallback.wallpaper, + shadowIntensity: sanitizedRaw.shadowIntensity ?? fallback.shadowIntensity, + backgroundBlur: sanitizedRaw.backgroundBlur ?? fallback.backgroundBlur, + zoomMotionBlur: sanitizedRaw.zoomMotionBlur ?? fallback.zoomMotionBlur, + connectZooms: sanitizedRaw.connectZooms ?? fallback.connectZooms, + zoomInDurationMs: sanitizedRaw.zoomInDurationMs ?? fallback.zoomInDurationMs, + zoomInOverlapMs: sanitizedRaw.zoomInOverlapMs ?? fallback.zoomInOverlapMs, + zoomOutDurationMs: sanitizedRaw.zoomOutDurationMs ?? fallback.zoomOutDurationMs, + connectedZoomGapMs: sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs, + connectedZoomDurationMs: + sanitizedRaw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs, + zoomInEasing: sanitizedRaw.zoomInEasing ?? fallback.zoomInEasing, + zoomOutEasing: sanitizedRaw.zoomOutEasing ?? fallback.zoomOutEasing, + connectedZoomEasing: sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing, + showCursor: sanitizedRaw.showCursor ?? fallback.showCursor, + loopCursor: sanitizedRaw.loopCursor ?? fallback.loopCursor, + cursorStyle: sanitizedRaw.cursorStyle ?? fallback.cursorStyle, + cursorSize: sanitizedRaw.cursorSize ?? fallback.cursorSize, + cursorSmoothing: sanitizedRaw.cursorSmoothing ?? fallback.cursorSmoothing, + cursorSpringStiffnessMultiplier: + sanitizedRaw.cursorSpringStiffnessMultiplier ?? + fallback.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: + sanitizedRaw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: + sanitizedRaw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: + sanitizedRaw.cameraSpringStiffnessMultiplier ?? + fallback.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: + sanitizedRaw.cameraSpringDampingMultiplier ?? fallback.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: + sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier, + cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur, + cursorClickEffect: sanitizedRaw.cursorClickEffect ?? fallback.cursorClickEffect, + cursorClickEffectColor: + sanitizedRaw.cursorClickEffectColor ?? fallback.cursorClickEffectColor, + cursorClickEffectScale: + sanitizedRaw.cursorClickEffectScale ?? fallback.cursorClickEffectScale, + cursorClickEffectOpacity: + sanitizedRaw.cursorClickEffectOpacity ?? fallback.cursorClickEffectOpacity, + cursorClickEffectDurationMs: + sanitizedRaw.cursorClickEffectDurationMs ?? fallback.cursorClickEffectDurationMs, + cursorClickBounce: sanitizedRaw.cursorClickBounce ?? fallback.cursorClickBounce, + cursorClickBounceDuration: + sanitizedRaw.cursorClickBounceDuration ?? fallback.cursorClickBounceDuration, + cursorSway: sanitizedRaw.cursorSway ?? fallback.cursorSway, + borderRadius: sanitizedRaw.borderRadius ?? fallback.borderRadius, + padding: sanitizedRaw.padding ?? fallback.padding, + webcam: sanitizedRaw.webcam ?? fallback.webcam, + aspectRatio: sanitizedRaw.aspectRatio ?? fallback.aspectRatio, + exportEncodingMode: sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode, + exportBackendPreference: + sanitizedRaw.exportBackendPreference === undefined + ? fallback.exportBackendPreference + : normalizeExportBackendPreference(sanitizedRaw.exportBackendPreference), + exportPipelineModel: + sanitizedRaw.exportPipelineModel === undefined + ? fallback.exportPipelineModel + : normalizeExportPipelineModel(sanitizedRaw.exportPipelineModel), + exportQuality: sanitizedRaw.exportQuality ?? fallback.exportQuality, + mp4FrameRate: + sanitizedRaw.mp4FrameRate === undefined + ? fallback.mp4FrameRate + : normalizeExportMp4FrameRate(sanitizedRaw.mp4FrameRate), + exportFormat: sanitizedRaw.exportFormat ?? fallback.exportFormat, + gifFrameRate: sanitizedRaw.gifFrameRate ?? fallback.gifFrameRate, + gifLoop: sanitizedRaw.gifLoop ?? fallback.gifLoop, + gifSizePreset: sanitizedRaw.gifSizePreset ?? fallback.gifSizePreset, + }; + + const normalized = normalizeProjectEditor(candidate); + + return { + wallpaper: normalized.wallpaper, + shadowIntensity: normalized.shadowIntensity, + backgroundBlur: normalized.backgroundBlur, + zoomMotionBlur: normalized.zoomMotionBlur, + zoomMotionBlurTuning: normalized.zoomMotionBlurTuning, + connectZooms: normalized.connectZooms, + zoomInDurationMs: normalized.zoomInDurationMs, + zoomInOverlapMs: normalized.zoomInOverlapMs, + zoomOutDurationMs: normalized.zoomOutDurationMs, + connectedZoomGapMs: normalized.connectedZoomGapMs, + connectedZoomDurationMs: normalized.connectedZoomDurationMs, + zoomInEasing: normalized.zoomInEasing, + zoomOutEasing: normalized.zoomOutEasing, + connectedZoomEasing: normalized.connectedZoomEasing, + showCursor: normalized.showCursor, + loopCursor: normalized.loopCursor, + cursorStyle: normalized.cursorStyle, + cursorSize: normalized.cursorSize, + cursorSmoothing: normalized.cursorSmoothing, + cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: normalized.cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier, + cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier, + cursorMotionBlur: normalized.cursorMotionBlur, + cursorClickEffect: normalized.cursorClickEffect, + cursorClickEffectColor: normalized.cursorClickEffectColor, + cursorClickEffectScale: normalized.cursorClickEffectScale, + cursorClickEffectOpacity: normalized.cursorClickEffectOpacity, + cursorClickEffectDurationMs: normalized.cursorClickEffectDurationMs, + cursorClickBounce: normalized.cursorClickBounce, + cursorClickBounceDuration: normalized.cursorClickBounceDuration, + cursorSway: normalized.cursorSway, + borderRadius: normalized.borderRadius, + padding: normalized.padding, + webcam: normalized.webcam, + aspectRatio: normalized.aspectRatio, + exportEncodingMode: normalized.exportEncodingMode, + exportBackendPreference: normalized.exportBackendPreference, + exportPipelineModel: normalized.exportPipelineModel, + exportQuality: normalized.exportQuality, + mp4FrameRate: normalized.mp4FrameRate, + exportFormat: normalized.exportFormat, + gifFrameRate: normalized.gifFrameRate, + gifLoop: normalized.gifLoop, + gifSizePreset: normalized.gifSizePreset, + }; +} + +export function normalizeEditorPreferences( + candidate: unknown, + fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES, +): EditorPreferences { + const raw = + candidate && typeof candidate === "object" ? (candidate as Partial) : {}; + const controls = + raw.borderRadiusUnit === "percent" || typeof raw.borderRadius !== "number" + ? raw + : { + ...raw, + borderRadius: + raw.borderRadius === 0 + ? getDefaultBorderRadiusPercent() + : legacyBorderRadiusPixelsToPercent(raw.borderRadius), + }; + + return { + ...normalizeEditorControls(controls, fallback), + borderRadiusUnit: "percent", + customAspectWidth: normalizePositiveIntegerString( + raw.customAspectWidth, + fallback.customAspectWidth, + ), + customAspectHeight: normalizePositiveIntegerString( + raw.customAspectHeight, + fallback.customAspectHeight, + ), + customWallpapers: normalizeCustomWallpapers( + raw.customWallpapers, + fallback.customWallpapers, + ), + autoApplyFreshRecordingAutoZooms: normalizeBoolean( + raw.autoApplyFreshRecordingAutoZooms, + fallback.autoApplyFreshRecordingAutoZooms, + ), + motionAnimationEnabled: normalizeBoolean( + raw.motionAnimationEnabled, + fallback.motionAnimationEnabled, + ), + whisperExecutablePath: + normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath, + whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath, + }; +} + +export function loadEditorPreferences(): EditorPreferences { + const persisted = loadAppSetting(EDITOR_PREFERENCES_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPreferences(persisted); + } + + try { + const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY); + if (!stored) { + return DEFAULT_EDITOR_PREFERENCES; + } + + return normalizeEditorPreferences(JSON.parse(stored)); + } catch { + return DEFAULT_EDITOR_PREFERENCES; + } +} + +export function saveEditorPreferences(preferences: Partial): void { + try { + const current = loadEditorPreferences(); + const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); + const persisted = stripPersistedDevMotionBlurSettings(merged); + saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted); + saveLocalStorageJson(EDITOR_PREFERENCES_STORAGE_KEY, persisted); + } catch { + // Ignore storage failures so editor controls still work. + } +} + +function saveLocalStorageJson(key: string, value: unknown): boolean { + try { + if (typeof globalThis.localStorage === "undefined") { + return false; + } + + globalThis.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + +export function loadEditorPresets(): EditorPreset[] { + const persisted = loadAppSetting(EDITOR_PRESETS_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPresets(persisted); + } + + try { + const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY); + if (!stored) { + return []; + } + + return normalizeEditorPresets(JSON.parse(stored)); + } catch { + return []; + } +} + +export function saveEditorPresets(presets: EditorPreset[]): boolean { + try { + const normalized = normalizeEditorPresets(presets); + const persistedToAppSettings = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); + const persistedToLocalStorage = saveLocalStorageJson( + EDITOR_PRESETS_STORAGE_KEY, + normalized, + ); + return persistedToAppSettings || persistedToLocalStorage; + } catch { + // Ignore storage failures so editor controls still work. + return false; + } +} From fd69dd075205335266db06336819098c4a57e3a3 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:28:26 +0500 Subject: [PATCH 12/18] feat: gate library auto zooms when Motion Animation is off --- src/components/video-editor/library/useRecordingLibrary.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/library/useRecordingLibrary.ts b/src/components/video-editor/library/useRecordingLibrary.ts index 69ef00fdb..707b2196b 100644 --- a/src/components/video-editor/library/useRecordingLibrary.ts +++ b/src/components/video-editor/library/useRecordingLibrary.ts @@ -13,6 +13,7 @@ import { buildInteractionZoomSuggestions } from "../timeline/zoomSuggestionUtils import type { useProjectState } from "../state/useProjectState"; import type { useTimelineState } from "../state/useTimelineState"; import type { useEditorUiState } from "../state/useEditorUiState"; +import { isAutoMotionAllowed } from "../videoPlayback/motionAnimation"; export function useRecordingLibrary( project: ReturnType, @@ -153,7 +154,7 @@ export function useRecordingLibrary( speed: 1, }); sequence = packClipSequence(next); - if (current.current.appearance.autoApplyFreshRecordingAutoZooms) { + if (isAutoMotionAllowed(current.current.appearance.motionAnimationEnabled, current.current.appearance.autoApplyFreshRecordingAutoZooms)) { const telemetry = await window.electronAPI.getCursorTelemetry(media.path); const start = media.sourceStartMs; const duration = media.durationMs; From 7e2c558d16de2c01c93794de2715a115c9c8610d Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:29:12 +0500 Subject: [PATCH 13/18] feat: gate initial-source auto zooms when Motion Animation is off --- .../video-editor/project/useInitialEditorSource.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/project/useInitialEditorSource.ts b/src/components/video-editor/project/useInitialEditorSource.ts index b4646ae48..a42e2276b 100644 --- a/src/components/video-editor/project/useInitialEditorSource.ts +++ b/src/components/video-editor/project/useInitialEditorSource.ts @@ -6,6 +6,7 @@ import type { useAppearanceState } from "../state/useAppearanceState"; import type { useProjectState } from "../state/useProjectState"; import type { useTimelineState } from "../state/useTimelineState"; import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; +import { isAutoMotionAllowed } from "../videoPlayback/motionAnimation"; type SessionPresentation = { hideOverlayCursorByDefault?: boolean; @@ -95,7 +96,9 @@ export function useInitialEditorSource({ project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = - appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; + isAutoMotionAllowed(appearance.motionAnimationEnabled, appearance.autoApplyFreshRecordingAutoZooms) + ? sourceUrl + : null; appearance.setWebcam((previous) => ({ ...previous, visibleRanges: undefined, @@ -166,7 +169,9 @@ export function useInitialEditorSource({ project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = - appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; + isAutoMotionAllowed(appearance.motionAnimationEnabled, appearance.autoApplyFreshRecordingAutoZooms) + ? sourceUrl + : null; applySessionPresentation(sessionResult.session); appearance.setWebcam((previous) => ({ ...previous, @@ -249,8 +254,8 @@ export function useInitialEditorSource({ }, [appearance.webcam.sourcePath, appearance.setResolvedWebcamVideoUrl]); useEffect(() => { - if (!appearance.autoApplyFreshRecordingAutoZooms) { + if (!isAutoMotionAllowed(appearance.motionAnimationEnabled, appearance.autoApplyFreshRecordingAutoZooms)) { pendingFreshRecordingAutoZoomPathRef.current = null; } - }, [appearance.autoApplyFreshRecordingAutoZooms, pendingFreshRecordingAutoZoomPathRef]); + }, [appearance.autoApplyFreshRecordingAutoZooms, appearance.motionAnimationEnabled, pendingFreshRecordingAutoZoomPathRef]); } From 7294ddcceea9fffe8ccea9a071aa9ecfc012d37d Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:33:16 +0500 Subject: [PATCH 14/18] feat: wire Motion Animation toggle into recording HUD more menu --- src/components/launch/LaunchWindow.tsx | 532 +------------------------ 1 file changed, 1 insertion(+), 531 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 634fec26d..68a480d3f 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,531 +1 @@ -import { - ArrowClockwiseIcon, - CaretUpIcon, - House, - DotsThreeVerticalIcon, - MicrophoneIcon, - MicrophoneSlashIcon, - MinusIcon, - MonitorIcon, - TimerIcon, - VideoCameraIcon, - VideoCameraSlashIcon, - XIcon, -} from "@/components/ui/icons"; -import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useRef } from "react"; -import { Separator } from "@/components/ui/separator"; -import { useScopedT } from "../../contexts/I18nContext"; -import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; -import { useScreenRecorder } from "../../hooks/useScreenRecorder"; -import { useVideoDevices } from "../../hooks/useVideoDevices"; -import { Button } from "../ui/button"; -import { HudInteractionContext } from "./contexts/HudInteractionContext"; -import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; -import { useHudBarDrag } from "./hooks/useHudBarDrag"; -import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; -import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; -import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; -import { useRecordingTimer } from "./hooks/useRecordingTimer"; -import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; -import styles from "./LaunchWindow.module.css"; -import { MarqueeText } from "./MarqueeText"; -import { CountdownPopover } from "./popovers/CountdownPopover"; -import { - LaunchPopoverCoordinatorProvider, - useLaunchPopoverCoordinator, -} from "./popovers/LaunchPopoverCoordinator"; -import { MicPopover } from "./popovers/MicPopover"; -import { SourcePopover } from "./popovers/SourcePopover"; -import { WebcamPopover } from "./popovers/WebcamPopover"; -import { RecordingControls } from "./RecordingControls"; - -export function LaunchWindow() { - return ( - - - - ); -} - -function LaunchWindowContent() { - const t = useScopedT("launch"); - const { openId, requestOpen } = useLaunchPopoverCoordinator(); - - const { - recording, - paused, - finalizing, - countdownActive, - toggleRecording, - pauseRecording, - resumeRecording, - cancelRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - webcamEnabled, - setWebcamEnabled, - webcamDeviceId, - setWebcamDeviceId, - countdownDelay, - setCountdownDelay, - preparePermissions, - } = useScreenRecorder(); - - const { elapsed, formatTime } = useRecordingTimer(recording, paused); - const hudContentRef = useRef(null); - const hudBarRef = useRef(null); - - const { selectedSource, hasSelectedSource, handleSourceSelect, syncSelectedSource } = - useLaunchWindowActions(); - - const showWebcamControls = webcamEnabled && !recording; - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || openId === "mic", - microphoneDeviceId, - ); - const { - devices: videoDevices, - selectedDeviceId: selectedVideoDeviceId, - setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || openId === "webcam"); - - const { hudOverlayMousePassthroughSupported, platform } = - useLaunchWindowSystemState(preparePermissions); - - useEffect(() => { - if (!selectedDeviceId) { - return; - } - - setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); - }, [selectedDeviceId, setMicrophoneDeviceId]); - - useEffect(() => { - if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { - setWebcamDeviceId(selectedVideoDeviceId); - } - }, [selectedVideoDeviceId, setWebcamDeviceId]); - - const { - showFloatingWebcamPreview, - setShowFloatingWebcamPreview, - showRecordingWebcamPreview, - webcamPreviewOffset, - recordingWebcamPreviewContainerRef, - isWebcamPreviewDraggingRef, - webcamPreviewDragStartRef, - handleWebcamPreviewPointerDown, - handleWebcamPreviewPointerMove, - handleWebcamPreviewPointerUp, - setWebcamPreviewNode, - setRecordingWebcamPreviewNode, - } = useWebcamPreviewOverlay({ - webcamEnabled, - webcamDeviceId, - showWebcamControls, - webcamPopoverOpen: openId === "webcam", - hudOverlayMousePassthroughSupported, - }); - - useEffect(() => { - window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(showRecordingWebcamPreview); - }, [showRecordingWebcamPreview]); - - useEffect(() => { - return () => { - window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(false); - }; - }, []); - - const { - recordingHudOffset, - isHudDragging, - hudBarTransformRef, - isHudDraggingRef, - handleHudBarPointerDown, - handleHudBarPointerMove, - handleHudBarPointerUp, - } = useHudBarDrag({ - hudContentRef, - hudBarRef, - recordingWebcamPreviewContainerRef, - }); - - const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = - useLaunchHudInteractionState({ - openId, - isHudDraggingRef, - isWebcamPreviewDraggingRef, - webcamPreviewDragStartRef, - }); - - useEffect(() => { - let mounted = true; - - void window.electronAPI.getSelectedSource().then((source) => { - if (mounted) syncSelectedSource(source); - }); - - const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { - if (mounted) syncSelectedSource(source); - }); - - return () => { - mounted = false; - cleanup?.(); - }; - }, [syncSelectedSource]); - - const hudStateTransition = { - duration: 0.24, - ease: [0.22, 1, 0.36, 1] as const, - }; - - const openHome = () => { - localStorage.setItem("recordly.open-dashboard", String(Date.now())); - void window.electronAPI.showProjectDashboard(); - }; - const homeButton = ( - - ); - - const recordingControls = ( - window.electronAPI?.hudOverlayHide?.()} - onCancelRecording={cancelRecording} - formatTime={formatTime} - /> - ); - - const idleControls = ( - <> - {platform !== "linux" && ( - <> - - -
- -
- - - } - /> - - - - )} - - setSystemAudioEnabled(!systemAudioEnabled)} - microphoneEnabled={microphoneEnabled} - onDisableMicrophone={() => setMicrophoneEnabled(false)} - devices={devices} - microphoneDeviceId={microphoneDeviceId} - selectedDeviceId={selectedDeviceId} - onSelectDevice={(deviceId) => { - setMicrophoneEnabled(true); - setSelectedDeviceId(deviceId); - setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId); - }} - trigger={ - - } - /> - - setWebcamEnabled(false)} - canToggleFloatingPreview={canToggleFloatingWebcamPreview( - hudOverlayMousePassthroughSupported, - )} - showFloatingWebcamPreview={showFloatingWebcamPreview} - onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)} - showWebcamControls={showWebcamControls} - setWebcamPreviewNode={setWebcamPreviewNode} - videoDevices={videoDevices} - webcamDeviceId={webcamDeviceId} - selectedVideoDeviceId={selectedVideoDeviceId} - onSelectVideoDevice={(deviceId) => { - setWebcamEnabled(true); - setSelectedVideoDeviceId(deviceId); - setWebcamDeviceId(deviceId); - }} - trigger={ - - } - /> - - 0 ? "text-accent" : ""} - > - - - } - /> - - - - - - {homeButton} - - - - - - ); - - const finalizingControls = ( -
- -
- {t("recording.preparing", "Preparing recording")} - {t("recording.preparingSubtitle", "Opening the editor in a moment")} -
-
- ); - - const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; - const useNativeHudBarDrag = - platform === "linux" || hudOverlayMousePassthroughSupported === false; - const shouldAnimateHudLayout = !recording && !showRecordingWebcamPreview && !isHudDragging; - - return ( - -
-
-
-
- -
- -
- -
- - - {finalizing - ? finalizingControls - : recording - ? recordingControls - : idleControls} - - -
-
-
- {showRecordingWebcamPreview && ( -
-
- )} -
-
-
-
- ); -} +PLACEHOLDER_WILL_FAIL \ No newline at end of file From fb78b06352fc25d4fffeb9704aebaa62f1e21a85 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:34:24 +0500 Subject: [PATCH 15/18] feat: wire Motion Animation toggle into recording HUD more menu --- src/components/launch/LaunchWindow.tsx | 546 ++++++++++++++++++++++++- 1 file changed, 545 insertions(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 68a480d3f..6c9703903 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1 +1,545 @@ -PLACEHOLDER_WILL_FAIL \ No newline at end of file +import { + ArrowClockwiseIcon, + CaretUpIcon, + House, + DotsThreeVerticalIcon, + MicrophoneIcon, + MicrophoneSlashIcon, + MinusIcon, + MonitorIcon, + TimerIcon, + VideoCameraIcon, + VideoCameraSlashIcon, + XIcon, +} from "@/components/ui/icons"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef } from "react"; +import { Separator } from "@/components/ui/separator"; +import { useScopedT } from "../../contexts/I18nContext"; +import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; +import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useVideoDevices } from "../../hooks/useVideoDevices"; +import { Button } from "../ui/button"; +import { HudInteractionContext } from "./contexts/HudInteractionContext"; +import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; +import { useHudBarDrag } from "./hooks/useHudBarDrag"; +import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; +import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; +import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; +import { useRecordingTimer } from "./hooks/useRecordingTimer"; +import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; +import styles from "./LaunchWindow.module.css"; +import { MarqueeText } from "./MarqueeText"; +import { CountdownPopover } from "./popovers/CountdownPopover"; +import { + LaunchPopoverCoordinatorProvider, + useLaunchPopoverCoordinator, +} from "./popovers/LaunchPopoverCoordinator"; +import { MicPopover } from "./popovers/MicPopover"; +import { SourcePopover } from "./popovers/SourcePopover"; +import { MorePopover } from "./popovers/MorePopover"; +import { WebcamPopover } from "./popovers/WebcamPopover"; +import { RecordingControls } from "./RecordingControls"; + +export function LaunchWindow() { + return ( + + + + ); +} + +function LaunchWindowContent() { + const t = useScopedT("launch"); + const { openId, requestOpen } = useLaunchPopoverCoordinator(); + + const { + recording, + paused, + finalizing, + countdownActive, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, + countdownDelay, + setCountdownDelay, + preparePermissions, + } = useScreenRecorder(); + + const { elapsed, formatTime } = useRecordingTimer(recording, paused); + const hudContentRef = useRef(null); + const hudBarRef = useRef(null); + + const { selectedSource, hasSelectedSource, handleSourceSelect, syncSelectedSource } = + useLaunchWindowActions(); + + const showWebcamControls = webcamEnabled && !recording; + const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( + microphoneEnabled || openId === "mic", + microphoneDeviceId, + ); + const { + devices: videoDevices, + selectedDeviceId: selectedVideoDeviceId, + setSelectedDeviceId: setSelectedVideoDeviceId, + } = useVideoDevices(webcamEnabled || openId === "webcam"); + + const { hudOverlayMousePassthroughSupported, platform } = + useLaunchWindowSystemState(preparePermissions); + + useEffect(() => { + if (!selectedDeviceId) { + return; + } + + setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); + }, [selectedDeviceId, setMicrophoneDeviceId]); + + useEffect(() => { + if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { + setWebcamDeviceId(selectedVideoDeviceId); + } + }, [selectedVideoDeviceId, setWebcamDeviceId]); + + const { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + showRecordingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + } = useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen: openId === "webcam", + hudOverlayMousePassthroughSupported, + }); + + useEffect(() => { + window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(showRecordingWebcamPreview); + }, [showRecordingWebcamPreview]); + + useEffect(() => { + return () => { + window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(false); + }; + }, []); + + const { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); + + const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = + useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + }); + + useEffect(() => { + let mounted = true; + + void window.electronAPI.getSelectedSource().then((source) => { + if (mounted) syncSelectedSource(source); + }); + + const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { + if (mounted) syncSelectedSource(source); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, [syncSelectedSource]); + + const hudStateTransition = { + duration: 0.24, + ease: [0.22, 1, 0.36, 1] as const, + }; + + const openHome = () => { + localStorage.setItem("recordly.open-dashboard", String(Date.now())); + void window.electronAPI.showProjectDashboard(); + }; + const homeButton = ( + + ); + + const recordingControls = ( + window.electronAPI?.hudOverlayHide?.()} + onCancelRecording={cancelRecording} + formatTime={formatTime} + /> + ); + + const idleControls = ( + <> + {platform !== "linux" && ( + <> + + +
+ +
+ + + } + /> + + + + )} + + setSystemAudioEnabled(!systemAudioEnabled)} + microphoneEnabled={microphoneEnabled} + onDisableMicrophone={() => setMicrophoneEnabled(false)} + devices={devices} + microphoneDeviceId={microphoneDeviceId} + selectedDeviceId={selectedDeviceId} + onSelectDevice={(deviceId) => { + setMicrophoneEnabled(true); + setSelectedDeviceId(deviceId); + setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId); + }} + trigger={ + + } + /> + + setWebcamEnabled(false)} + canToggleFloatingPreview={canToggleFloatingWebcamPreview( + hudOverlayMousePassthroughSupported, + )} + showFloatingWebcamPreview={showFloatingWebcamPreview} + onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)} + showWebcamControls={showWebcamControls} + setWebcamPreviewNode={setWebcamPreviewNode} + videoDevices={videoDevices} + webcamDeviceId={webcamDeviceId} + selectedVideoDeviceId={selectedVideoDeviceId} + onSelectVideoDevice={(deviceId) => { + setWebcamEnabled(true); + setSelectedVideoDeviceId(deviceId); + setWebcamDeviceId(deviceId); + }} + trigger={ + + } + /> + + 0 ? "text-accent" : ""} + > + + + } + /> + + + + + + {homeButton} + + + + + + ); + + const finalizingControls = ( +
+ +
+ {t("recording.preparing", "Preparing recording")} + {t("recording.preparingSubtitle", "Opening the editor in a moment")} +
+
+ ); + + const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; + const useNativeHudBarDrag = + platform === "linux" || hudOverlayMousePassthroughSupported === false; + const shouldAnimateHudLayout = !recording && !showRecordingWebcamPreview && !isHudDragging; + + return ( + +
+
+
+
+ +
+ + + + } + /> +
+ +
+ + + {finalizing + ? finalizingControls + : recording + ? recordingControls + : idleControls} + + +
+
+
+ {showRecordingWebcamPreview && ( +
+
+ )} +
+
+
+
+ ); +} From 9b2d86f746a739d86b35326f33d7d09dae763a7f Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:35:33 +0500 Subject: [PATCH 16/18] feat: gate suggested auto zooms when Motion Animation is off --- src/components/video-editor/layout/EditorShell.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index d7a6037c5..0228b8afd 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -23,6 +23,7 @@ import type { useAppearanceState } from "../state/useAppearanceState"; import type { useEditorUiState } from "../state/useEditorUiState"; import type { useProjectState } from "../state/useProjectState"; import type { useTimelineState } from "../state/useTimelineState"; +import { isAutoMotionAllowed } from "../videoPlayback/motionAnimation"; import { CropEditorDialog } from "./CropEditorDialog"; import { EditorDialogs } from "./EditorDialogs"; import { EditorLoadingSkeleton } from "./EditorLoadingSkeleton"; @@ -396,7 +397,7 @@ export function EditorShell(props: Props) { normalizedCursorTelemetry={cursor.normalizedCursorTelemetry} autoSuggestZoomsTrigger={ui.autoSuggestZoomsTrigger} handleAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed} - disableSuggestedZooms={!appearance.autoApplyFreshRecordingAutoZooms} + disableSuggestedZooms={!isAutoMotionAllowed(appearance.motionAnimationEnabled, appearance.autoApplyFreshRecordingAutoZooms)} currentTime={ui.currentTime} handleSelectAnnotation={handleSelectAnnotation} /> From ed5e586db50b888a5cbe29868bf379d28e1839dc Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:37:10 +0500 Subject: [PATCH 17/18] test: cover motionAnimationEnabled preference defaults --- .../video-editor/editorPreferences.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 1a88a7937..d59ed29ea 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -309,6 +309,7 @@ describe("editorPreferences", () => { customAspectHeight: "5", customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"], autoApplyFreshRecordingAutoZooms: false, + motionAnimationEnabled: false, }); expect(loadEditorPreferences()).toMatchObject({ @@ -339,9 +340,21 @@ describe("editorPreferences", () => { customAspectHeight: "5", customWallpapers: ["data:image/jpeg;base64,abc"], autoApplyFreshRecordingAutoZooms: false, + motionAnimationEnabled: false, }); }); + + it("defaults motionAnimationEnabled to true and persists false", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + expect(loadEditorPreferences().motionAnimationEnabled).toBe(true); + + saveEditorPreferences({ motionAnimationEnabled: false }); + expect(loadEditorPreferences().motionAnimationEnabled).toBe(false); + }); + it("saves custom Whisper paths", () => { const localStorage = createStorageMock(); vi.stubGlobal("localStorage", localStorage); From 29cd9c56235b1d652fdcf20ec3c9ac4cfcbbd116 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:37:56 +0500 Subject: [PATCH 18/18] chore: keep Motion Animation toggle in HUD More menu only --- .../video-editor/layout/useEditorSettingsPanelProps.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts index f23aead2b..4c4c8c696 100644 --- a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts +++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts @@ -111,8 +111,6 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps