Polish timeline markers and normalize motion presets

This commit is contained in:
webadderall
2026-05-08 14:27:30 +10:00
parent 2380e56431
commit 2977fbcc9c
5 changed files with 168 additions and 55 deletions
+17 -16
View File
@@ -45,7 +45,11 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { AppLocale } from "../../i18n/config";
import { SUPPORTED_LOCALES } from "../../i18n/config";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId } from "./cursorMotionPresets";
import {
CURSOR_MOTION_PRESETS,
getMatchingCursorMotionPresetId,
type CursorMotionPresetId,
} from "./cursorMotionPresets";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { SliderControl } from "./SliderControl";
import { KeyboardShortcutsDialog } from "./TutorialHelp";
@@ -1515,21 +1519,18 @@ export function SettingsPanel({
const activeMotionPresetId = useMemo(() => {
return (
MOTION_PRESET_ORDER.find((presetId) => {
const preset = CURSOR_MOTION_PRESETS[presetId];
return (
preset.zoomInDurationMs === zoomInDurationMs &&
preset.zoomOutDurationMs === zoomOutDurationMs &&
preset.cursorSize === cursorSize &&
preset.cursorSmoothing === cursorSmoothing &&
preset.cursorSpringStiffnessMultiplier === cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === cursorSpringMassMultiplier &&
preset.cursorMotionBlur === cursorMotionBlur &&
preset.cursorClickBounce === cursorClickBounce &&
preset.cursorClickBounceDuration === cursorClickBounceDuration
);
}) ?? null
getMatchingCursorMotionPresetId({
zoomInDurationMs,
zoomOutDurationMs,
cursorSize,
cursorSmoothing,
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
}) ?? "focused"
);
}, [
cursorClickBounce,
+55
View File
@@ -1,5 +1,6 @@
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import { fromFileUrl, normalizeProjectEditor, toFileUrl } from "./projectPersistence";
describe("Audio path handling", () => {
@@ -235,3 +236,57 @@ describe("Audio region normalization", () => {
});
});
});
describe("Motion preset normalization", () => {
it("preserves the smooth preset when the saved values match it exactly", () => {
const smooth = CURSOR_MOTION_PRESETS.smooth;
const result = normalizeProjectEditor({
zoomInDurationMs: smooth.zoomInDurationMs,
zoomOutDurationMs: smooth.zoomOutDurationMs,
cursorSize: smooth.cursorSize,
cursorSmoothing: smooth.cursorSmoothing,
cursorSpringStiffnessMultiplier: smooth.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: smooth.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: smooth.cursorSpringMassMultiplier,
cursorMotionBlur: smooth.cursorMotionBlur,
cursorClickBounce: smooth.cursorClickBounce,
cursorClickBounceDuration: smooth.cursorClickBounceDuration,
} as any);
expect(result.zoomInDurationMs).toBe(smooth.zoomInDurationMs);
expect(result.zoomOutDurationMs).toBe(smooth.zoomOutDurationMs);
expect(result.cursorSpringStiffnessMultiplier).toBe(
smooth.cursorSpringStiffnessMultiplier,
);
expect(result.cursorSpringDampingMultiplier).toBe(smooth.cursorSpringDampingMultiplier);
});
it("falls back to focused when the saved values do not match a supported preset", () => {
const focused = CURSOR_MOTION_PRESETS.focused;
const result = normalizeProjectEditor({
zoomInDurationMs: 275,
zoomOutDurationMs: 410,
cursorSize: 3.25,
cursorSmoothing: 0.52,
cursorSpringStiffnessMultiplier: 1.1,
cursorSpringDampingMultiplier: 1.05,
cursorSpringMassMultiplier: 1.6,
cursorMotionBlur: 0.9,
cursorClickBounce: 2.25,
cursorClickBounceDuration: 280,
} as any);
expect(result.zoomInDurationMs).toBe(focused.zoomInDurationMs);
expect(result.zoomOutDurationMs).toBe(focused.zoomOutDurationMs);
expect(result.cursorSize).toBe(focused.cursorSize);
expect(result.cursorSmoothing).toBe(focused.cursorSmoothing);
expect(result.cursorSpringStiffnessMultiplier).toBe(
focused.cursorSpringStiffnessMultiplier,
);
expect(result.cursorSpringDampingMultiplier).toBe(focused.cursorSpringDampingMultiplier);
expect(result.cursorSpringMassMultiplier).toBe(focused.cursorSpringMassMultiplier);
expect(result.cursorMotionBlur).toBe(focused.cursorMotionBlur);
expect(result.cursorClickBounce).toBe(focused.cursorClickBounce);
expect(result.cursorClickBounceDuration).toBe(focused.cursorClickBounceDuration);
});
});
@@ -18,6 +18,19 @@ export interface CursorMotionPreset {
cursorClickBounceDuration: number;
}
export interface CursorMotionPresetSelectionInput {
zoomInDurationMs: number;
zoomOutDurationMs: number;
cursorSize: number;
cursorSmoothing: number;
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
}
const SHARED_CURSOR_PRESET_VALUES = {
cursorSize: 2.5,
cursorSmoothing: 0.67,
@@ -48,4 +61,35 @@ export const CURSOR_MOTION_PRESETS: Record<CursorMotionPresetId, CursorMotionPre
cursorSpringStiffnessMultiplier: 0.92,
cursorSpringDampingMultiplier: 1.36,
},
};
};
export function getMatchingCursorMotionPresetId(
values: CursorMotionPresetSelectionInput,
): CursorMotionPresetId | null {
for (const presetId of Object.keys(CURSOR_MOTION_PRESETS) as CursorMotionPresetId[]) {
const preset = CURSOR_MOTION_PRESETS[presetId];
if (
preset.zoomInDurationMs === values.zoomInDurationMs &&
preset.zoomOutDurationMs === values.zoomOutDurationMs &&
preset.cursorSize === values.cursorSize &&
preset.cursorSmoothing === values.cursorSmoothing &&
preset.cursorSpringStiffnessMultiplier === values.cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === values.cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === values.cursorSpringMassMultiplier &&
preset.cursorMotionBlur === values.cursorMotionBlur &&
preset.cursorClickBounce === values.cursorClickBounce &&
preset.cursorClickBounceDuration === values.cursorClickBounceDuration
) {
return presetId;
}
}
return null;
}
export function resolveCursorMotionPresetId(
values: CursorMotionPresetSelectionInput,
fallback: CursorMotionPresetId = "focused",
): CursorMotionPresetId {
return getMatchingCursorMotionPresetId(values) ?? fallback;
}
@@ -19,7 +19,7 @@ import {
} from "@/lib/exporter/temporalMotionBlur";
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
import {
type AnnotationRegion,
type AudioRegion,
@@ -797,31 +797,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? "tahoe-inverted"
: editor.cursorStyle
: DEFAULT_CURSOR_STYLE;
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomMotionBlurTuning: normalizedZoomMotionBlurTuning,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
const normalizedMotionValues = {
zoomInDurationMs: normalizedZoomInDurationMs,
zoomInOverlapMs: normalizedZoomInOverlapMs,
zoomOutDurationMs: normalizedZoomOutDurationMs,
connectedZoomGapMs: normalizedConnectedZoomGapMs,
connectedZoomDurationMs: normalizedConnectedZoomDurationMs,
zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING),
zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING),
connectedZoomEasing: normalizeZoomTransitionEasing(
editor.connectedZoomEasing,
DEFAULT_CONNECTED_ZOOM_EASING,
),
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorStyle: normalizedCursorStyle,
cursorSize: isFiniteNumber(editor.cursorSize)
? clamp(editor.cursorSize, 0.5, 10)
: DEFAULT_MOTION_PRESET.cursorSize,
@@ -837,18 +815,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier)
? clamp(editor.cursorSpringMassMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier)
? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3)
: 1,
cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier)
? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3)
: 1,
cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier)
? clamp(editor.cameraSpringMassMultiplier, 0.25, 3)
: 1,
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
: DEFAULT_MOTION_PRESET.cursorMotionBlur,
@@ -864,6 +830,54 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
500,
)
: DEFAULT_MOTION_PRESET.cursorClickBounceDuration,
};
const normalizedMotionPreset =
CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)];
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomMotionBlurTuning: normalizedZoomMotionBlurTuning,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
zoomInDurationMs: normalizedMotionPreset.zoomInDurationMs,
zoomInOverlapMs: normalizedZoomInOverlapMs,
zoomOutDurationMs: normalizedMotionPreset.zoomOutDurationMs,
connectedZoomGapMs: normalizedConnectedZoomGapMs,
connectedZoomDurationMs: normalizedConnectedZoomDurationMs,
zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING),
zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING),
connectedZoomEasing: normalizeZoomTransitionEasing(
editor.connectedZoomEasing,
DEFAULT_CONNECTED_ZOOM_EASING,
),
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorStyle: normalizedCursorStyle,
cursorSize: normalizedMotionPreset.cursorSize,
cursorSmoothing: normalizedMotionPreset.cursorSmoothing,
cursorSpringStiffnessMultiplier: normalizedMotionPreset.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: normalizedMotionPreset.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: normalizedMotionPreset.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier)
? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3)
: 1,
cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier)
? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3)
: 1,
cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier)
? clamp(editor.cameraSpringMassMultiplier, 0.25, 3)
: 1,
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: normalizedMotionPreset.cursorMotionBlur,
cursorClickBounce: normalizedMotionPreset.cursorClickBounce,
cursorClickBounceDuration: normalizedMotionPreset.cursorClickBounceDuration,
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
: DEFAULT_CURSOR_SWAY,
@@ -585,7 +585,7 @@ function ClipMarkerOverlay({ videoDurationMs }: { videoDurationMs: number }) {
bottom: "7.5%",
[sideProperty]: `${offset}px`,
background:
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.10) 35%, rgba(255,255,255,0.10) 65%, transparent 100%)",
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)",
}}
/>
))}
@@ -858,7 +858,6 @@ function Timeline({
onMouseMove={handleTimelineMouseMove}
onMouseLeave={handleTimelineMouseLeave}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground)/0.03)_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}