diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 634fec26d..6c9703903 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -37,6 +37,7 @@ import { } 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"; @@ -455,10 +456,23 @@ function LaunchWindowContent() { onPointerUp={handleHudBarPointerUp} onPointerCancel={handleHudBarPointerUp} > - + + + } /> 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/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); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index f696f0a91..e8b1912f0 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -90,6 +90,8 @@ export interface EditorPreferences extends PersistedEditorControls { 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; } @@ -152,6 +154,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { customAspectHeight: "9", customWallpapers: [], autoApplyFreshRecordingAutoZooms: true, + motionAnimationEnabled: true, whisperExecutablePath: null, whisperModelPath: null, }; @@ -457,6 +460,10 @@ export function normalizeEditorPreferences( raw.autoApplyFreshRecordingAutoZooms, fallback.autoApplyFreshRecordingAutoZooms, ), + motionAnimationEnabled: normalizeBoolean( + raw.motionAnimationEnabled, + fallback.motionAnimationEnabled, + ), whisperExecutablePath: normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath, whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath, 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, 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} /> 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 ( , @@ -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; 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, 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]); } 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, 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, 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; +} 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", 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",