Merge pull request #436 from webadderallorg/codex/zoom-motion-blur-refine

feat/zoom motion blur refine
This commit is contained in:
webadderall authored and GitHub committed 2026-05-07 19:22:15 +10:00
commit cd95bfda23
16 files changed
+1205 -464

No files matched your search

+323 -150
View File
@@ -29,10 +29,6 @@ import {
import {
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
} from "@/lib/exporter/temporalMotionBlur";
import type { ExtensionSettingField } from "@/lib/extensions";
import { extensionHost, type FrameInstance } from "@/lib/extensions";
@@ -68,6 +64,7 @@ import type {
WebcamPositionPreset,
ZoomDepth,
ZoomMode,
ZoomMotionBlurTuning,
ZoomTransitionEasing,
} from "./types";
import {
@@ -89,6 +86,7 @@ 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";
@@ -478,6 +476,8 @@ 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;
@@ -520,6 +520,12 @@ interface SettingsPanelProps {
onCursorSpringDampingMultiplierChange?: (multiplier: number) => void;
cursorSpringMassMultiplier?: number;
onCursorSpringMassMultiplierChange?: (multiplier: number) => void;
cameraSpringStiffnessMultiplier?: number;
onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void;
cameraSpringDampingMultiplier?: number;
onCameraSpringDampingMultiplierChange?: (multiplier: number) => void;
cameraSpringMassMultiplier?: number;
onCameraSpringMassMultiplierChange?: (multiplier: number) => void;
zoomClassicMode?: boolean;
onZoomClassicModeChange?: (enabled: boolean) => void;
cursorMotionBlur?: number;
@@ -570,6 +576,8 @@ interface SettingsPanelProps {
onClearAutoCaptions?: () => void;
onDownloadWhisperSmallModel?: () => void;
onDeleteWhisperSmallModel?: () => void;
nativeCaptureUnavailableSession?: boolean;
onOpenNativeCaptureUnavailableModal?: () => void;
}
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
@@ -861,12 +869,8 @@ export function SettingsPanel({
onShadowChange,
backgroundBlur = 0,
onBackgroundBlurChange,
zoomTemporalMotionBlur = 0,
onZoomTemporalMotionBlurChange,
zoomMotionBlurSampleCount = TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
onZoomMotionBlurSampleCountChange,
zoomMotionBlurShutterFraction = TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
onZoomMotionBlurShutterFractionChange,
zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING,
onZoomMotionBlurTuningChange,
connectZooms = true,
onConnectZoomsChange,
autoApplyFreshRecordingAutoZooms = true,
@@ -891,6 +895,12 @@ export function SettingsPanel({
onCursorSpringDampingMultiplierChange,
cursorSpringMassMultiplier = 1,
onCursorSpringMassMultiplierChange,
cameraSpringStiffnessMultiplier = 1,
onCameraSpringStiffnessMultiplierChange,
cameraSpringDampingMultiplier = 1,
onCameraSpringDampingMultiplierChange,
cameraSpringMassMultiplier = 1,
onCameraSpringMassMultiplierChange,
zoomClassicMode = false,
onZoomClassicModeChange,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
@@ -939,6 +949,8 @@ export function SettingsPanel({
onClearAutoCaptions,
onDownloadWhisperSmallModel,
onDeleteWhisperSmallModel,
nativeCaptureUnavailableSession = false,
onOpenNativeCaptureUnavailableModal,
}: SettingsPanelProps) {
const tSettings = useScopedT("settings");
const { locale, setLocale, t } = useI18n();
@@ -1467,10 +1479,15 @@ export function SettingsPanel({
};
const resetZoomSection = () => {
onZoomTemporalMotionBlurChange?.(initialEditorPreferences.zoomTemporalMotionBlur);
onZoomMotionBlurSampleCountChange?.(initialEditorPreferences.zoomMotionBlurSampleCount);
onZoomMotionBlurShutterFractionChange?.(
initialEditorPreferences.zoomMotionBlurShutterFraction,
onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning);
onCameraSpringStiffnessMultiplierChange?.(
initialEditorPreferences.cameraSpringStiffnessMultiplier,
);
onCameraSpringDampingMultiplierChange?.(
initialEditorPreferences.cameraSpringDampingMultiplier,
);
onCameraSpringMassMultiplierChange?.(
initialEditorPreferences.cameraSpringMassMultiplier,
);
onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs);
onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs);
@@ -2590,6 +2607,282 @@ export function SettingsPanel({
triggerClassName="h-10 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground"
/>
</section>
{showDevMotionControls ? (
<section className="flex flex-col gap-2 rounded-xl border border-[#2563EB]/15 bg-[#2563EB]/5 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<SectionLabel>
{tSettings("effects.devSection", "Dev")}
</SectionLabel>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.devSectionHint",
"Temporary testing controls for native capture and motion tuning.",
)}
</div>
</div>
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]">
DEV
</span>
</div>
<div className="rounded-lg border border-foreground/10 bg-background/60 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings(
"effects.nativeCaptureWarningTester",
"Native capture warning",
)}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{nativeCaptureUnavailableSession
? tSettings(
"effects.nativeCaptureWarningTesterUnavailable",
"This project is currently marked as native capture unavailable.",
)
: tSettings(
"effects.nativeCaptureWarningTesterAvailable",
"This project is not marked as unsupported, but you can still open the modal for UI testing.",
)}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenNativeCaptureUnavailableModal?.()}
className="h-8 shrink-0 border-[#2563EB]/20 bg-[#2563EB]/10 text-[#2563EB] hover:bg-[#2563EB]/15"
>
{tSettings("effects.openNativeCaptureWarning", "Open warning")}
</Button>
</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={32}
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={0.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">
{tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.cameraDebugTuningHint",
"Development-only spring tuning controls for camera motion.",
)}
</div>
</div>
<SliderControl
label={tSettings(
"effects.cameraSpringStiffnessMultiplier",
"Camera stiffness",
)}
value={cameraSpringStiffnessMultiplier}
defaultValue={
initialEditorPreferences.cameraSpringStiffnessMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCameraSpringStiffnessMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cameraSpringDampingMultiplier",
"Camera damping",
)}
value={cameraSpringDampingMultiplier}
defaultValue={
initialEditorPreferences.cameraSpringDampingMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) => onCameraSpringDampingMultiplierChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cameraSpringMassMultiplier",
"Camera mass",
)}
value={cameraSpringMassMultiplier}
defaultValue={initialEditorPreferences.cameraSpringMassMultiplier}
min={0.25}
max={3}
step={0.01}
onChange={(value) => onCameraSpringMassMultiplierChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
</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.cursorDebugTuning", "Cursor Debug Tuning")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.cursorDebugTuningHint",
"Development-only spring tuning controls.",
)}
</div>
</div>
<SliderControl
label={tSettings(
"effects.cursorSpringStiffnessMultiplier",
"Spring stiffness",
)}
value={cursorSpringStiffnessMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringStiffnessMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringStiffnessMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringDampingMultiplier",
"Spring damping",
)}
value={cursorSpringDampingMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringDampingMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) => onCursorSpringDampingMultiplierChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringMassMultiplier",
"Spring mass",
)}
value={cursorSpringMassMultiplier}
defaultValue={initialEditorPreferences.cursorSpringMassMultiplier}
min={0.25}
max={3}
step={0.01}
onChange={(value) => onCursorSpringMassMultiplierChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
</div>
</section>
) : null}
</div>
);
@@ -2710,80 +3003,22 @@ export function SettingsPanel({
)}
</div>
)}
{showDevMotionControls ? (
<div className="space-y-1.5 rounded-lg border border-[#2563EB]/15 bg-[#2563EB]/5 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings("effects.exportBlurDebug", "Export Blur Debug")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.exportBlurDebugHint",
"Development-only temporal blur tuning for export and preview parity checks.",
)}
</div>
</div>
<SliderControl
label={tSettings("effects.zoomTemporalMotionBlur", "Temporal blur")}
value={zoomTemporalMotionBlur}
defaultValue={initialEditorPreferences.zoomTemporalMotionBlur}
min={0}
max={2}
step={0.05}
onChange={(value) => onZoomTemporalMotionBlurChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings("effects.zoomMotionBlurSampleCount", "Sample count")}
value={
zoomMotionBlurSampleCount ??
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
}
defaultValue={
initialEditorPreferences.zoomMotionBlurSampleCount ??
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
}
min={TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT}
max={TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT}
step={2}
onChange={(value) =>
onZoomMotionBlurSampleCountChange?.(Math.round(value))
}
formatValue={(value) => `${Math.round(value)} samples`}
parseInput={(text) => parseFloat(text.replace(/samples?$/i, "").trim())}
/>
<SliderControl
label={tSettings("effects.zoomMotionBlurShutterFraction", "Shutter")}
value={
zoomMotionBlurShutterFraction ??
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION
}
defaultValue={
initialEditorPreferences.zoomMotionBlurShutterFraction ??
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION
}
min={TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION}
max={TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION}
step={0.01}
onChange={(value) => onZoomMotionBlurShutterFractionChange?.(value)}
formatValue={(value) => `${Math.round(value * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
</div>
) : (
<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.exportBlurLocked",
"Export blur is fixed for this build.",
)}
{showDevMotionControls
? tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)
: tSettings(
"effects.exportBlurLocked",
"Export blur is fixed for this build.",
)}
</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>
)}
</div>
{selectedZoomId && (
<Button
onClick={() => {
@@ -3028,75 +3263,13 @@ export function SettingsPanel({
}}
/>
{showDevMotionControls ? (
<div className="space-y-1.5 rounded-lg border border-[#2563EB]/15 bg-[#2563EB]/5 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings(
"effects.cursorDebugTuning",
"Cursor Debug Tuning",
)}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.cursorDebugTuningHint",
"Development-only spring tuning controls.",
)}
</div>
<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.cursorDebugMovedToDev",
"Cursor spring tuning is available in Settings > Dev.",
)}
</div>
<SliderControl
label={tSettings(
"effects.cursorSpringStiffnessMultiplier",
"Spring stiffness",
)}
value={cursorSpringStiffnessMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringStiffnessMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringStiffnessMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringDampingMultiplier",
"Spring damping",
)}
value={cursorSpringDampingMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringDampingMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringDampingMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringMassMultiplier",
"Spring mass",
)}
value={cursorSpringMassMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringMassMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringMassMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
</div>
) : null}
</div>
+180 -25
View File
@@ -33,6 +33,14 @@ import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -164,6 +172,7 @@ import {
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_MOTION_BLUR_TUNING,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
type EditorEffectSection,
@@ -180,6 +189,7 @@ import {
type ZoomDepth,
type ZoomFocus,
type ZoomMode,
type ZoomMotionBlurTuning,
type ZoomRegion,
type ZoomTransitionEasing,
} from "./types";
@@ -597,6 +607,9 @@ export default function VideoEditor() {
);
const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur);
const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur);
const [zoomMotionBlurTuning, setZoomMotionBlurTuning] = useState<ZoomMotionBlurTuning>(
initialEditorPreferences.zoomMotionBlurTuning ?? DEFAULT_ZOOM_MOTION_BLUR_TUNING,
);
const [zoomTemporalMotionBlur, setZoomTemporalMotionBlur] = useState(
initialEditorPreferences.zoomTemporalMotionBlur,
);
@@ -652,9 +665,21 @@ export default function VideoEditor() {
const [cursorSpringMassMultiplier, setCursorSpringMassMultiplier] = useState(
initialEditorPreferences.cursorSpringMassMultiplier,
);
const [cameraSpringStiffnessMultiplier, setCameraSpringStiffnessMultiplier] = useState(
initialEditorPreferences.cameraSpringStiffnessMultiplier,
);
const [cameraSpringDampingMultiplier, setCameraSpringDampingMultiplier] = useState(
initialEditorPreferences.cameraSpringDampingMultiplier,
);
const [cameraSpringMassMultiplier, setCameraSpringMassMultiplier] = useState(
initialEditorPreferences.cameraSpringMassMultiplier,
);
const [sessionShowCursorOverride, setSessionShowCursorOverride] = useState<boolean | null>(
null,
);
const [sessionNativeCaptureUnavailable, setSessionNativeCaptureUnavailable] = useState(false);
const [nativeCaptureUnavailableModalOpen, setNativeCaptureUnavailableModalOpen] =
useState(false);
const [zoomSmoothness, setZoomSmoothness] = useState(0.5);
const [zoomClassicMode, setZoomClassicMode] = useState(false);
const [cursorMotionBlur, setCursorMotionBlur] = useState(
@@ -718,6 +743,22 @@ export default function VideoEditor() {
const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState<string[]>([]);
const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] =
useState<Record<string, number>>({});
const applySessionPresentation = useCallback(
(
session:
| {
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
}
| null
| undefined,
) => {
setSessionShowCursorOverride(session?.hideOverlayCursorByDefault ? false : null);
setSessionNativeCaptureUnavailable(Boolean(session?.nativeCaptureUnavailable));
setNativeCaptureUnavailableModalOpen(Boolean(session?.nativeCaptureUnavailable));
},
[],
);
const effectiveShowCursor = sessionShowCursorOverride ?? showCursor;
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
initialEditorPreferences.aspectRatio,
@@ -838,6 +879,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning: { ...zoomMotionBlurTuning },
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -858,6 +900,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -885,6 +930,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -905,6 +951,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -973,6 +1022,7 @@ export default function VideoEditor() {
setShadowIntensity(snapshot.shadowIntensity);
setBackgroundBlur(snapshot.backgroundBlur);
setZoomMotionBlur(snapshot.zoomMotionBlur);
setZoomMotionBlurTuning({ ...snapshot.zoomMotionBlurTuning });
setZoomTemporalMotionBlur(snapshot.zoomTemporalMotionBlur);
setZoomMotionBlurSampleCount(snapshot.zoomMotionBlurSampleCount);
setZoomMotionBlurShutterFraction(snapshot.zoomMotionBlurShutterFraction);
@@ -993,6 +1043,9 @@ export default function VideoEditor() {
setCursorSpringStiffnessMultiplier(snapshot.cursorSpringStiffnessMultiplier);
setCursorSpringDampingMultiplier(snapshot.cursorSpringDampingMultiplier);
setCursorSpringMassMultiplier(snapshot.cursorSpringMassMultiplier);
setCameraSpringStiffnessMultiplier(snapshot.cameraSpringStiffnessMultiplier);
setCameraSpringDampingMultiplier(snapshot.cameraSpringDampingMultiplier);
setCameraSpringMassMultiplier(snapshot.cameraSpringMassMultiplier);
setCursorMotionBlur(snapshot.cursorMotionBlur);
setCursorClickBounce(snapshot.cursorClickBounce);
setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
@@ -1204,6 +1257,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -1259,6 +1313,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -1337,6 +1394,9 @@ export default function VideoEditor() {
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorSpringStiffnessMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
cursorStyle,
cursorSway,
@@ -1353,6 +1413,7 @@ export default function VideoEditor() {
zoomInEasing,
zoomInOverlapMs,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -1652,15 +1713,16 @@ export default function VideoEditor() {
}, [activeEffectSection]);
const buildPersistedEditorState = useCallback(
(
editor: Partial<{
wallpaper: string;
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
(
editor: Partial<{
wallpaper: string;
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomMotionBlurTuning: ZoomMotionBlurTuning;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
connectZooms: boolean;
zoomInDurationMs: number;
zoomInOverlapMs: number;
@@ -1678,6 +1740,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cameraSpringStiffnessMultiplier: number;
cameraSpringDampingMultiplier: number;
cameraSpringMassMultiplier: number;
zoomSmoothness: number;
zoomClassicMode: boolean;
cursorMotionBlur: number;
@@ -1812,6 +1877,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -1832,6 +1898,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -1868,6 +1937,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -1888,6 +1958,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -2045,16 +2118,19 @@ export default function VideoEditor() {
preserveProjectPath: Boolean(path),
},
);
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
}
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
}
const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
applySessionPresentation(sessionResult?.success ? sessionResult.session : null);
setWallpaper(normalizedEditor.wallpaper);
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
setBackgroundBlur(normalizedEditor.backgroundBlur);
setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
setZoomMotionBlurTuning({ ...normalizedEditor.zoomMotionBlurTuning });
setZoomTemporalMotionBlur(normalizedEditor.zoomTemporalMotionBlur);
setZoomMotionBlurSampleCount(normalizedEditor.zoomMotionBlurSampleCount);
setZoomMotionBlurShutterFraction(normalizedEditor.zoomMotionBlurShutterFraction);
@@ -2064,11 +2140,10 @@ export default function VideoEditor() {
setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs);
setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs);
setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs);
setZoomInEasing(normalizedEditor.zoomInEasing);
setZoomOutEasing(normalizedEditor.zoomOutEasing);
setConnectedZoomEasing(normalizedEditor.connectedZoomEasing);
setSessionShowCursorOverride(null);
setShowCursor(normalizedEditor.showCursor);
setZoomInEasing(normalizedEditor.zoomInEasing);
setZoomOutEasing(normalizedEditor.zoomOutEasing);
setConnectedZoomEasing(normalizedEditor.connectedZoomEasing);
setShowCursor(normalizedEditor.showCursor);
setLoopCursor(normalizedEditor.loopCursor);
setCursorStyle(normalizedEditor.cursorStyle);
setCursorSize(normalizedEditor.cursorSize);
@@ -2076,6 +2151,9 @@ export default function VideoEditor() {
setCursorSpringStiffnessMultiplier(normalizedEditor.cursorSpringStiffnessMultiplier);
setCursorSpringDampingMultiplier(normalizedEditor.cursorSpringDampingMultiplier);
setCursorSpringMassMultiplier(normalizedEditor.cursorSpringMassMultiplier);
setCameraSpringStiffnessMultiplier(normalizedEditor.cameraSpringStiffnessMultiplier);
setCameraSpringDampingMultiplier(normalizedEditor.cameraSpringDampingMultiplier);
setCameraSpringMassMultiplier(normalizedEditor.cameraSpringMassMultiplier);
setZoomSmoothness(normalizedEditor.zoomSmoothness);
setZoomClassicMode(normalizedEditor.zoomClassicMode);
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
@@ -2388,9 +2466,7 @@ export default function VideoEditor() {
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
setSessionShowCursorOverride(
sessionResult.session.hideOverlayCursorByDefault ? false : null,
);
applySessionPresentation(sessionResult.session);
setWebcam((prev) => ({
...prev,
enabled: Boolean(sessionResult.session?.webcamPath),
@@ -2410,7 +2486,7 @@ export default function VideoEditor() {
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
pendingFreshRecordingAutoZoomPathRef.current = null;
setSessionShowCursorOverride(null);
applySessionPresentation(null);
setWebcam((prev) => ({
...prev,
enabled: false,
@@ -2430,6 +2506,7 @@ export default function VideoEditor() {
loadInitialData();
}, [
applyLoadedProject,
applySessionPresentation,
autoApplyFreshRecordingAutoZooms,
initialEditorPreferences,
smokeExportConfig.enabled,
@@ -2466,6 +2543,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -2487,6 +2565,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -2513,6 +2594,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -2534,6 +2616,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -3780,7 +3865,7 @@ export default function VideoEditor() {
),
);
},
[],
[applySessionPresentation],
);
const handleAnnotationDelete = useCallback(
@@ -4360,6 +4445,7 @@ export default function VideoEditor() {
shadowIntensity: effectiveShadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -4392,6 +4478,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -4539,6 +4628,7 @@ export default function VideoEditor() {
shadowIntensity: effectiveShadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -4570,6 +4660,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -4793,6 +4886,7 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomMotionBlurTuning,
zoomTemporalMotionBlur,
zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction,
@@ -4813,6 +4907,9 @@ export default function VideoEditor() {
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier,
cameraSpringMassMultiplier,
zoomSmoothness,
zoomClassicMode,
cursorMotionBlur,
@@ -5177,12 +5274,41 @@ export default function VideoEditor() {
}}
/>
);
const nativeCaptureUnavailableDialog = (
<Dialog
open={nativeCaptureUnavailableModalOpen}
onOpenChange={setNativeCaptureUnavailableModalOpen}
>
<DialogContent className="max-w-md bg-editor-dialog border-foreground/10 text-foreground">
<DialogHeader>
<DialogTitle>
{t(
"editor.nativeCaptureUnavailable.title",
"Nothing’s broken, but we won’t be able to render an animated cursor overlay.",
)}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t(
"editor.nativeCaptureUnavailable.description",
"Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setNativeCaptureUnavailableModalOpen(false)}>
{t("editor.nativeCaptureUnavailable.confirm", "Okay")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-foreground">Loading video...</div>
{projectBrowser}
{nativeCaptureUnavailableDialog}
<Toaster className="pointer-events-auto" />
</div>
);
@@ -5202,6 +5328,7 @@ export default function VideoEditor() {
</button>
</div>
{projectBrowser}
{nativeCaptureUnavailableDialog}
<Toaster className="pointer-events-auto" />
</div>
);
@@ -5790,6 +5917,8 @@ export default function VideoEditor() {
onShadowChange={setShadowIntensity}
backgroundBlur={backgroundBlur}
onBackgroundBlurChange={setBackgroundBlur}
zoomMotionBlurTuning={zoomMotionBlurTuning}
onZoomMotionBlurTuningChange={setZoomMotionBlurTuning}
zoomTemporalMotionBlur={zoomTemporalMotionBlur}
onZoomTemporalMotionBlurChange={setZoomTemporalMotionBlur}
zoomMotionBlurSampleCount={zoomMotionBlurSampleCount}
@@ -5840,6 +5969,16 @@ export default function VideoEditor() {
}
cursorSpringMassMultiplier={cursorSpringMassMultiplier}
onCursorSpringMassMultiplierChange={setCursorSpringMassMultiplier}
cameraSpringStiffnessMultiplier={cameraSpringStiffnessMultiplier}
onCameraSpringStiffnessMultiplierChange={
setCameraSpringStiffnessMultiplier
}
cameraSpringDampingMultiplier={cameraSpringDampingMultiplier}
onCameraSpringDampingMultiplierChange={
setCameraSpringDampingMultiplier
}
cameraSpringMassMultiplier={cameraSpringMassMultiplier}
onCameraSpringMassMultiplierChange={setCameraSpringMassMultiplier}
zoomClassicMode={zoomClassicMode}
onZoomClassicModeChange={setZoomClassicMode}
cursorMotionBlur={cursorMotionBlur}
@@ -5883,6 +6022,10 @@ export default function VideoEditor() {
onClearAutoCaptions={handleClearAutoCaptions}
onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel}
onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel}
nativeCaptureUnavailableSession={sessionNativeCaptureUnavailable}
onOpenNativeCaptureUnavailableModal={() =>
setNativeCaptureUnavailableModalOpen(true)
}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
@@ -6046,8 +6189,19 @@ export default function VideoEditor() {
cursorSpringMassMultiplier={
cursorSpringMassMultiplier
}
cameraSpringStiffnessMultiplier={
cameraSpringStiffnessMultiplier
}
cameraSpringDampingMultiplier={
cameraSpringDampingMultiplier
}
cameraSpringMassMultiplier={
cameraSpringMassMultiplier
}
zoomSmoothness={zoomSmoothness}
zoomClassicMode={zoomClassicMode}
zoomMotionBlur={zoomMotionBlur}
zoomMotionBlurTuning={zoomMotionBlurTuning}
cursorMotionBlur={cursorMotionBlur}
cursorClickBounce={cursorClickBounce}
cursorClickBounceDuration={
@@ -6374,6 +6528,7 @@ export default function VideoEditor() {
) : null}
{projectBrowser}
{nativeCaptureUnavailableDialog}
<Toaster className="pointer-events-auto" />
</div>
+67 -28
View File
@@ -40,6 +40,7 @@ import {
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomMotionBlurTuning,
type ZoomRegion,
type ZoomTransitionEasing,
} from "./types";
@@ -102,6 +103,8 @@ import {
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_MOTION_BLUR,
DEFAULT_ZOOM_MOTION_BLUR_TUNING,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
getDefaultCaptionFontFamily,
@@ -262,8 +265,13 @@ interface VideoPlaybackProps {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -334,8 +342,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorSpringStiffnessMultiplier = 1,
cursorSpringDampingMultiplier = 1,
cursorSpringMassMultiplier = 1,
cameraSpringStiffnessMultiplier = 1,
cameraSpringDampingMultiplier = 1,
cameraSpringMassMultiplier = 1,
zoomSmoothness = 0.5,
zoomClassicMode = false,
zoomMotionBlur = DEFAULT_ZOOM_MOTION_BLUR,
zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
@@ -426,10 +439,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cursorSpringStiffnessMultiplierRef = useRef(cursorSpringStiffnessMultiplier);
const cursorSpringDampingMultiplierRef = useRef(cursorSpringDampingMultiplier);
const cursorSpringMassMultiplierRef = useRef(cursorSpringMassMultiplier);
const cameraSpringStiffnessMultiplierRef = useRef(cameraSpringStiffnessMultiplier);
const cameraSpringDampingMultiplierRef = useRef(cameraSpringDampingMultiplier);
const cameraSpringMassMultiplierRef = useRef(cameraSpringMassMultiplier);
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
const cursorSwayRef = useRef(cursorSway);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const zoomMotionBlurTuningRef = useRef(zoomMotionBlurTuning);
const lastEmittedClickTimeMsRef = useRef(-1);
// Spring animation state for smooth zoom transitions
@@ -665,7 +683,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const zoomBlurFilter = zoomBlurFilterRef.current;
const motionBlurFilter = motionBlurFilterRef.current;
if (!app || !videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) {
if (!app || !videoEffectsContainer || !motionBlurFilter || !zoomBlurFilter) {
return;
}
@@ -676,8 +694,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const stageWidth = Math.max(1, stageSizeRef.current.width || app.screen.width);
const stageHeight = Math.max(1, stageSizeRef.current.height || app.screen.height);
zoomBlurFilter.resolution = filterResolution;
motionBlurFilter.resolution = filterResolution;
zoomBlurFilter.resolution = filterResolution;
videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight);
}, []);
@@ -1158,11 +1176,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const zoomBlurFilter = zoomBlurFilterRef.current;
const motionBlurFilter = motionBlurFilterRef.current;
if (!videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) {
if (!videoEffectsContainer || !motionBlurFilter || !zoomBlurFilter) {
return;
}
videoEffectsContainer.filters = null;
videoEffectsContainer.filters =
(zoomMotionBlurRef.current ?? 0) > 0 ? [motionBlurFilter, zoomBlurFilter] : null;
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.offset = 0;
@@ -1250,10 +1269,42 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorSpringMassMultiplierRef.current = cursorSpringMassMultiplier;
}, [cursorSpringMassMultiplier]);
useEffect(() => {
cameraSpringStiffnessMultiplierRef.current = cameraSpringStiffnessMultiplier;
}, [cameraSpringStiffnessMultiplier]);
useEffect(() => {
cameraSpringDampingMultiplierRef.current = cameraSpringDampingMultiplier;
}, [cameraSpringDampingMultiplier]);
useEffect(() => {
cameraSpringMassMultiplierRef.current = cameraSpringMassMultiplier;
}, [cameraSpringMassMultiplier]);
useEffect(() => {
zoomSmoothnessRef.current = zoomSmoothness;
}, [zoomSmoothness]);
useEffect(() => {
zoomMotionBlurRef.current = zoomMotionBlur;
const videoEffectsContainer = videoEffectsContainerRef.current;
const zoomBlurFilter = zoomBlurFilterRef.current;
const motionBlurFilter = motionBlurFilterRef.current;
if (!videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) {
return;
}
motionBlurStateRef.current = createMotionBlurState();
videoEffectsContainer.filters =
zoomMotionBlur > 0 ? [motionBlurFilter, zoomBlurFilter] : null;
}, [videoPath, zoomMotionBlur]);
useEffect(() => {
zoomMotionBlurTuningRef.current = zoomMotionBlurTuning;
}, [zoomMotionBlurTuning]);
useEffect(() => {
zoomClassicModeRef.current = zoomClassicMode;
}, [zoomClassicMode]);
@@ -1335,7 +1386,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
zoomScale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurAmount: 0,
motionBlurState: motionBlurStateRef.current,
@@ -1531,8 +1581,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
// same layer in preview and export.
const videoEffectsContainer = new Container();
videoEffectsContainerRef.current = videoEffectsContainer;
zoomBlurFilterRef.current = new ZoomBlurFilter({ strength: 0 });
zoomBlurFilterRef.current = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 });
motionBlurFilterRef.current = new MotionBlurFilter([0, 0], 5, 0);
videoEffectsContainer.filters = [
motionBlurFilterRef.current,
zoomBlurFilterRef.current,
];
cameraContainer.addChild(videoEffectsContainer);
syncPreviewMotionBlurQuality();
@@ -1726,8 +1780,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const applyTransform = (
transform: { scale: number; x: number; y: number },
focus: ZoomFocus,
motionIntensity: number,
motionVector: { x: number; y: number },
) => {
const cameraContainer = cameraContainerRef.current;
if (!cameraContainer) return;
@@ -1744,10 +1796,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
zoomProgress: state.progress,
focusX: focus.cx,
focusY: focus.cy,
motionIntensity,
motionVector,
isPlaying: isPlayingRef.current,
motionBlurAmount: 0,
motionBlurAmount: zoomMotionBlurRef.current,
motionBlurTuning: zoomMotionBlurTuningRef.current,
transformOverride: transform,
motionBlurState: motionBlurStateRef.current,
frameTimeMs: performance.now(),
@@ -1848,9 +1899,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
const state = animationStateRef.current;
const prevScale = state.appliedScale;
const prevX = state.x;
const prevY = state.y;
state.scale = targetScaleFactor;
state.focusX = targetFocus.cx;
@@ -1880,7 +1928,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lastTickTimeRef.current !== null ? now - lastTickTimeRef.current : 1000 / 60;
lastTickTimeRef.current = now;
const zoomSpringConfig = getZoomSpringConfig(zoomSmoothnessRef.current);
const zoomSpringConfig = getZoomSpringConfig(zoomSmoothnessRef.current, {
stiffnessMultiplier: cameraSpringStiffnessMultiplierRef.current,
dampingMultiplier: cameraSpringDampingMultiplierRef.current,
massMultiplier: cameraSpringMassMultiplierRef.current,
});
const useSpring =
isPlayingRef.current && !isSeekingRef.current && !zoomClassicModeRef.current;
@@ -1917,22 +1969,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
resetSpringState(springYRef.current, appliedY);
}
const motionIntensity = Math.max(
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height),
);
const motionVector = {
x: appliedX - prevX,
y: appliedY - prevY,
};
applyTransform(
{ scale: appliedScale, x: appliedX, y: appliedY },
targetFocus,
motionIntensity,
motionVector,
);
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
@@ -12,6 +12,7 @@ type PersistedEditorControls = Pick<
| "shadowIntensity"
| "backgroundBlur"
| "zoomMotionBlur"
| "zoomMotionBlurTuning"
| "zoomTemporalMotionBlur"
| "zoomMotionBlurSampleCount"
| "zoomMotionBlurShutterFraction"
@@ -32,6 +33,9 @@ type PersistedEditorControls = Pick<
| "cursorSpringStiffnessMultiplier"
| "cursorSpringDampingMultiplier"
| "cursorSpringMassMultiplier"
| "cameraSpringStiffnessMultiplier"
| "cameraSpringDampingMultiplier"
| "cameraSpringMassMultiplier"
| "cursorMotionBlur"
| "cursorClickBounce"
| "cursorClickBounceDuration"
@@ -89,6 +93,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning,
zoomTemporalMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurShutterFraction,
@@ -109,6 +114,9 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier,
cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration,
@@ -264,6 +272,7 @@ function normalizeEditorControls(
shadowIntensity: raw.shadowIntensity ?? fallback.shadowIntensity,
backgroundBlur: raw.backgroundBlur ?? fallback.backgroundBlur,
zoomMotionBlur: raw.zoomMotionBlur ?? fallback.zoomMotionBlur,
zoomMotionBlurTuning: raw.zoomMotionBlurTuning ?? fallback.zoomMotionBlurTuning,
zoomTemporalMotionBlur: raw.zoomTemporalMotionBlur ?? fallback.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount:
raw.zoomMotionBlurSampleCount ?? fallback.zoomMotionBlurSampleCount,
@@ -289,6 +298,12 @@ function normalizeEditorControls(
raw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier:
raw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier:
raw.cameraSpringStiffnessMultiplier ?? fallback.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier:
raw.cameraSpringDampingMultiplier ?? fallback.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier:
raw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier,
cursorMotionBlur: raw.cursorMotionBlur ?? fallback.cursorMotionBlur,
cursorClickBounce: raw.cursorClickBounce ?? fallback.cursorClickBounce,
cursorClickBounceDuration:
@@ -326,6 +341,7 @@ function normalizeEditorControls(
shadowIntensity: normalized.shadowIntensity,
backgroundBlur: normalized.backgroundBlur,
zoomMotionBlur: normalized.zoomMotionBlur,
zoomMotionBlurTuning: normalized.zoomMotionBlurTuning,
zoomTemporalMotionBlur: normalized.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalized.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalized.zoomMotionBlurShutterFraction,
@@ -346,6 +362,9 @@ function normalizeEditorControls(
cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: normalized.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier,
cursorMotionBlur: normalized.cursorMotionBlur,
cursorClickBounce: normalized.cursorClickBounce,
cursorClickBounceDuration: normalized.cursorClickBounceDuration,
@@ -57,6 +57,7 @@ import {
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_MOTION_BLUR,
DEFAULT_ZOOM_MOTION_BLUR_TUNING,
DEFAULT_ZOOM_OUT_EASING,
DEFAULT_ZOOM_SMOOTHNESS,
getDefaultCaptionFontFamily,
@@ -64,6 +65,7 @@ import {
type SpeedRegion,
type TrimRegion,
type WebcamOverlaySettings,
type ZoomMotionBlurTuning,
type ZoomRegion,
type ZoomTransitionEasing,
} from "./types";
@@ -78,6 +80,7 @@ export interface ProjectEditorState {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomMotionBlurTuning: ZoomMotionBlurTuning;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
@@ -98,6 +101,9 @@ export interface ProjectEditorState {
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cameraSpringStiffnessMultiplier: number;
cameraSpringDampingMultiplier: number;
cameraSpringMassMultiplier: number;
zoomSmoothness: number;
zoomClassicMode: boolean;
cursorMotionBlur: number;
@@ -337,6 +343,35 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
: legacyMotionBlurEnabled
? 0.35
: DEFAULT_ZOOM_MOTION_BLUR;
const rawZoomMotionBlurTuning =
(editor as Partial<ProjectEditorState>).zoomMotionBlurTuning &&
typeof (editor as Partial<ProjectEditorState>).zoomMotionBlurTuning === "object"
? ((editor as Partial<ProjectEditorState>)
.zoomMotionBlurTuning as Partial<ZoomMotionBlurTuning>)
: {};
const normalizedZoomMotionBlurTuning: ZoomMotionBlurTuning = {
panVelocityThreshold: isFiniteNumber(rawZoomMotionBlurTuning.panVelocityThreshold)
? clamp(rawZoomMotionBlurTuning.panVelocityThreshold, 0, 240)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.panVelocityThreshold,
zoomVelocityThreshold: isFiniteNumber(rawZoomMotionBlurTuning.zoomVelocityThreshold)
? clamp(rawZoomMotionBlurTuning.zoomVelocityThreshold, 0, 0.4)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomVelocityThreshold,
maxDirectionalBlurPx: isFiniteNumber(rawZoomMotionBlurTuning.maxDirectionalBlurPx)
? clamp(rawZoomMotionBlurTuning.maxDirectionalBlurPx, 0, 32)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx,
maxRadialBlurStrength: isFiniteNumber(rawZoomMotionBlurTuning.maxRadialBlurStrength)
? clamp(rawZoomMotionBlurTuning.maxRadialBlurStrength, 0, 0.5)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxRadialBlurStrength,
panResponsePerSecond: isFiniteNumber(rawZoomMotionBlurTuning.panResponsePerSecond)
? clamp(rawZoomMotionBlurTuning.panResponsePerSecond, 1, 30)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.panResponsePerSecond,
zoomResponsePerSecond: isFiniteNumber(rawZoomMotionBlurTuning.zoomResponsePerSecond)
? clamp(rawZoomMotionBlurTuning.zoomResponsePerSecond, 1, 30)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomResponsePerSecond,
zoomSafeZoneRadiusPx: isFiniteNumber(rawZoomMotionBlurTuning.zoomSafeZoneRadiusPx)
? clamp(rawZoomMotionBlurTuning.zoomSafeZoneRadiusPx, 0, 80)
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomSafeZoneRadiusPx,
};
const normalizedZoomTemporalMotionBlur = isFiniteNumber(
(editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur,
)
@@ -768,6 +803,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomMotionBlurTuning: normalizedZoomMotionBlurTuning,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
@@ -801,6 +837,15 @@ 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,
+22 -7
View File
@@ -100,9 +100,28 @@ export const DEFAULT_CURSOR_SMOOTHING = 0.67;
export const DEFAULT_CURSOR_MOTION_BLUR = 0.4;
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
export const DEFAULT_CURSOR_CLICK_BOUNCE_DURATION = 350;
export const DEFAULT_CURSOR_SWAY = 0.25;
export const DEFAULT_CURSOR_SWAY = 0.4;
export const DEFAULT_ZOOM_SMOOTHNESS = 0.5;
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
export interface ZoomMotionBlurTuning {
panVelocityThreshold: number;
zoomVelocityThreshold: number;
maxDirectionalBlurPx: number;
maxRadialBlurStrength: number;
panResponsePerSecond: number;
zoomResponsePerSecond: number;
zoomSafeZoneRadiusPx: number;
}
export const DEFAULT_ZOOM_MOTION_BLUR_TUNING: ZoomMotionBlurTuning = {
panVelocityThreshold: 24,
zoomVelocityThreshold: 0.05,
maxDirectionalBlurPx: 11,
maxRadialBlurStrength: 0.14,
panResponsePerSecond: 11,
zoomResponsePerSecond: 9,
zoomSafeZoneRadiusPx: 6,
};
export const DEFAULT_ZOOM_IN_DURATION_MS = 1522.575;
export const DEFAULT_ZOOM_IN_OVERLAP_MS = 500;
export const DEFAULT_ZOOM_OUT_DURATION_MS = 1015.05;
@@ -201,9 +220,7 @@ export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[])
continue;
}
return Math.round(
clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip),
);
return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip));
}
if (sortedClips.length === 0) {
@@ -223,9 +240,7 @@ export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[])
continue;
}
return Math.round(
clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip),
);
return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip));
}
if (sortedClips.length === 0) {
@@ -272,17 +272,20 @@ export function getCursorSpringConfig(
}, tuning);
}
export function getZoomSpringConfig(smoothnessFactor = 0.5): SpringConfig {
export function getZoomSpringConfig(
smoothnessFactor = 0.5,
tuning?: CursorSpringTuning,
): SpringConfig {
const clamped = Math.max(0, Math.min(1, smoothnessFactor));
if (clamped <= 0) {
return {
return applyCursorSpringTuning({
stiffness: 1000,
damping: 100,
mass: 1,
restDelta: 0.0001,
restSpeed: 0.001,
};
}, tuning);
}
// Map 0-1 slider to the internal 0-2 spring range so that
@@ -294,11 +297,11 @@ export function getZoomSpringConfig(smoothnessFactor = 0.5): SpringConfig {
// The overshoot clamp in stepSpringValue prevents wobble even at
// this low damping, so animations stay fast and responsive.
// Higher scaled → lower stiffness + higher mass → slower, floatier settle.
return {
return applyCursorSpringTuning({
stiffness: 100 / scaled,
damping: 21,
mass: 1.0 * scaled,
restDelta: 0.0005,
restSpeed: 0.015,
};
}, tuning);
}
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { applyZoomTransform, computeZoomTransform, createMotionBlurState } from "./zoomTransform";
function createStubContainer() {
return {
scale: { set: () => undefined },
position: { set: () => undefined },
};
}
describe("applyZoomTransform motion blur routing", () => {
it("keeps pure translation on the directional blur path", () => {
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, x: 96, y: 24 },
frameTimeMs: 1016,
});
expect(
Math.hypot(motionBlurFilter.velocity.x, motionBlurFilter.velocity.y),
).toBeGreaterThan(0);
expect(zoomBlurFilter.strength).toBe(0);
});
it("infers an off-center zoom origin for radial blur", () => {
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 stageSize = { width: 1000, height: 800 };
const baseMask = { x: 100, y: 100, width: 800, height: 600 };
const focusX = 0.2;
const focusY = 0.7;
applyZoomTransform({
cameraContainer: createStubContainer() as never,
zoomBlurFilter: zoomBlurFilter as never,
motionBlurFilter: motionBlurFilter as never,
stageSize,
baseMask,
zoomScale: 1,
focusX,
focusY,
isPlaying: true,
motionBlurAmount: 1,
motionBlurState,
transformOverride: computeZoomTransform({
stageSize,
baseMask,
zoomScale: 1,
focusX,
focusY,
}),
frameTimeMs: 1000,
});
applyZoomTransform({
cameraContainer: createStubContainer() as never,
zoomBlurFilter: zoomBlurFilter as never,
motionBlurFilter: motionBlurFilter as never,
stageSize,
baseMask,
zoomScale: 1.7,
focusX,
focusY,
isPlaying: true,
motionBlurAmount: 1,
motionBlurState,
transformOverride: computeZoomTransform({
stageSize,
baseMask,
zoomScale: 1.7,
focusX,
focusY,
}),
frameTimeMs: 1016,
});
expect(Math.abs(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);
});
});
@@ -1,26 +1,17 @@
import { Container } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
import { DEFAULT_ZOOM_MOTION_BLUR_TUNING, type ZoomMotionBlurTuning } from "../types";
const PEAK_TRANSLATION_VELOCITY_PPS = 1800;
const PAN_VELOCITY_THRESHOLD_PPS = 24;
const PEAK_LOG_SCALE_VELOCITY_PER_SECOND = 1.45;
const LOG_SCALE_VELOCITY_THRESHOLD = 0.05;
const MAX_DIRECTIONAL_BLUR_PX = 7.5;
const MAX_ZOOM_BLUR_STRENGTH = 0.14;
const PAN_RESPONSE_PER_SECOND = 11;
const ZOOM_RESPONSE_PER_SECOND = 9;
const MIN_DIRECTIONAL_BLUR_MAGNITUDE = 0.01;
const DIRECTIONAL_BLUR_KERNEL_SIZE = 13;
const DIRECTIONAL_BLUR_OFFSET_DIVISOR = 32;
export interface MotionBlurState {
lastFrameTimeMs: number;
prevCamX: number;
prevCamY: number;
prevCamScale: number;
smoothedPanVelocityX: number;
smoothedPanVelocityY: number;
smoothedLogScaleVelocity: number;
smoothedDirectionalBlur: number;
smoothedRadialBlur: number;
initialized: boolean;
}
@@ -30,11 +21,6 @@ export function createMotionBlurState(): MotionBlurState {
prevCamX: 0,
prevCamY: 0,
prevCamScale: 1,
smoothedPanVelocityX: 0,
smoothedPanVelocityY: 0,
smoothedLogScaleVelocity: 0,
smoothedDirectionalBlur: 0,
smoothedRadialBlur: 0,
initialized: false,
};
}
@@ -49,10 +35,9 @@ interface TransformParams {
zoomProgress?: number;
focusX: number;
focusY: number;
motionIntensity: number;
motionVector?: { x: number; y: number };
isPlaying: boolean;
motionBlurAmount?: number;
motionBlurTuning?: ZoomMotionBlurTuning;
transformOverride?: AppliedTransform;
motionBlurState?: MotionBlurState;
frameTimeMs?: number;
@@ -64,6 +49,27 @@ interface AppliedTransform {
y: number;
}
interface Point2D {
x: number;
y: number;
}
interface TransformQuad {
center: Point2D;
corners: [Point2D, Point2D, Point2D, Point2D];
diagonal: number;
size: Point2D;
}
interface CameraStepAnalysis {
mode: "move" | "zoom" | null;
moveVelocity: Point2D;
moveBlurVelocity: Point2D;
moveBlurOffset: number;
zoomCenter: Point2D;
zoomStrength: number;
}
interface FocusFromTransformGeometry {
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
@@ -103,87 +109,295 @@ function resetMotionEffects(
motionBlurState.prevCamX = 0;
motionBlurState.prevCamY = 0;
motionBlurState.prevCamScale = 1;
motionBlurState.smoothedPanVelocityX = 0;
motionBlurState.smoothedPanVelocityY = 0;
motionBlurState.smoothedLogScaleVelocity = 0;
motionBlurState.smoothedDirectionalBlur = 0;
motionBlurState.smoothedRadialBlur = 0;
motionBlurState.initialized = false;
}
}
function mixTowards(current: number, target: number, blendFactor: number) {
return current + (target - current) * blendFactor;
}
function computeSmoothingBlend(deltaSeconds: number, responsePerSecond: number) {
return 1 - Math.exp(-Math.max(0, deltaSeconds) * responsePerSecond);
}
function remapMotionStrength(value: number, threshold: number, peak: number) {
if (!Number.isFinite(value) || value <= threshold) {
return 0;
function resolveMotionBlurTuning(motionBlurTuning?: ZoomMotionBlurTuning): ZoomMotionBlurTuning {
if (!motionBlurTuning) {
return DEFAULT_ZOOM_MOTION_BLUR_TUNING;
}
const span = Math.max(0.0001, peak - threshold);
return Math.min(1, (value - threshold) / span);
}
function squareEase(value: number) {
return value * value;
}
function resolveDirectionalKernelSize(blurStrength: number) {
if (blurStrength >= 5.5) {
return 13;
}
if (blurStrength >= 3) {
return 11;
}
if (blurStrength >= 1.25) {
return 7;
}
return 5;
}
function computeZoomBlurGeometry({
stageSize,
baseMask,
targetZoomScale,
focusX,
focusY,
}: {
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
targetZoomScale: number;
focusX: number;
focusY: number;
}) {
const safeZoomScale = Math.max(1, targetZoomScale);
const visibleWidth = stageSize.width / safeZoomScale;
const visibleHeight = stageSize.height / safeZoomScale;
const visibleHalfDiagonal = Math.hypot(visibleWidth / 2, visibleHeight / 2);
const focusStagePxX = baseMask.x + focusX * baseMask.width;
const focusStagePxY = baseMask.y + focusY * baseMask.height;
const outerRadius = Math.max(
Math.hypot(focusStagePxX, focusStagePxY),
Math.hypot(stageSize.width - focusStagePxX, focusStagePxY),
Math.hypot(focusStagePxX, stageSize.height - focusStagePxY),
Math.hypot(stageSize.width - focusStagePxX, stageSize.height - focusStagePxY),
);
const radius = Math.max(outerRadius, 1);
return {
centerX: focusStagePxX,
centerY: focusStagePxY,
innerRadius: Math.max(0, Math.min(Math.max(18, Math.min(radius - 1, visibleHalfDiagonal)), radius - 1)),
radius,
...DEFAULT_ZOOM_MOTION_BLUR_TUNING,
...motionBlurTuning,
};
}
function computeTransformQuad(
baseMask: { x: number; y: number; width: number; height: number },
transform: AppliedTransform,
): TransformQuad {
const topLeft = {
x: transform.x + baseMask.x * transform.scale,
y: transform.y + baseMask.y * transform.scale,
};
const topRight = {
x: transform.x + (baseMask.x + baseMask.width) * transform.scale,
y: transform.y + baseMask.y * transform.scale,
};
const bottomRight = {
x: transform.x + (baseMask.x + baseMask.width) * transform.scale,
y: transform.y + (baseMask.y + baseMask.height) * transform.scale,
};
const bottomLeft = {
x: transform.x + baseMask.x * transform.scale,
y: transform.y + (baseMask.y + baseMask.height) * transform.scale,
};
const center = {
x: (topLeft.x + bottomRight.x) * 0.5,
y: (topLeft.y + bottomRight.y) * 0.5,
};
return {
center,
corners: [topLeft, topRight, bottomRight, bottomLeft],
diagonal: Math.hypot(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y),
size: {
x: Math.hypot(topRight.x - topLeft.x, topRight.y - topLeft.y),
y: Math.hypot(bottomLeft.x - topLeft.x, bottomLeft.y - topLeft.y),
},
};
}
function crossProduct(a: Point2D, b: Point2D) {
return a.x * b.y - a.y * b.x;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function intersectInfiniteLines(
aStart: Point2D,
aEnd: Point2D,
bStart: Point2D,
bEnd: Point2D,
): Point2D | null {
const aDelta = { x: aEnd.x - aStart.x, y: aEnd.y - aStart.y };
const bDelta = { x: bEnd.x - bStart.x, y: bEnd.y - bStart.y };
const denominator = crossProduct(aDelta, bDelta);
if (Math.abs(denominator) < 0.0001) {
return null;
}
const originDelta = { x: bStart.x - aStart.x, y: bStart.y - aStart.y };
const t = crossProduct(originDelta, bDelta) / denominator;
return {
x: aStart.x + aDelta.x * t,
y: aStart.y + aDelta.y * t,
};
}
function inferZoomCenterFromQuads(
previousQuad: TransformQuad,
currentQuad: TransformQuad,
stageSize: { width: number; height: number },
) {
const hasMeaningfulChange =
Math.abs(currentQuad.center.x - previousQuad.center.x) > 0.001 ||
Math.abs(currentQuad.center.y - previousQuad.center.y) > 0.001 ||
Math.abs(currentQuad.diagonal - previousQuad.diagonal) > 0.001;
if (!hasMeaningfulChange) {
return previousQuad.center;
}
const motionSegments: Array<{ start: Point2D; end: Point2D }> = [];
for (let i = 0; i < previousQuad.corners.length; i += 1) {
const start = previousQuad.corners[i];
const end = currentQuad.corners[i];
if (Math.hypot(end.x - start.x, end.y - start.y) < 0.5) {
continue;
}
motionSegments.push({ start, end });
}
for (let i = 0; i < motionSegments.length; i += 1) {
for (let j = i + 1; j < motionSegments.length; j += 1) {
const intersection = intersectInfiniteLines(
motionSegments[i].start,
motionSegments[i].end,
motionSegments[j].start,
motionSegments[j].end,
);
if (!intersection) {
continue;
}
const marginX = stageSize.width;
const marginY = stageSize.height;
return {
x: clamp(intersection.x, -marginX, stageSize.width + marginX),
y: clamp(intersection.y, -marginY, stageSize.height + marginY),
};
}
}
return currentQuad.center;
}
function resolveFpsScale(deltaSeconds: number) {
const fps = 1 / Math.max(1 / 240, deltaSeconds);
return fps / 60;
}
function resolveBlurChannels(
motionBlurAmount: number,
motionBlurTuning: ZoomMotionBlurTuning,
deltaSeconds: number,
) {
const fpsScale = resolveFpsScale(deltaSeconds);
return {
motion:
motionBlurAmount *
fpsScale *
(motionBlurTuning.maxDirectionalBlurPx /
DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxDirectionalBlurPx),
zoom:
motionBlurAmount *
fpsScale *
(motionBlurTuning.maxRadialBlurStrength /
DEFAULT_ZOOM_MOTION_BLUR_TUNING.maxRadialBlurStrength),
};
}
function computeMoveDelta(previousQuad: TransformQuad, currentQuad: TransformQuad) {
return {
x: currentQuad.center.x - previousQuad.center.x,
y: currentQuad.center.y - previousQuad.center.y,
};
}
function computeSizeDelta(previousQuad: TransformQuad, currentQuad: TransformQuad) {
return {
x: Math.abs(currentQuad.size.x - previousQuad.size.x),
y: Math.abs(currentQuad.size.y - previousQuad.size.y),
};
}
function computeZoomStrength(previousQuad: TransformQuad, currentQuad: TransformQuad) {
return Math.abs(1 - currentQuad.diagonal / Math.max(0.0001, previousQuad.diagonal));
}
function classifyMotionMode(
previousQuad: TransformQuad,
currentQuad: TransformQuad,
motionBlurTuning: ZoomMotionBlurTuning,
deltaSeconds: number,
) {
const moveDelta = computeMoveDelta(previousQuad, currentQuad);
const sizeDelta = computeSizeDelta(previousQuad, currentQuad);
const moveDistance = Math.hypot(moveDelta.x, moveDelta.y);
const zoomDistance = Math.hypot(sizeDelta.x, sizeDelta.y);
const moveVelocity = Math.hypot(moveDelta.x, moveDelta.y) / Math.max(0.0001, deltaSeconds);
const zoomVelocity =
Math.abs(
Math.log(
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;
if (!moveActive && !zoomActive) {
return null;
}
if (moveActive && !zoomActive) {
return "move";
}
if (zoomActive && !moveActive) {
return "zoom";
}
return zoomDistance > moveDistance ? "zoom" : "move";
}
function createZeroPoint(): Point2D {
return { x: 0, y: 0 };
}
function analyzeCameraStep({
previousQuad,
currentQuad,
stageSize,
motionBlurAmount,
motionBlurTuning,
deltaSeconds,
}: {
previousQuad: TransformQuad;
currentQuad: TransformQuad;
stageSize: { width: number; height: number };
motionBlurAmount: number;
motionBlurTuning: ZoomMotionBlurTuning;
deltaSeconds: number;
}): CameraStepAnalysis {
const mode = classifyMotionMode(
previousQuad,
currentQuad,
motionBlurTuning,
deltaSeconds,
);
const moveDelta = computeMoveDelta(previousQuad, currentQuad);
const blurChannels = resolveBlurChannels(
motionBlurAmount,
motionBlurTuning,
deltaSeconds,
);
const moveBlurVelocity = {
x: moveDelta.x * blurChannels.motion,
y: moveDelta.y * blurChannels.motion,
};
const moveBlurMagnitude = Math.hypot(moveBlurVelocity.x, moveBlurVelocity.y);
return {
mode,
moveVelocity: moveDelta,
moveBlurVelocity:
mode === "move" && moveBlurMagnitude >= MIN_DIRECTIONAL_BLUR_MAGNITUDE
? moveBlurVelocity
: createZeroPoint(),
moveBlurOffset:
mode === "move" && moveBlurMagnitude >= MIN_DIRECTIONAL_BLUR_MAGNITUDE
? -moveBlurMagnitude / DIRECTIONAL_BLUR_OFFSET_DIVISOR
: 0,
zoomCenter: inferZoomCenterFromQuads(previousQuad, currentQuad, stageSize),
zoomStrength:
mode === "zoom"
? computeZoomStrength(previousQuad, currentQuad) * blurChannels.zoom
: 0,
};
}
function applyCameraStepBlur({
analysis,
motionBlurFilter,
zoomBlurFilter,
}: {
analysis: CameraStepAnalysis;
motionBlurFilter: MotionBlurFilter;
zoomBlurFilter?: ZoomBlurFilter | null;
}) {
motionBlurFilter.velocity = analysis.moveBlurVelocity;
motionBlurFilter.kernelSize = DIRECTIONAL_BLUR_KERNEL_SIZE;
motionBlurFilter.offset = analysis.moveBlurOffset;
if (zoomBlurFilter) {
zoomBlurFilter.center = analysis.zoomCenter;
zoomBlurFilter.strength = analysis.zoomStrength;
zoomBlurFilter.innerRadius = 0;
zoomBlurFilter.radius = -1;
}
}
export function computeZoomTransform({
stageSize,
baseMask,
@@ -255,10 +469,9 @@ export function applyZoomTransform({
zoomProgress = 1,
focusX,
focusY,
motionIntensity: _motionIntensity,
motionVector: _motionVector,
isPlaying,
motionBlurAmount = 0,
motionBlurTuning,
transformOverride,
motionBlurState,
frameTimeMs,
@@ -289,6 +502,7 @@ export function applyZoomTransform({
// Apply position & scale to camera container
cameraContainer.scale.set(transform.scale);
cameraContainer.position.set(transform.x, transform.y);
const resolvedTuning = resolveMotionBlurTuning(motionBlurTuning);
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
const now = frameTimeMs ?? performance.now();
@@ -300,7 +514,7 @@ export function applyZoomTransform({
motionBlurState.lastFrameTimeMs = now;
motionBlurState.initialized = true;
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.kernelSize = DIRECTIONAL_BLUR_KERNEL_SIZE;
motionBlurFilter.offset = 0;
if (zoomBlurFilter) {
zoomBlurFilter.strength = 0;
@@ -310,104 +524,26 @@ export function applyZoomTransform({
const dtSeconds = dtMs / 1000;
motionBlurState.lastFrameTimeMs = now;
const dx = transform.x - motionBlurState.prevCamX;
const dy = transform.y - motionBlurState.prevCamY;
const previousScale = Math.max(0.0001, motionBlurState.prevCamScale);
const scaleRatio = Math.max(0.0001, transform.scale) / previousScale;
const logScaleVelocity = Math.log(scaleRatio) / dtSeconds;
const previousTransform = {
scale: motionBlurState.prevCamScale,
x: motionBlurState.prevCamX,
y: motionBlurState.prevCamY,
};
const previousQuad = computeTransformQuad(baseMask, previousTransform);
const currentQuad = computeTransformQuad(baseMask, transform);
const analysis = analyzeCameraStep({
previousQuad,
currentQuad,
stageSize,
motionBlurAmount,
motionBlurTuning: resolvedTuning,
deltaSeconds: dtSeconds,
});
motionBlurState.prevCamX = transform.x;
motionBlurState.prevCamY = transform.y;
motionBlurState.prevCamScale = transform.scale;
const smoothingBlend = computeSmoothingBlend(dtSeconds, PAN_RESPONSE_PER_SECOND);
const zoomSmoothingBlend = computeSmoothingBlend(dtSeconds, ZOOM_RESPONSE_PER_SECOND);
const rawVelocityX = dx / dtSeconds;
const rawVelocityY = dy / dtSeconds;
motionBlurState.smoothedPanVelocityX = mixTowards(
motionBlurState.smoothedPanVelocityX,
rawVelocityX,
smoothingBlend,
);
motionBlurState.smoothedPanVelocityY = mixTowards(
motionBlurState.smoothedPanVelocityY,
rawVelocityY,
smoothingBlend,
);
motionBlurState.smoothedLogScaleVelocity = mixTowards(
motionBlurState.smoothedLogScaleVelocity,
logScaleVelocity,
zoomSmoothingBlend,
);
const panVelocityX = motionBlurState.smoothedPanVelocityX;
const panVelocityY = motionBlurState.smoothedPanVelocityY;
const panSpeed = Math.hypot(panVelocityX, panVelocityY);
const panStrength = squareEase(
remapMotionStrength(
panSpeed,
PAN_VELOCITY_THRESHOLD_PPS,
PEAK_TRANSLATION_VELOCITY_PPS,
),
);
const zoomStrength = squareEase(
remapMotionStrength(
Math.abs(motionBlurState.smoothedLogScaleVelocity),
LOG_SCALE_VELOCITY_THRESHOLD,
PEAK_LOG_SCALE_VELOCITY_PER_SECOND,
),
);
const targetDirectionalBlur =
panStrength *
(1 - Math.min(0.82, zoomStrength * 0.85)) *
MAX_DIRECTIONAL_BLUR_PX *
motionBlurAmount;
const targetRadialBlur = zoomStrength * MAX_ZOOM_BLUR_STRENGTH * motionBlurAmount;
motionBlurState.smoothedDirectionalBlur = mixTowards(
motionBlurState.smoothedDirectionalBlur,
targetDirectionalBlur,
smoothingBlend,
);
motionBlurState.smoothedRadialBlur = mixTowards(
motionBlurState.smoothedRadialBlur,
targetRadialBlur,
zoomSmoothingBlend,
);
const directionalBlur = motionBlurState.smoothedDirectionalBlur;
const radialBlur = motionBlurState.smoothedRadialBlur;
const dirMag = Math.hypot(panVelocityX, panVelocityY) || 1;
const velocityScale = directionalBlur * 1.1;
motionBlurFilter.velocity =
directionalBlur > 0.15
? {
x: (panVelocityX / dirMag) * velocityScale,
y: (panVelocityY / dirMag) * velocityScale,
}
: { x: 0, y: 0 };
motionBlurFilter.kernelSize = resolveDirectionalKernelSize(directionalBlur);
motionBlurFilter.offset = directionalBlur > 0.45 ? -0.12 : 0;
if (zoomBlurFilter) {
const zoomBlurGeometry = computeZoomBlurGeometry({
stageSize,
baseMask,
targetZoomScale: Math.max(zoomScale, transform.scale, 1),
focusX,
focusY,
});
zoomBlurFilter.center = {
x: zoomBlurGeometry.centerX,
y: zoomBlurGeometry.centerY,
};
zoomBlurFilter.innerRadius = zoomBlurGeometry.innerRadius;
zoomBlurFilter.radius = zoomBlurGeometry.radius;
zoomBlurFilter.strength =
radialBlur * (motionBlurState.smoothedLogScaleVelocity >= 0 ? 0.88 : 1);
}
applyCameraStepBlur({ analysis, motionBlurFilter, zoomBlurFilter });
}
} else {
resetMotionEffects(zoomBlurFilter, motionBlurFilter, motionBlurState);
+5
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "Untitled"
},
"nativeCaptureUnavailable": {
"title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.",
"description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.",
"confirm": "Okay"
},
"exportStatus": {
"exporting": "Exporting",
"renderingFile": "Rendering your file.",
+5
View File
@@ -113,6 +113,11 @@
"project": {
"untitled": "Sin título"
},
"nativeCaptureUnavailable": {
"title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.",
"description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. Recordly seguirá funcionando, pero hará imposible el suavizado del cursor.",
"confirm": "Entendido"
},
"exportStatus": {
"exporting": "Exportando",
"renderingFile": "Renderizando tu archivo.",
+28 -35
View File
@@ -11,6 +11,7 @@ import type {
Padding,
SpeedRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
@@ -75,10 +76,9 @@ import { renderAnnotations } from "./annotationRenderer";
import { renderCaptions } from "./captionRenderer";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
import {
buildTemporalSamplePlanUs,
getTemporalMotionBlurConfig,
} from "./temporalMotionBlur";
import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur";
const TEMPORAL_ZOOM_MOTION_BLUR_ENABLED = false;
interface FrameRenderConfig {
width: number;
@@ -90,6 +90,7 @@ interface FrameRenderConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
@@ -123,6 +124,9 @@ interface FrameRenderConfig {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
@@ -229,7 +233,6 @@ export class FrameRenderer {
private motionBlurState: MotionBlurState;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private lastMotionVector = { x: 0, y: 0 };
private springScale: SpringState;
private springX: SpringState;
private springY: SpringState;
@@ -341,7 +344,7 @@ export class FrameRenderer {
await this.setupFrame();
if ((this.config.zoomMotionBlur ?? 0) > 0) {
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0 });
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 });
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
this.videoContainer.filterArea = new Rectangle(
0,
@@ -349,7 +352,7 @@ export class FrameRenderer {
this.config.width,
this.config.height,
);
this.videoContainer.filters = [this.zoomBlurFilter, this.motionBlurFilter];
this.videoContainer.filters = [this.motionBlurFilter, this.zoomBlurFilter];
} else {
this.videoContainer.filters = null;
}
@@ -1275,7 +1278,10 @@ export class FrameRenderer {
}
const temporalSnapshot =
typeof frameDurationUs === "number" && frameDurationUs > 0
TEMPORAL_ZOOM_MOTION_BLUR_ENABLED &&
(this.config.zoomTemporalMotionBlur ?? 0) > 0 &&
typeof frameDurationUs === "number" &&
frameDurationUs > 0
? await this.renderTemporalMotionBlurFrame(
timestamp,
cursorTimestamp,
@@ -1442,13 +1448,10 @@ export class FrameRenderer {
const TICKS_PER_FRAME = 1;
let maxMotionIntensity = 0;
for (let i = 0; i < TICKS_PER_FRAME; i++) {
const motionIntensity = this.updateAnimationState(timeMs);
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
this.updateAnimationState(timeMs);
}
// Apply transform once with maximum motion intensity from all ticks
applyZoomTransform({
cameraContainer: this.cameraContainer,
zoomBlurFilter: this.zoomBlurFilter,
@@ -1459,10 +1462,9 @@ export class FrameRenderer {
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity: maxMotionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: this.config.zoomMotionBlur ?? 0,
motionBlurTuning: this.config.zoomMotionBlurTuning,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
@@ -1834,7 +1836,11 @@ export class FrameRenderer {
this.lastContentTimeMs !== null ? timeMs - this.lastContentTimeMs : 1000 / 60;
this.lastContentTimeMs = timeMs;
const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness);
const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness, {
stiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
dampingMultiplier: this.config.cameraSpringDampingMultiplier,
massMultiplier: this.config.cameraSpringMassMultiplier,
});
if (this.config.zoomClassicMode) {
state.appliedScale = projectedTransform.scale;
@@ -1864,11 +1870,6 @@ export class FrameRenderer {
);
}
this.lastMotionVector = {
x: state.x - prevX,
y: state.y - prevY,
};
return Math.max(
Math.abs(state.appliedScale - prevScale),
Math.abs(state.x - prevX) / Math.max(1, this.layoutCache.stageSize.width),
@@ -1919,7 +1920,7 @@ export class FrameRenderer {
},
);
const motionIntensity = this.updateAnimationState(timeMs);
this.updateAnimationState(timeMs);
applyZoomTransform({
cameraContainer: this.cameraContainer,
@@ -1931,10 +1932,9 @@ export class FrameRenderer {
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0,
motionBlurTuning: this.config.zoomMotionBlurTuning,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
@@ -1972,21 +1972,14 @@ export class FrameRenderer {
frameDurationUs: number,
layoutCache: LayoutCache,
): Promise<RenderSnapshot | null> {
if (
!this.compositeCanvas ||
!this.compositeCtx ||
!this.temporalAccumulationCtx
) {
if (!this.compositeCanvas || !this.compositeCtx || !this.temporalAccumulationCtx) {
return null;
}
const blurConfig = getTemporalMotionBlurConfig(
this.config.zoomTemporalMotionBlur ?? this.config.zoomMotionBlur,
{
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
},
);
const blurConfig = getTemporalMotionBlurConfig(this.config.zoomTemporalMotionBlur, {
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
});
if (!blurConfig) {
return null;
}
+9
View File
@@ -10,6 +10,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
@@ -42,6 +43,7 @@ interface GifExporterConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
@@ -71,6 +73,9 @@ interface GifExporterConfig {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
@@ -164,6 +169,7 @@ export class GifExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
@@ -197,6 +203,9 @@ export class GifExporter {
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
zoomSmoothness: this.config.zoomSmoothness,
zoomClassicMode: this.config.zoomClassicMode,
cursorMotionBlur: this.config.cursorMotionBlur,
+28 -26
View File
@@ -21,6 +21,7 @@ import type {
Padding,
SpeedRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
@@ -92,6 +93,9 @@ import {
WEBCAM_SHADOW_LAYER_PROFILES,
} from "./shadowProfile";
import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur";
const TEMPORAL_ZOOM_MOTION_BLUR_ENABLED = false;
import type { ExportRenderBackend } from "./types";
interface FrameRenderConfig {
@@ -104,6 +108,7 @@ interface FrameRenderConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
@@ -137,6 +142,9 @@ interface FrameRenderConfig {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -394,7 +402,6 @@ export class FrameRenderer {
private lastContentTimeMs: number | null = null;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private lastMotionVector = { x: 0, y: 0 };
private cursorOverlay: PixiCursorOverlay | null = null;
private lastSyncedWebcamTime: number | null = null;
private lastWebcamCacheRefreshTime: number | null = null;
@@ -427,8 +434,8 @@ export class FrameRenderer {
}
const activeFilters =
this.shouldUseZoomMotionBlur() && this.zoomBlurFilter && this.motionBlurFilter
? [this.zoomBlurFilter, this.motionBlurFilter]
this.shouldUseZoomMotionBlur() && this.motionBlurFilter && this.zoomBlurFilter
? [this.motionBlurFilter, this.zoomBlurFilter]
: null;
this.videoEffectsContainer.filters = activeFilters;
}
@@ -541,7 +548,7 @@ export class FrameRenderer {
this.setupCaptionResources();
if (this.shouldUseZoomMotionBlur()) {
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0 });
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0, maxKernelSize: 13 });
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
}
@@ -2513,7 +2520,7 @@ export class FrameRenderer {
);
}
const motionIntensity = this.updateAnimationState(timeMs);
this.updateAnimationState(timeMs);
applyZoomTransform({
cameraContainer: this.cameraContainer,
@@ -2525,10 +2532,9 @@ export class FrameRenderer {
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0,
motionBlurTuning: this.config.zoomMotionBlurTuning,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
@@ -2595,13 +2601,10 @@ export class FrameRenderer {
return null;
}
const blurConfig = getTemporalMotionBlurConfig(
this.config.zoomTemporalMotionBlur ?? this.config.zoomMotionBlur,
{
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
},
);
const blurConfig = getTemporalMotionBlurConfig(this.config.zoomTemporalMotionBlur, {
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
});
if (!blurConfig) {
return null;
}
@@ -2716,7 +2719,10 @@ export class FrameRenderer {
}
const temporalSnapshot =
typeof frameDurationUs === "number" && frameDurationUs > 0
TEMPORAL_ZOOM_MOTION_BLUR_ENABLED &&
(this.config.zoomTemporalMotionBlur ?? 0) > 0 &&
typeof frameDurationUs === "number" &&
frameDurationUs > 0
? await this.renderTemporalMotionBlurFrame(
timestamp,
cursorTimestamp,
@@ -2761,9 +2767,7 @@ export class FrameRenderer {
);
}
let maxMotionIntensity = 0;
const motionIntensity = this.updateAnimationState(timeMs);
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
this.updateAnimationState(timeMs);
applyZoomTransform({
cameraContainer: this.cameraContainer,
@@ -2775,10 +2779,9 @@ export class FrameRenderer {
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity: maxMotionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: this.config.zoomMotionBlur ?? 0,
motionBlurTuning: this.config.zoomMotionBlurTuning,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
@@ -3244,7 +3247,11 @@ export class FrameRenderer {
this.lastContentTimeMs !== null ? timeMs - this.lastContentTimeMs : 1000 / 60;
this.lastContentTimeMs = timeMs;
const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness);
const zoomSpringConfig = getZoomSpringConfig(this.config.zoomSmoothness, {
stiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
dampingMultiplier: this.config.cameraSpringDampingMultiplier,
massMultiplier: this.config.cameraSpringMassMultiplier,
});
if (this.config.zoomClassicMode) {
state.appliedScale = projectedTransform.scale;
@@ -3274,11 +3281,6 @@ export class FrameRenderer {
);
}
this.lastMotionVector = {
x: state.x - previousX,
y: state.y - previousY,
};
return Math.max(
Math.abs(state.appliedScale - previousScale),
Math.abs(state.x - previousX) / Math.max(1, this.layoutCache.stageSize.width),
+9
View File
@@ -10,6 +10,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
@@ -59,6 +60,7 @@ interface VideoExporterConfig extends ExportConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
@@ -88,6 +90,9 @@ interface VideoExporterConfig extends ExportConfig {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -309,6 +314,7 @@ export class ModernVideoExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
@@ -342,6 +348,9 @@ export class ModernVideoExporter {
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
+9
View File
@@ -10,6 +10,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
@@ -48,6 +49,7 @@ interface VideoExporterConfig extends ExportConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
@@ -77,6 +79,9 @@ interface VideoExporterConfig extends ExportConfig {
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cameraSpringStiffnessMultiplier?: number;
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -198,6 +203,7 @@ export class VideoExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
@@ -231,6 +237,9 @@ export class VideoExporter {
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,