diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 81c934d5..5146aa1d 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -22,10 +22,6 @@ import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext";
import { getAssetPath, getRenderableVideoUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath";
-import {
- TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
- TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
-} from "@/lib/exporter/temporalMotionBlur";
import { cn } from "@/lib/utils";
import type { BuiltInWallpaper } from "@/lib/wallpapers";
import {
@@ -64,7 +60,6 @@ import type {
WebcamPositionPreset,
ZoomDepth,
ZoomMode,
- ZoomMotionBlurTuning,
ZoomTransitionEasing,
} from "./types";
import {
@@ -78,7 +73,6 @@ import {
DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS,
DEFAULT_CURSOR_CLICK_EFFECT_OPACITY,
DEFAULT_CURSOR_CLICK_EFFECT_SCALE,
- DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_STYLE,
DEFAULT_CURSOR_SWAY,
@@ -92,7 +86,6 @@ import {
DEFAULT_WEBCAM_SHADOW,
DEFAULT_WEBCAM_SIZE,
DEFAULT_ZOOM_IN_DURATION_MS,
- DEFAULT_ZOOM_MOTION_BLUR_TUNING,
DEFAULT_ZOOM_OUT_DURATION_MS,
} from "./types";
import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway";
@@ -548,14 +541,6 @@ interface SettingsPanelProps {
onShadowChange?: (intensity: number) => void;
backgroundBlur?: number;
onBackgroundBlurChange?: (amount: number) => void;
- zoomMotionBlurTuning?: ZoomMotionBlurTuning;
- onZoomMotionBlurTuningChange?: (tuning: ZoomMotionBlurTuning) => void;
- zoomTemporalMotionBlur?: number;
- onZoomTemporalMotionBlurChange?: (amount: number) => void;
- zoomMotionBlurSampleCount?: number | null;
- onZoomMotionBlurSampleCountChange?: (count: number | null) => void;
- zoomMotionBlurShutterFraction?: number | null;
- onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void;
connectZooms?: boolean;
onConnectZoomsChange?: (enabled: boolean) => void;
autoApplyFreshRecordingAutoZooms?: boolean;
@@ -600,8 +585,6 @@ interface SettingsPanelProps {
onCameraSpringMassMultiplierChange?: (multiplier: number) => void;
zoomClassicMode?: boolean;
onZoomClassicModeChange?: (enabled: boolean) => void;
- cursorMotionBlur?: number;
- onCursorMotionBlurChange?: (amount: number) => void;
cursorClickEffect?: CursorClickEffectStyle;
onCursorClickEffectChange?: (effect: CursorClickEffectStyle) => void;
cursorClickEffectColor?: string;
@@ -1009,8 +992,6 @@ export function SettingsPanel({
onShadowChange,
backgroundBlur = 0,
onBackgroundBlurChange,
- zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING,
- onZoomMotionBlurTuningChange,
connectZooms = true,
onConnectZoomsChange,
autoApplyFreshRecordingAutoZooms = true,
@@ -1043,8 +1024,6 @@ export function SettingsPanel({
onCameraSpringMassMultiplierChange,
zoomClassicMode = false,
onZoomClassicModeChange,
- cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
- onCursorMotionBlurChange,
cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT,
onCursorClickEffectChange,
cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR,
@@ -1546,7 +1525,6 @@ export function SettingsPanel({
};
const resetZoomSection = () => {
- onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning);
onCameraSpringStiffnessMultiplierChange?.(
initialEditorPreferences.cameraSpringStiffnessMultiplier,
);
@@ -1572,7 +1550,6 @@ export function SettingsPanel({
initialEditorPreferences.cursorSpringDampingMultiplier,
);
onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier);
- onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur);
onCursorClickEffectChange?.(initialEditorPreferences.cursorClickEffect);
onCursorClickEffectColorChange?.(initialEditorPreferences.cursorClickEffectColor);
onCursorClickEffectScaleChange?.(initialEditorPreferences.cursorClickEffectScale);
@@ -1593,7 +1570,6 @@ export function SettingsPanel({
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
- cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
}) ?? "focused"
@@ -1601,7 +1577,6 @@ export function SettingsPanel({
}, [
cursorClickBounce,
cursorClickBounceDuration,
- cursorMotionBlur,
cursorSize,
cursorSmoothing,
cursorSpringDampingMultiplier,
@@ -1620,7 +1595,6 @@ export function SettingsPanel({
onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier);
onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier);
onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier);
- onCursorMotionBlurChange?.(preset.cursorMotionBlur);
onCursorClickBounceChange?.(preset.cursorClickBounce);
onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration);
};
@@ -2719,104 +2693,6 @@ export function SettingsPanel({
-
-
-
- {tSettings("effects.motionBlurDebug", "Motion Blur Debug")}
-
-
- {tSettings(
- "effects.motionBlurDebugHint",
- "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.",
- )}
-
-
-
- onZoomMotionBlurTuningChange?.({
- ...zoomMotionBlurTuning,
- panVelocityThreshold: value,
- })
- }
- formatValue={(value) => `${Math.round(value)} px/s`}
- parseInput={(text) =>
- parseFloat(text.replace(/px\/s$/i, "").trim())
- }
- />
-
- onZoomMotionBlurTuningChange?.({
- ...zoomMotionBlurTuning,
- maxDirectionalBlurPx: value,
- })
- }
- formatValue={(value) => `${value.toFixed(1)} px`}
- parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())}
- />
-
- onZoomMotionBlurTuningChange?.({
- ...zoomMotionBlurTuning,
- zoomVelocityThreshold: value,
- })
- }
- formatValue={(value) => value.toFixed(3)}
- parseInput={(text) => parseFloat(text)}
- />
-
- onZoomMotionBlurTuningChange?.({
- ...zoomMotionBlurTuning,
- maxRadialBlurStrength: value,
- })
- }
- formatValue={(value) => value.toFixed(3)}
- parseInput={(text) => parseFloat(text)}
- />
-
-
@@ -3061,19 +2937,6 @@ export function SettingsPanel({
)}
)}
- {showDevMotionControls ? (
-
-
- {tSettings(
- "effects.exportBlurMovedToDev",
- "Export blur tuning is available in Settings > Dev.",
- )}
-
-
- {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
-
-
- ) : null}
{selectedZoomId && (
{
@@ -3412,17 +3275,6 @@ export function SettingsPanel({
formatValue={(v) => `${v.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
- onCursorMotionBlurChange?.(v)}
- formatValue={(v) => `${v.toFixed(2)}×`}
- parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
- />
{
cursorSpringStiffnessMultiplier: smooth.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: smooth.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: smooth.cursorSpringMassMultiplier,
- cursorMotionBlur: smooth.cursorMotionBlur,
cursorClickBounce: smooth.cursorClickBounce,
cursorClickBounceDuration: smooth.cursorClickBounceDuration,
} as any);
@@ -288,7 +288,7 @@ describe("Motion preset normalization", () => {
);
expect(result.cursorSpringDampingMultiplier).toBe(focused.cursorSpringDampingMultiplier);
expect(result.cursorSpringMassMultiplier).toBe(focused.cursorSpringMassMultiplier);
- expect(result.cursorMotionBlur).toBe(focused.cursorMotionBlur);
+ expect(result.cursorMotionBlur).toBe(DEFAULT_CURSOR_MOTION_BLUR);
expect(result.cursorClickBounce).toBe(focused.cursorClickBounce);
expect(result.cursorClickBounceDuration).toBe(focused.cursorClickBounceDuration);
});
diff --git a/src/components/video-editor/cursorMotionPresets.ts b/src/components/video-editor/cursorMotionPresets.ts
index 04e9745d..22f2dc67 100644
--- a/src/components/video-editor/cursorMotionPresets.ts
+++ b/src/components/video-editor/cursorMotionPresets.ts
@@ -1,8 +1,4 @@
-import {
- DEFAULT_CURSOR_MOTION_BLUR,
- DEFAULT_ZOOM_IN_DURATION_MS,
- DEFAULT_ZOOM_OUT_DURATION_MS,
-} from "./types";
+import { DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_OUT_DURATION_MS } from "./types";
export type CursorMotionPresetId = "focused" | "smooth";
@@ -17,7 +13,6 @@ export interface CursorMotionPreset {
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
- cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
}
@@ -30,7 +25,6 @@ export interface CursorMotionPresetSelectionInput {
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
- cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
}
@@ -39,7 +33,6 @@ const SHARED_CURSOR_PRESET_VALUES = {
cursorSize: 2.5,
cursorSmoothing: 0.67,
cursorSpringMassMultiplier: 1.29,
- cursorMotionBlur: DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce: 3.5,
cursorClickBounceDuration: 350,
} as const;
@@ -80,7 +73,6 @@ export function getMatchingCursorMotionPresetId(
preset.cursorSpringStiffnessMultiplier === values.cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === values.cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === values.cursorSpringMassMultiplier &&
- preset.cursorMotionBlur === values.cursorMotionBlur &&
preset.cursorClickBounce === values.cursorClickBounce &&
preset.cursorClickBounceDuration === values.cursorClickBounceDuration
) {
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 93c0c5a1..93292297 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -46,6 +46,7 @@ import {
DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS,
DEFAULT_CURSOR_CLICK_EFFECT_OPACITY,
DEFAULT_CURSOR_CLICK_EFFECT_SCALE,
+ DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_STYLE,
DEFAULT_CURSOR_SWAY,
DEFAULT_FIGURE_DATA,
@@ -851,9 +852,6 @@ export function normalizeProjectEditor(editor: Partial): Pro
cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier)
? clamp(editor.cursorSpringMassMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier,
- cursorMotionBlur: isFiniteNumber((editor as Partial).cursorMotionBlur)
- ? clamp((editor as Partial).cursorMotionBlur as number, 0, 2)
- : DEFAULT_MOTION_PRESET.cursorMotionBlur,
cursorClickBounce: isFiniteNumber((editor as Partial).cursorClickBounce)
? clamp((editor as Partial).cursorClickBounce as number, 0, 5)
: DEFAULT_MOTION_PRESET.cursorClickBounce,
@@ -942,7 +940,7 @@ export function normalizeProjectEditor(editor: Partial): Pro
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
- cursorMotionBlur: normalizedMotionPreset.cursorMotionBlur,
+ cursorMotionBlur: DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce: normalizedMotionPreset.cursorClickBounce,
cursorClickBounceDuration: normalizedMotionPreset.cursorClickBounceDuration,
cursorSway: isFiniteNumber((editor as Partial).cursorSway)
diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts
index 16538285..def88661 100644
--- a/src/components/video-editor/videoPlayback/cursorRenderer.ts
+++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts
@@ -114,7 +114,7 @@ export interface CursorRenderConfig {
const MIN_CURSOR_VIEWPORT_SCALE = 0;
// Keep the cursor visually responsive while sharing the camera's frame-rate
// normalization, kernel, and directional offset formula.
-const CURSOR_DIRECTIONAL_BLUR_STRENGTH = 3.2;
+const CURSOR_DIRECTIONAL_BLUR_STRENGTH = 2;
const CURSOR_TIME_DISCONTINUITY_MS = 100;
const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7;
const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18;
diff --git a/src/components/video-editor/videoPlayback/zoomTransform.test.ts b/src/components/video-editor/videoPlayback/zoomTransform.test.ts
index faf5f6e1..79333ec9 100644
--- a/src/components/video-editor/videoPlayback/zoomTransform.test.ts
+++ b/src/components/video-editor/videoPlayback/zoomTransform.test.ts
@@ -132,10 +132,53 @@ describe("applyZoomTransform motion blur routing", () => {
frameTimeMs: 1016,
});
- expect(Math.abs(zoomBlurFilter.strength)).toBeGreaterThan(0);
+ expect(zoomBlurFilter.strength).toBeGreaterThan(0);
expect(Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y)).toBe(0);
expect(zoomBlurFilter.center.x).toBeCloseTo(stageSize.width / 2, 0);
expect(zoomBlurFilter.center.y).toBeCloseTo(stageSize.height / 2, 0);
expect(zoomBlurFilter.radius).toBe(-1);
});
+
+ it("keeps a zoom on the radial path even when its center moves farther than its size", () => {
+ const motionBlurState = createMotionBlurState();
+ const motionBlurFilter = {
+ velocity: { x: 0, y: 0 },
+ kernelSize: 5,
+ offset: 0,
+ };
+ const zoomBlurFilter = {
+ strength: 0,
+ center: { x: 0, y: 0 },
+ innerRadius: 0,
+ radius: -1,
+ };
+ const sharedParams = {
+ cameraContainer: createStubContainer() as never,
+ zoomBlurFilter: zoomBlurFilter as never,
+ motionBlurFilter: motionBlurFilter as never,
+ stageSize: { width: 1280, height: 720 },
+ baseMask: { x: 80, y: 60, width: 1120, height: 600 },
+ zoomScale: 1,
+ focusX: 0.5,
+ focusY: 0.5,
+ isPlaying: true,
+ motionBlurAmount: 1,
+ motionBlurState,
+ };
+
+ applyZoomTransform({
+ ...sharedParams,
+ transformOverride: { scale: 1, x: 0, y: 0 },
+ frameTimeMs: 1000,
+ });
+ applyZoomTransform({
+ ...sharedParams,
+ transformOverride: { scale: 1.01, x: 120, y: 0 },
+ frameTimeMs: 1016,
+ });
+
+ expect(zoomBlurFilter.strength).toBeGreaterThan(0);
+ expect(Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y)).toBe(0);
+ expect(zoomBlurFilter.innerRadius).toBe(0);
+ });
});
diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts
index 26ff84a5..55ace06a 100644
--- a/src/components/video-editor/videoPlayback/zoomTransform.ts
+++ b/src/components/video-editor/videoPlayback/zoomTransform.ts
@@ -6,6 +6,10 @@ import { DEFAULT_ZOOM_MOTION_BLUR_TUNING, type ZoomMotionBlurTuning } from "../t
const MIN_DIRECTIONAL_BLUR_MAGNITUDE = 0.01;
const DIRECTIONAL_BLUR_KERNEL_SIZE = 13;
const DIRECTIONAL_BLUR_OFFSET_DIVISOR = 32;
+const SPATIAL_PAN_SHUTTER_MULTIPLIER = 4;
+// Spatial equivalent of a longer exposure. This extends the one-pass zoom
+// trail without multiplying scene renders like temporal accumulation does.
+const SPATIAL_ZOOM_SHUTTER_MULTIPLIER = 4;
export interface DirectionalMotionBlur {
velocity: { x: number; y: number };
@@ -285,6 +289,7 @@ function resolveZoomBlurStrength(
return (
motionBlurAmount *
fpsScale *
+ SPATIAL_ZOOM_SHUTTER_MULTIPLIER *
(motionBlurTuning.maxRadialBlurStrength /
DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxRadialBlurStrength)
);
@@ -305,6 +310,9 @@ function computeSizeDelta(previousQuad: TransformQuad, currentQuad: TransformQua
}
function computeZoomStrength(previousQuad: TransformQuad, currentQuad: TransformQuad) {
+ // Keep the spatial approximation sampling inward. Sampling outward during
+ // expansion can leave the filter's captured texture and produce transparent
+ // or black frames; true directionality requires temporal/overscanned input.
return Math.abs(1 - currentQuad.diagonal / Math.max(0.0001, previousQuad.diagonal));
}
@@ -325,22 +333,22 @@ function classifyMotionMode(
Math.max(0.0001, currentQuad.diagonal) / Math.max(0.0001, previousQuad.diagonal),
),
) / Math.max(0.0001, deltaSeconds);
- const moveActive = moveVelocity >= motionBlurTuning.panVelocityThreshold;
- const zoomActive = zoomVelocity >= motionBlurTuning.zoomVelocityThreshold;
+ const moveActive = moveDistance > 0.001 && moveVelocity > motionBlurTuning.panVelocityThreshold;
+ const zoomActive =
+ zoomDistance > 0.001 && zoomVelocity > motionBlurTuning.zoomVelocityThreshold;
if (!moveActive && !zoomActive) {
return null;
}
- if (moveActive && !zoomActive) {
- return "move";
- }
-
- if (zoomActive && !moveActive) {
+ // Any meaningful scale change is a zoom. The inferred zoom center already
+ // accounts for the translation caused by an off-center zoom, so routing it
+ // through pan blur would turn a straight radial zoom into a directional smear.
+ if (zoomActive) {
return "zoom";
}
- return zoomDistance > moveDistance ? "zoom" : "move";
+ return "move";
}
function createZeroPoint(): Point2D {
@@ -373,8 +381,9 @@ function analyzeCameraStep({
moveDelta,
motionBlurAmount,
deltaSeconds,
- motionBlurTuning.maxDirectionalBlurPx /
- DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx,
+ SPATIAL_PAN_SHUTTER_MULTIPLIER *
+ (motionBlurTuning.maxDirectionalBlurPx /
+ DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx),
);
return {