Make motion blur streakier and simplify its controls

This commit is contained in:
young committed 2026-08-31 13:17:55 +10:00
1 parent bb268188dc
commit 1e6be1295d
8 files changed
+69 -187

No files matched your search

@@ -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({
</div>
</div>
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings("effects.motionBlurDebug", "Motion Blur Debug")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{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.",
)}
</div>
</div>
<SliderControl
label={tSettings("effects.motionBlurPanThreshold", "Pan threshold")}
value={zoomMotionBlurTuning.panVelocityThreshold}
defaultValue={
initialEditorPreferences.zoomMotionBlurTuning
.panVelocityThreshold
}
min={0}
max={240}
step={1}
onChange={(value) =>
onZoomMotionBlurTuningChange?.({
...zoomMotionBlurTuning,
panVelocityThreshold: value,
})
}
formatValue={(value) => `${Math.round(value)} px/s`}
parseInput={(text) =>
parseFloat(text.replace(/px\/s$/i, "").trim())
}
/>
<SliderControl
label={tSettings("effects.motionBlurPanStrength", "Pan max blur")}
value={zoomMotionBlurTuning.maxDirectionalBlurPx}
defaultValue={
initialEditorPreferences.zoomMotionBlurTuning
.maxDirectionalBlurPx
}
min={0}
max={96}
step={0.1}
onChange={(value) =>
onZoomMotionBlurTuningChange?.({
...zoomMotionBlurTuning,
maxDirectionalBlurPx: value,
})
}
formatValue={(value) => `${value.toFixed(1)} px`}
parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())}
/>
<SliderControl
label={tSettings(
"effects.motionBlurZoomThreshold",
"Zoom threshold",
)}
value={zoomMotionBlurTuning.zoomVelocityThreshold}
defaultValue={
initialEditorPreferences.zoomMotionBlurTuning
.zoomVelocityThreshold
}
min={0}
max={0.4}
step={0.005}
onChange={(value) =>
onZoomMotionBlurTuningChange?.({
...zoomMotionBlurTuning,
zoomVelocityThreshold: value,
})
}
formatValue={(value) => value.toFixed(3)}
parseInput={(text) => parseFloat(text)}
/>
<SliderControl
label={tSettings(
"effects.motionBlurZoomStrength",
"Zoom blur strength",
)}
value={zoomMotionBlurTuning.maxRadialBlurStrength}
defaultValue={
initialEditorPreferences.zoomMotionBlurTuning
.maxRadialBlurStrength
}
min={0}
max={1.5}
step={0.005}
onChange={(value) =>
onZoomMotionBlurTuningChange?.({
...zoomMotionBlurTuning,
maxRadialBlurStrength: value,
})
}
formatValue={(value) => value.toFixed(3)}
parseInput={(text) => parseFloat(text)}
/>
</div>
<div className="space-y-1.5 rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
@@ -3061,19 +2937,6 @@ export function SettingsPanel({
)}
</div>
)}
{showDevMotionControls ? (
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
</div>
</div>
) : null}
{selectedZoomId && (
<Button
onClick={() => {
@@ -3412,17 +3275,6 @@ export function SettingsPanel({
formatValue={(v) => `${v.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings("effects.cursorMotionBlur")}
value={cursorMotionBlur}
defaultValue={DEFAULT_CURSOR_MOTION_BLUR}
min={0}
max={2}
step={0.05}
onChange={(v) => onCursorMotionBlurChange?.(v)}
formatValue={(v) => `${v.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<CursorClickEffectCards
title={tSettings(
"effects.cursorClickEffects.title",
@@ -6372,16 +6372,6 @@ export default function VideoEditor() {
onShadowChange={setShadowIntensity}
backgroundBlur={backgroundBlur}
onBackgroundBlurChange={setBackgroundBlur}
zoomMotionBlurTuning={zoomMotionBlurTuning}
onZoomMotionBlurTuningChange={setZoomMotionBlurTuning}
zoomTemporalMotionBlur={zoomTemporalMotionBlur}
onZoomTemporalMotionBlurChange={setZoomTemporalMotionBlur}
zoomMotionBlurSampleCount={zoomMotionBlurSampleCount}
onZoomMotionBlurSampleCountChange={setZoomMotionBlurSampleCount}
zoomMotionBlurShutterFraction={zoomMotionBlurShutterFraction}
onZoomMotionBlurShutterFractionChange={
setZoomMotionBlurShutterFraction
}
autoApplyFreshRecordingAutoZooms={autoApplyFreshRecordingAutoZooms}
onAutoApplyFreshRecordingAutoZoomsChange={
setAutoApplyFreshRecordingAutoZooms
@@ -6436,8 +6426,6 @@ export default function VideoEditor() {
onCameraSpringMassMultiplierChange={setCameraSpringMassMultiplier}
zoomClassicMode={zoomClassicMode}
onZoomClassicModeChange={setZoomClassicMode}
cursorMotionBlur={cursorMotionBlur}
onCursorMotionBlurChange={setCursorMotionBlur}
cursorClickEffect={cursorClickEffect}
cursorClickEffectColor={cursorClickEffectColor}
onCursorClickEffectChange={setCursorClickEffect}
+2 -2
View File
@@ -1,6 +1,7 @@
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import { DEFAULT_CURSOR_MOTION_BLUR } from "./types";
import {
fromFileUrl,
normalizeProjectEditor,
@@ -253,7 +254,6 @@ describe("Motion preset normalization", () => {
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);
});
@@ -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
) {
@@ -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<ProjectEditorState>): Pro
cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier)
? clamp(editor.cursorSpringMassMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
: DEFAULT_MOTION_PRESET.cursorMotionBlur,
cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce)
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5)
: DEFAULT_MOTION_PRESET.cursorClickBounce,
@@ -942,7 +940,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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<ProjectEditorState>).cursorSway)
@@ -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;
@@ -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);
});
});
@@ -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 {