From dd84edaf41f7f03515aa7a24c3a38a116f47b554 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 16 Mar 2026 11:17:09 +0100 Subject: [PATCH 1/3] feat: replace motion blur toggle with intensity slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motion blur was a boolean switch (on/off). This changes it to a slider from 0 (off) to 1 (full intensity), with 0.35 as the recommended sweet spot per feedback on PR #207. - EditorState/ProjectEditorState: motionBlurEnabled:bool → motionBlurAmount:number - SettingsPanel: Switch → Slider (0–1, step 0.01); shows 'off' or value - VideoPlayback/zoomTransform: scale blur by amount instead of boolean gate - FrameRenderer/VideoExporter/GifExporter: propagate numeric amount - projectPersistence: backward-compat loader (old true → 0.35, false → 0) --- src/components/video-editor/SettingsPanel.tsx | 31 ++++++++++++------- src/components/video-editor/VideoEditor.tsx | 24 +++++++------- src/components/video-editor/VideoPlayback.tsx | 15 +++++---- .../video-editor/projectPersistence.ts | 11 +++++-- src/hooks/useEditorHistory.ts | 4 +-- src/lib/exporter/frameRenderer.ts | 4 +-- src/lib/exporter/gifExporter.ts | 4 +-- src/lib/exporter/videoExporter.ts | 4 +-- 8 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 70c38c4c..92ddec4a 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -92,8 +92,8 @@ interface SettingsPanelProps { onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; - motionBlurEnabled?: boolean; - onMotionBlurChange?: (enabled: boolean) => void; + motionBlurAmount?: number; + onMotionBlurChange?: (amount: number) => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; onBorderRadiusCommit?: () => void; @@ -157,7 +157,7 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, - motionBlurEnabled = false, + motionBlurAmount = 0, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, @@ -574,14 +574,6 @@ export function SettingsPanel({
-
-
Motion Blur
- -
Blur BG
+
+
+
Motion Blur
+ + {motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)} + +
+ onMotionBlurChange?.(values[0])} + onValueCommit={(values) => onMotionBlurChange?.(values[0])} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
Shadow
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 46e49984..cae81a40 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -70,7 +70,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, aspectRatio, @@ -139,7 +139,7 @@ export default function VideoEditor() { wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, - motionBlurEnabled: normalizedEditor.motionBlurEnabled, + motionBlurAmount: normalizedEditor.motionBlurAmount, borderRadius: normalizedEditor.borderRadius, padding: normalizedEditor.padding, cropRegion: normalizedEditor.cropRegion, @@ -198,7 +198,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -220,7 +220,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -294,7 +294,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -347,7 +347,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -933,7 +933,7 @@ export default function VideoEditor() { showShadow: shadowIntensity > 0, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, videoPadding: padding, @@ -1060,7 +1060,7 @@ export default function VideoEditor() { showShadow: shadowIntensity > 0, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -1121,7 +1121,7 @@ export default function VideoEditor() { speedRegions, shadowIntensity, showBlur, - motionBlurEnabled, + motionBlurAmount, borderRadius, padding, cropRegion, @@ -1270,7 +1270,7 @@ export default function VideoEditor() { showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} showBlur={showBlur} - motionBlurEnabled={motionBlurEnabled} + motionBlurAmount={motionBlurAmount} borderRadius={borderRadius} padding={padding} cropRegion={cropRegion} @@ -1369,8 +1369,8 @@ export default function VideoEditor() { onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurEnabled={motionBlurEnabled} - onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })} + motionBlurAmount={motionBlurAmount} + onMotionBlurChange={(v) => pushState({ motionBlurAmount: v })} borderRadius={borderRadius} onBorderRadiusChange={(v) => updateState({ borderRadius: v })} onBorderRadiusCommit={commitState} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 7998e6df..877173d9 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -70,7 +70,7 @@ interface VideoPlaybackProps { showShadow?: boolean; shadowIntensity?: number; showBlur?: boolean; - motionBlurEnabled?: boolean; + motionBlurAmount?: number; borderRadius?: number; padding?: number; cropRegion?: import("./types").CropRegion; @@ -113,7 +113,7 @@ const VideoPlayback = forwardRef( showShadow, shadowIntensity = 0, showBlur, - motionBlurEnabled = false, + motionBlurAmount = 0, borderRadius = 0, padding = 50, cropRegion, @@ -128,7 +128,6 @@ const VideoPlayback = forwardRef( }, ref, ) => { - const ZOOM_MOTION_BLUR_AMOUNT = 0.35; const videoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -169,7 +168,7 @@ const VideoPlayback = forwardRef( const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); - const motionBlurEnabledRef = useRef(motionBlurEnabled); + const motionBlurAmountRef = useRef(motionBlurAmount); const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); @@ -400,8 +399,8 @@ const VideoPlayback = forwardRef( }, [speedRegions]); useEffect(() => { - motionBlurEnabledRef.current = motionBlurEnabled; - }, [motionBlurEnabled]); + motionBlurAmountRef.current = motionBlurAmount; + }, [motionBlurAmount]); useEffect(() => { onTimeUpdateRef.current = onTimeUpdate; @@ -475,7 +474,7 @@ const VideoPlayback = forwardRef( focusY: DEFAULT_FOCUS.cy, motionIntensity: 0, isPlaying: false, - motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0, + motionBlurAmount: motionBlurAmountRef.current, }); requestAnimationFrame(() => { @@ -739,7 +738,7 @@ const VideoPlayback = forwardRef( motionIntensity, motionVector, isPlaying: isPlayingRef.current, - motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0, + motionBlurAmount: motionBlurAmountRef.current, transformOverride: transform, motionBlurState: motionBlurStateRef.current, frameTimeMs: performance.now(), diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index a73a1a85..feecabee 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -28,7 +28,7 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; - motionBlurEnabled: boolean; + motionBlurAmount: number; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -302,8 +302,13 @@ export function normalizeProjectEditor(editor: Partial): Pro wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, - motionBlurEnabled: - typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false, + motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) + ? clamp(editor.motionBlurAmount, 0, 1) + : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" + ? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled + ? 0.35 + : 0 + : 0, borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0, padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, cropRegion: { diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index 38a1d4a6..5b43a134 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -20,7 +20,7 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; - motionBlurEnabled: boolean; + motionBlurAmount: number; borderRadius: number; padding: number; aspectRatio: AspectRatio; @@ -35,7 +35,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { wallpaper: "/wallpapers/wallpaper1.jpg", shadowIntensity: 0, showBlur: false, - motionBlurEnabled: false, + motionBlurAmount: 0, borderRadius: 0, padding: 50, aspectRatio: "16:9", diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index a4003efc..4ac04044 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -40,7 +40,7 @@ interface FrameRenderConfig { showShadow: boolean; shadowIntensity: number; showBlur: boolean; - motionBlurEnabled?: boolean; + motionBlurAmount?: number; borderRadius?: number; padding?: number; cropRegion: CropRegion; @@ -351,7 +351,7 @@ export class FrameRenderer { focusY: this.animationState.focusY, motionIntensity: maxMotionIntensity, isPlaying: true, - motionBlurAmount: this.config.motionBlurEnabled ? 0.35 : 0, + motionBlurAmount: this.config.motionBlurAmount ?? 0, motionBlurState: this.motionBlurState, frameTimeMs: timeMs, }); diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 36f5758e..8cf2478a 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -32,7 +32,7 @@ interface GifExporterConfig { showShadow: boolean; shadowIntensity: number; showBlur: boolean; - motionBlurEnabled?: boolean; + motionBlurAmount?: number; borderRadius?: number; padding?: number; videoPadding?: number; @@ -106,7 +106,7 @@ export class GifExporter { showShadow: this.config.showShadow, shadowIntensity: this.config.shadowIntensity, showBlur: this.config.showBlur, - motionBlurEnabled: this.config.motionBlurEnabled, + motionBlurAmount: this.config.motionBlurAmount, borderRadius: this.config.borderRadius, padding: this.config.padding, cropRegion: this.config.cropRegion, diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index ef010017..2560841a 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -20,7 +20,7 @@ interface VideoExporterConfig extends ExportConfig { showShadow: boolean; shadowIntensity: number; showBlur: boolean; - motionBlurEnabled?: boolean; + motionBlurAmount?: number; borderRadius?: number; padding?: number; videoPadding?: number; @@ -70,7 +70,7 @@ export class VideoExporter { showShadow: this.config.showShadow, shadowIntensity: this.config.shadowIntensity, showBlur: this.config.showBlur, - motionBlurEnabled: this.config.motionBlurEnabled, + motionBlurAmount: this.config.motionBlurAmount, borderRadius: this.config.borderRadius, padding: this.config.padding, cropRegion: this.config.cropRegion, From c35a33203baa047d6d6c2a7f5f3ed22ef7881cba Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 16 Mar 2026 12:40:08 +0100 Subject: [PATCH 2/3] fix: increase motion blur intensity range --- .../videoPlayback/zoomTransform.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 8fcf397f..61ced66a 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -1,9 +1,16 @@ import { BlurFilter, Container } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; -const PEAK_VELOCITY_PPS = 2000; -const MAX_BLUR_PX = 8; -const VELOCITY_THRESHOLD_PPS = 15; +const PEAK_VELOCITY_PPS = 1400; +const MAX_BLUR_PX = 14; +const VELOCITY_THRESHOLD_PPS = 12; +const MAX_AMOUNT_BOOST = 2.2; + +function getMotionBlurAmountResponse(motionBlurAmount: number) { + const clampedAmount = Math.min(1, Math.max(0, motionBlurAmount)); + // Keep the low end usable while giving the top of the slider substantially more headroom. + return clampedAmount * (1 + (MAX_AMOUNT_BOOST - 1) * clampedAmount); +} export interface MotionBlurState { lastFrameTimeMs: number; @@ -185,6 +192,7 @@ export function applyZoomTransform({ const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs)); const dtSeconds = dtMs / 1000; motionBlurState.lastFrameTimeMs = now; + const amountResponse = getMotionBlurAmountResponse(motionBlurAmount); // Camera displacement this frame (stage-px) const dx = transform.x - motionBlurState.prevCamX; @@ -204,17 +212,15 @@ export function applyZoomTransform({ const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS); const targetBlur = - speed < VELOCITY_THRESHOLD_PPS - ? 0 - : normalised * normalised * MAX_BLUR_PX * motionBlurAmount; + speed < VELOCITY_THRESHOLD_PPS ? 0 : normalised * normalised * MAX_BLUR_PX * amountResponse; const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1; - const velocityScale = targetBlur * 1.2; + const velocityScale = targetBlur * 2.4; motionBlurFilter.velocity = targetBlur > 0 ? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale } : { x: 0, y: 0 }; - motionBlurFilter.kernelSize = targetBlur > 4 ? 11 : targetBlur > 1.5 ? 9 : 5; + motionBlurFilter.kernelSize = targetBlur > 8 ? 15 : targetBlur > 4 ? 11 : 7; motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0; if (blurFilter) { From 446e3a35fc0cacaff15778e4f9fa15ffb70a5a5f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 16 Mar 2026 12:51:54 +0100 Subject: [PATCH 3/3] fix: avoid history checkpoint spam on motion blur drag --- src/components/video-editor/SettingsPanel.tsx | 4 +++- src/components/video-editor/VideoEditor.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 92ddec4a..ec589ab2 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -94,6 +94,7 @@ interface SettingsPanelProps { onBlurChange?: (showBlur: boolean) => void; motionBlurAmount?: number; onMotionBlurChange?: (amount: number) => void; + onMotionBlurCommit?: () => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; onBorderRadiusCommit?: () => void; @@ -159,6 +160,7 @@ export function SettingsPanel({ onBlurChange, motionBlurAmount = 0, onMotionBlurChange, + onMotionBlurCommit, borderRadius = 0, onBorderRadiusChange, onBorderRadiusCommit, @@ -595,7 +597,7 @@ export function SettingsPanel({ onMotionBlurChange?.(values[0])} - onValueCommit={(values) => onMotionBlurChange?.(values[0])} + onValueCommit={() => onMotionBlurCommit?.()} min={0} max={1} step={0.01} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cae81a40..b3bdb8da 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1370,7 +1370,8 @@ export default function VideoEditor() { showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} motionBlurAmount={motionBlurAmount} - onMotionBlurChange={(v) => pushState({ motionBlurAmount: v })} + onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} + onMotionBlurCommit={commitState} borderRadius={borderRadius} onBorderRadiusChange={(v) => updateState({ borderRadius: v })} onBorderRadiusCommit={commitState}