diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 34333206..fb441bf4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -78,6 +78,7 @@ import { DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, DEFAULT_CURSOR_CLICK_EFFECT_SCALE, @@ -158,11 +159,34 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str { value: "pop", label: "Pop" }, ]; +const CLICK_EFFECT_COLOR_OPTIONS = [ + "#2563EB", + "#EF4444", + "#F59E0B", + "#22C55E", + "#A855F7", + "#EC4899", + "#14B8A6", + "#F97316", +] as const; + type BackgroundTab = "image" | "video" | "color" | "gradient"; function isHexWallpaper(value: string): boolean { return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); } +function hexToRgba(hex: string, alpha: number) { + const normalized = isHexWallpaper(hex) ? hex : DEFAULT_CURSOR_CLICK_EFFECT_COLOR; + const value = normalized.length === 4 + ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}` + : normalized; + const color = Number.parseInt(value.slice(1), 16); + const red = (color >> 16) & 255; + const green = (color >> 8) & 255; + const blue = color & 255; + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + function getBackgroundTabForWallpaper(value: string): BackgroundTab { if (GRADIENTS.includes(value)) { return "gradient"; @@ -407,20 +431,20 @@ const CURSOR_CLICK_EFFECT_OPTIONS: Array<{ label: "Off", description: "No click animation. Keeps the pointer steady on every tap.", }, - { - id: "ripple", - label: "Ripple", - description: "Concentric rings that expand from the click point.", - }, { id: "spotlight", label: "Spotlight", description: "A soft pulse that blooms behind the cursor on click.", }, { - id: "burst", - label: "Burst", - description: "A sharper burst with sparks for more energetic demos.", + id: "ripple", + label: "Ripple", + description: "Concentric rings that expand from the click point.", + }, + { + id: "echo", + label: "Echo", + description: "A pair of soft rings that spread outward with a cleaner pulse.", }, ]; @@ -482,7 +506,13 @@ function MotionPresetCards({ ); } -function CursorClickEffectPreview({ effect }: { effect: CursorClickEffectStyle }) { +function CursorClickEffectPreview({ + effect, + color = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +}: { + effect: CursorClickEffectStyle; + color?: string; +}) { return (
{effect === "none" ? ( -
- - -
+ ) : null} {effect === "ripple" ? ( - - + + + ) : null} - {effect === "burst" ? ( + {effect === "echo" ? ( ) : null} @@ -552,6 +589,7 @@ function CursorClickEffectPreview({ effect }: { effect: CursorClickEffectStyle } function CursorClickEffectCards({ title, activeEffectId, + effectColor, onApply, showAdvanced, onToggleAdvanced, @@ -559,6 +597,7 @@ function CursorClickEffectCards({ }: { title: string; activeEffectId: CursorClickEffectStyle; + effectColor: string; onApply: (effectId: CursorClickEffectStyle) => void; showAdvanced: boolean; onToggleAdvanced: () => void; @@ -621,8 +660,8 @@ function CursorClickEffectCards({ )} >
-
- +
+
@@ -723,6 +762,8 @@ interface SettingsPanelProps { onCursorMotionBlurChange?: (amount: number) => void; cursorClickEffect?: CursorClickEffectStyle; onCursorClickEffectChange?: (effect: CursorClickEffectStyle) => void; + cursorClickEffectColor?: string; + onCursorClickEffectColorChange?: (color: string) => void; cursorClickEffectScale?: number; onCursorClickEffectScaleChange?: (scale: number) => void; cursorClickEffectOpacity?: number; @@ -1157,6 +1198,8 @@ export function SettingsPanel({ onCursorMotionBlurChange, cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT, onCursorClickEffectChange, + cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + onCursorClickEffectColorChange, cursorClickEffectScale = DEFAULT_CURSOR_CLICK_EFFECT_SCALE, onCursorClickEffectScaleChange, cursorClickEffectOpacity = DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, @@ -1396,6 +1439,7 @@ export function SettingsPanel({ getBackgroundTabForWallpaper(selected), ); const customColorInputRef = useRef(null); + const cursorClickEffectColorInputRef = useRef(null); const defaultWebcam = initialEditorPreferences.webcam; const [internalActiveEffectSection] = useState("scene"); const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; @@ -3632,6 +3676,7 @@ export function SettingsPanel({ "Click Effects", )} activeEffectId={cursorClickEffect} + effectColor={cursorClickEffectColor} onApply={(effectId) => onCursorClickEffectChange?.(effectId)} showAdvanced={showCursorClickEffectAdvanced} onToggleAdvanced={() => @@ -3641,6 +3686,57 @@ export function SettingsPanel({ /> {showCursorClickEffectAdvanced ? (
+ + onCursorClickEffectColorChange?.(event.target.value) + } + className="sr-only" + /> +
+
+ {tSettings( + "effects.cursorClickEffects.color", + "Effect Color", + )} +
+
+ {CLICK_EFFECT_COLOR_OPTIONS.map((color) => { + const isSelected = + cursorClickEffectColor.toLowerCase() === color.toLowerCase(); + return ( + +
+
( initialEditorPreferences.cursorClickEffect, ); + const [cursorClickEffectColor, setCursorClickEffectColor] = useState( + initialEditorPreferences.cursorClickEffectColor, + ); const [cursorClickEffectScale, setCursorClickEffectScale] = useState( initialEditorPreferences.cursorClickEffectScale, ); @@ -640,6 +639,7 @@ export default function VideoEditor() { const smokeExportReadyStateRef = useRef>({}); const [historyVersion, setHistoryVersion] = useState(0); const timelineRef = useRef(null); + const previewShellRef = useRef(null); function formatTime(seconds: number) { if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00"; @@ -648,7 +648,68 @@ export default function VideoEditor() { return `${mins}:${secs.toString().padStart(2, "0")}`; } - const [timelineCollapsed, setTimelineCollapsed] = useState(false); + const [isPreviewPlayerOpen, setIsPreviewPlayerOpen] = useState(false); + + const handleTogglePreviewFullscreen = useCallback(async () => { + if (typeof document === "undefined") { + setIsPreviewPlayerOpen((current) => !current); + return; + } + + const previewShell = previewShellRef.current; + if (!previewShell?.requestFullscreen) { + setIsPreviewPlayerOpen((current) => !current); + return; + } + + if (document.fullscreenElement === previewShell) { + try { + await document.exitFullscreen(); + } catch (error) { + console.warn("[VideoEditor] Failed to exit preview fullscreen", error); + } + setIsPreviewPlayerOpen(false); + return; + } + + try { + await previewShell.requestFullscreen(); + setIsPreviewPlayerOpen(true); + } catch (error) { + console.warn("[VideoEditor] Failed to enter preview fullscreen", error); + setIsPreviewPlayerOpen(false); + } + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isPreviewPlayerOpen) { + event.preventDefault(); + setIsPreviewPlayerOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [isPreviewPlayerOpen]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const handleFullscreenChange = () => { + setIsPreviewPlayerOpen(document.fullscreenElement === previewShellRef.current); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; + }, []); useEffect(() => { void window.electronAPI?.getPlatform?.()?.then((platform) => { @@ -714,6 +775,7 @@ export default function VideoEditor() { cameraSpringMassMultiplier, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -770,6 +832,7 @@ export default function VideoEditor() { cameraSpringMassMultiplier, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -867,6 +930,7 @@ export default function VideoEditor() { setCameraSpringMassMultiplier(snapshot.cameraSpringMassMultiplier); setCursorMotionBlur(snapshot.cursorMotionBlur); setCursorClickEffect(snapshot.cursorClickEffect); + setCursorClickEffectColor(snapshot.cursorClickEffectColor); setCursorClickEffectScale(snapshot.cursorClickEffectScale); setCursorClickEffectOpacity(snapshot.cursorClickEffectOpacity); setCursorClickEffectDurationMs(snapshot.cursorClickEffectDurationMs); @@ -1144,6 +1208,7 @@ export default function VideoEditor() { zoomClassicMode, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -1617,6 +1682,8 @@ export default function VideoEditor() { zoomSmoothness: number; zoomClassicMode: boolean; cursorMotionBlur: number; + cursorClickEffect: CursorClickEffectStyle; + cursorClickEffectColor: string; cursorClickEffectScale: number; cursorClickEffectOpacity: number; cursorClickEffectDurationMs: number; @@ -1724,6 +1791,7 @@ export default function VideoEditor() { zoomClassicMode, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -1790,6 +1858,7 @@ export default function VideoEditor() { zoomClassicMode, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -1975,6 +2044,7 @@ export default function VideoEditor() { setCameraSpringDampingMultiplier(normalizedEditor.cameraSpringDampingMultiplier); setCameraSpringMassMultiplier(normalizedEditor.cameraSpringMassMultiplier); setCursorClickEffect(normalizedEditor.cursorClickEffect); + setCursorClickEffectColor(normalizedEditor.cursorClickEffectColor); setCursorClickEffectScale(normalizedEditor.cursorClickEffectScale); setCursorClickEffectOpacity(normalizedEditor.cursorClickEffectOpacity); setCursorClickEffectDurationMs(normalizedEditor.cursorClickEffectDurationMs); @@ -2479,6 +2549,7 @@ export default function VideoEditor() { cameraSpringMassMultiplier, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -3296,17 +3367,19 @@ export default function VideoEditor() { }, }); + const getActivePlayback = useCallback(() => videoPlaybackRef.current, []); + const startPlayback = useCallback(() => { - const playback = videoPlaybackRef.current; + const playback = getActivePlayback(); const video = playback?.video; if (!playback || !video) return; audio.playSourceAudioPreview(); playback.play().catch((err) => console.error("Video play failed:", err)); - }, [audio.playSourceAudioPreview]); + }, [audio.playSourceAudioPreview, getActivePlayback]); function togglePlayPause() { - const playback = videoPlaybackRef.current; + const playback = getActivePlayback(); const video = playback?.video; if (!playback || !video) return; @@ -3323,7 +3396,7 @@ export default function VideoEditor() { const handleSeek = useCallback( (time: number, options: { pause?: boolean } = {}) => { - const playback = videoPlaybackRef.current; + const playback = getActivePlayback(); const video = playback?.video; if (!video) return; @@ -3333,7 +3406,7 @@ export default function VideoEditor() { video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; }, - [mapTimelineTimeToSourceTime], + [getActivePlayback, mapTimelineTimeToSourceTime], ); const handleTimelineSeek = useCallback( @@ -3343,6 +3416,22 @@ export default function VideoEditor() { [handleSeek], ); + const handlePreviewSkipBack = useCallback(() => { + const currentMs = timelinePlayheadTime * 1000; + const keyframes = timelineRef.current?.keyframes ?? []; + const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50); + handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); + }, [handleSeek, timelinePlayheadTime]); + + const handlePreviewSkipForward = useCallback(() => { + const currentMs = timelinePlayheadTime * 1000; + const keyframes = timelineRef.current?.keyframes ?? []; + const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); + handleSeek( + next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5), + ); + }, [handleSeek, timelineDuration, timelinePlayheadTime]); + const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { @@ -4218,6 +4307,7 @@ export default function VideoEditor() { zoomClassicMode, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -4400,6 +4490,7 @@ export default function VideoEditor() { zoomClassicMode, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -5042,6 +5133,100 @@ export default function VideoEditor() { percent: Math.round(exportProgress.percentage), }) : t("editor.exportStatus.preparing", "Preparing export..."); + const previewAspectRatioValue = getAspectRatioValue( + aspectRatio, + (() => { + const previewVideo = videoPlaybackRef.current?.video; + if (previewVideo && previewVideo.videoHeight > 0) { + return previewVideo.videoWidth / previewVideo.videoHeight; + } + return 16 / 9; + })(), + ); + const renderPreviewPlayback = ( + playbackRef: typeof videoPlaybackRef | undefined, + suspendRendering: boolean, + keySuffix: "inline" | "fullscreen", + ) => ( + 0} + shadowIntensity={shadowIntensity} + backgroundBlur={backgroundBlur} + connectZooms={connectZooms} + zoomInDurationMs={zoomInDurationMs} + zoomInOverlapMs={zoomInOverlapMs} + zoomOutDurationMs={zoomOutDurationMs} + connectedZoomGapMs={connectedZoomGapMs} + connectedZoomDurationMs={connectedZoomDurationMs} + zoomInEasing={zoomInEasing} + zoomOutEasing={zoomOutEasing} + connectedZoomEasing={connectedZoomEasing} + borderRadius={isPreviewPlayerOpen ? 0 : borderRadius} + padding={padding} + frame={frame} + cropRegion={cropRegion} + webcam={webcam} + webcamVideoPath={webcam.sourcePath ? resolvedWebcamVideoUrl : null} + trimRegions={trimRegions} + speedRegions={effectiveSpeedRegions} + annotationRegions={annotationRegions} + autoCaptions={autoCaptions} + autoCaptionSettings={autoCaptionSettings} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTelemetry={effectiveCursorTelemetry} + showCursor={effectiveShowCursor} + cursorStyle={cursorStyle} + cursorSize={cursorSize} + cursorSmoothing={cursorSmoothing} + cursorSpringStiffnessMultiplier={cursorSpringStiffnessMultiplier} + cursorSpringDampingMultiplier={cursorSpringDampingMultiplier} + cursorSpringMassMultiplier={cursorSpringMassMultiplier} + cameraSpringStiffnessMultiplier={cameraSpringStiffnessMultiplier} + cameraSpringDampingMultiplier={cameraSpringDampingMultiplier} + cameraSpringMassMultiplier={cameraSpringMassMultiplier} + zoomSmoothness={zoomSmoothness} + zoomClassicMode={zoomClassicMode} + zoomMotionBlur={zoomMotionBlur} + zoomMotionBlurTuning={zoomMotionBlurTuning} + cursorMotionBlur={cursorMotionBlur} + cursorClickEffect={cursorClickEffect} + cursorClickEffectColor={cursorClickEffectColor} + cursorClickEffectScale={cursorClickEffectScale} + cursorClickEffectOpacity={cursorClickEffectOpacity} + cursorClickEffectDurationMs={cursorClickEffectDurationMs} + cursorClickBounce={cursorClickBounce} + cursorClickBounceDuration={cursorClickBounceDuration} + cursorSway={cursorSway} + volume={ + audio.shouldMutePreviewVideo || audio.isCurrentClipMuted + ? 0 + : Math.max( + 0, + Math.min(1, previewVolume * audio.embeddedSourcePreviewGain), + ) + } + suspendRendering={suspendRendering} + /> + ); const projectBrowser = (
{ - const previewVideo = - videoPlaybackRef.current?.video; - if ( - previewVideo && - previewVideo.videoHeight > 0 - ) { - return ( - previewVideo.videoWidth / - previewVideo.videoHeight - ); - } - return 16 / 9; - })(), - ), - maxWidth: "100%", - margin: "0 auto", + width: isPreviewPlayerOpen ? "100vw" : "auto", + height: isPreviewPlayerOpen ? "100vh" : "100%", + maxWidth: isPreviewPlayerOpen ? "100vw" : "100%", + maxHeight: isPreviewPlayerOpen ? "100vh" : "100%", + margin: isPreviewPlayerOpen ? "0" : "0 auto", boxSizing: "border-box", }} > - 0} - shadowIntensity={shadowIntensity} - backgroundBlur={backgroundBlur} - connectZooms={connectZooms} - zoomInDurationMs={zoomInDurationMs} - zoomInOverlapMs={zoomInOverlapMs} - zoomOutDurationMs={zoomOutDurationMs} - connectedZoomGapMs={connectedZoomGapMs} - connectedZoomDurationMs={connectedZoomDurationMs} - zoomInEasing={zoomInEasing} - zoomOutEasing={zoomOutEasing} - connectedZoomEasing={connectedZoomEasing} - borderRadius={borderRadius} - padding={padding} - frame={frame} - cropRegion={cropRegion} - webcam={webcam} - webcamVideoPath={ - webcam.sourcePath - ? resolvedWebcamVideoUrl - : null - } - trimRegions={trimRegions} - speedRegions={effectiveSpeedRegions} - annotationRegions={annotationRegions} - autoCaptions={autoCaptions} - autoCaptionSettings={autoCaptionSettings} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={ - handleAnnotationPositionChange - } - onAnnotationSizeChange={handleAnnotationSizeChange} - cursorTelemetry={effectiveCursorTelemetry} - showCursor={effectiveShowCursor} - cursorStyle={cursorStyle} - cursorSize={cursorSize} - cursorSmoothing={cursorSmoothing} - cursorSpringStiffnessMultiplier={ - cursorSpringStiffnessMultiplier - } - cursorSpringDampingMultiplier={ - cursorSpringDampingMultiplier - } - cursorSpringMassMultiplier={ - cursorSpringMassMultiplier - } - cameraSpringStiffnessMultiplier={ - cameraSpringStiffnessMultiplier - } - cameraSpringDampingMultiplier={ - cameraSpringDampingMultiplier - } - cameraSpringMassMultiplier={ - cameraSpringMassMultiplier - } - zoomSmoothness={zoomSmoothness} - zoomClassicMode={zoomClassicMode} - zoomMotionBlur={zoomMotionBlur} - zoomMotionBlurTuning={zoomMotionBlurTuning} - cursorMotionBlur={cursorMotionBlur} - cursorClickEffect={cursorClickEffect} - cursorClickEffectScale={cursorClickEffectScale} - cursorClickEffectOpacity={cursorClickEffectOpacity} - cursorClickEffectDurationMs={ - cursorClickEffectDurationMs - } - cursorClickBounce={cursorClickBounce} - cursorClickBounceDuration={ - cursorClickBounceDuration - } - cursorSway={cursorSway} - volume={ - audio.shouldMutePreviewVideo || - audio.isCurrentClipMuted - ? 0 - : Math.max( - 0, - Math.min( - 1, - previewVolume * - audio.embeddedSourcePreviewGain, - ), - ) - } - suspendRendering={shouldSuspendPreviewRendering} - /> +
+ {renderPreviewPlayback( + videoPlaybackRef, + shouldSuspendPreviewRendering, + "inline", + )} +
+ {isPreviewPlayerOpen ? ( +
+
+ + + + + + {formatTime(timelinePlayheadTime)} + + handleSeek(Number(event.target.value))} + className="h-2 flex-1 cursor-pointer accent-white" + /> + + {formatTime(timelineDuration)} + + +
+
+ ) : null}
@@ -6155,18 +6321,7 @@ export default function VideoEditor() { size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" title={t("editor.playback.skipBack")} - onClick={() => { - const currentMs = timelinePlayheadTime * 1000; - const kfs = timelineRef.current?.keyframes ?? []; - const prev = [...kfs] - .reverse() - .find((k) => k.time < currentMs - 50); - handleSeek( - prev - ? prev.time / 1000 - : Math.max(0, timelinePlayheadTime - 5), - ); - }} + onClick={handlePreviewSkipBack} > @@ -6188,19 +6343,7 @@ export default function VideoEditor() { size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" title={t("editor.playback.skipForward")} - onClick={() => { - const currentMs = timelinePlayheadTime * 1000; - const kfs = timelineRef.current?.keyframes ?? []; - const next = kfs.find((k) => k.time > currentMs + 50); - handleSeek( - next - ? next.time / 1000 - : Math.min( - timelineDuration, - timelinePlayheadTime + 5, - ), - ); - }} + onClick={handlePreviewSkipForward} > @@ -6215,19 +6358,17 @@ export default function VideoEditor() { variant="ghost" size="icon" title={ - timelineCollapsed - ? t("editor.timeline.expand") - : t("editor.timeline.collapse") + isPreviewPlayerOpen + ? t("editor.preview.exitFullscreen", "Exit fullscreen preview") + : t("editor.preview.enterFullscreen", "Open preview player") } className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" - onClick={() => { - setTimelineCollapsed((p) => !p); - }} + onClick={() => void handleTogglePreviewFullscreen()} > - {timelineCollapsed ? ( - + {isPreviewPlayerOpen ? ( + ) : ( - + )}
@@ -6284,8 +6425,8 @@ export default function VideoEditor() {
( zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT, + cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, cursorClickEffectScale = DEFAULT_CURSOR_CLICK_EFFECT_SCALE, cursorClickEffectOpacity = DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, cursorClickEffectDurationMs = DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, @@ -586,6 +589,7 @@ const VideoPlayback = forwardRef( const cameraSpringMassMultiplierRef = useRef(cameraSpringMassMultiplier); const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickEffectRef = useRef(cursorClickEffect); + const cursorClickEffectColorRef = useRef(cursorClickEffectColor); const cursorClickEffectScaleRef = useRef(cursorClickEffectScale); const cursorClickEffectOpacityRef = useRef(cursorClickEffectOpacity); const cursorClickEffectDurationMsRef = useRef(cursorClickEffectDurationMs); @@ -1619,6 +1623,10 @@ const VideoPlayback = forwardRef( cursorClickEffectRef.current = cursorClickEffect; }, [cursorClickEffect]); + useEffect(() => { + cursorClickEffectColorRef.current = cursorClickEffectColor; + }, [cursorClickEffectColor]); + useEffect(() => { cursorClickEffectScaleRef.current = cursorClickEffectScale; }, [cursorClickEffectScale]); @@ -1930,6 +1938,7 @@ const VideoPlayback = forwardRef( }, motionBlur: cursorMotionBlurRef.current, clickEffect: cursorClickEffectRef.current, + clickEffectColor: cursorClickEffectColorRef.current, clickEffectScale: cursorClickEffectScaleRef.current, clickEffectOpacity: cursorClickEffectOpacityRef.current, clickEffectDurationMs: cursorClickEffectDurationMsRef.current, @@ -2517,6 +2526,7 @@ const VideoPlayback = forwardRef( }); overlay.setMotionBlur(cursorMotionBlur); overlay.setClickEffect(cursorClickEffect); + overlay.setClickEffectColor(cursorClickEffectColor); overlay.setClickEffectScale(cursorClickEffectScale); overlay.setClickEffectOpacity(cursorClickEffectOpacity); overlay.setClickEffectDurationMs(cursorClickEffectDurationMs); @@ -2552,6 +2562,7 @@ const VideoPlayback = forwardRef( cursorSpringMassMultiplier, cursorMotionBlur, cursorClickEffect, + cursorClickEffectColor, cursorClickEffectScale, cursorClickEffectOpacity, cursorClickEffectDurationMs, @@ -2771,10 +2782,11 @@ const VideoPlayback = forwardRef( return (
{/* Background layer */} diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 0d9a8646..fbf354ab 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -40,6 +40,7 @@ type PersistedEditorControls = Pick< | "cameraSpringMassMultiplier" | "cursorMotionBlur" | "cursorClickEffect" + | "cursorClickEffectColor" | "cursorClickEffectScale" | "cursorClickEffectOpacity" | "cursorClickEffectDurationMs" @@ -127,6 +128,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier, cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur, cursorClickEffect: DEFAULT_EDITOR_CONTROLS.cursorClickEffect, + cursorClickEffectColor: DEFAULT_EDITOR_CONTROLS.cursorClickEffectColor, cursorClickEffectScale: DEFAULT_EDITOR_CONTROLS.cursorClickEffectScale, cursorClickEffectOpacity: DEFAULT_EDITOR_CONTROLS.cursorClickEffectOpacity, cursorClickEffectDurationMs: DEFAULT_EDITOR_CONTROLS.cursorClickEffectDurationMs, @@ -320,6 +322,8 @@ function normalizeEditorControls( sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier, cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur, cursorClickEffect: sanitizedRaw.cursorClickEffect ?? fallback.cursorClickEffect, + cursorClickEffectColor: + sanitizedRaw.cursorClickEffectColor ?? fallback.cursorClickEffectColor, cursorClickEffectScale: sanitizedRaw.cursorClickEffectScale ?? fallback.cursorClickEffectScale, cursorClickEffectOpacity: @@ -388,6 +392,7 @@ function normalizeEditorControls( cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier, cursorMotionBlur: normalized.cursorMotionBlur, cursorClickEffect: normalized.cursorClickEffect, + cursorClickEffectColor: normalized.cursorClickEffectColor, cursorClickEffectScale: normalized.cursorClickEffectScale, cursorClickEffectOpacity: normalized.cursorClickEffectOpacity, cursorClickEffectDurationMs: normalized.cursorClickEffectDurationMs, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 2fcc34c8..704371a6 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -41,6 +41,7 @@ import { DEFAULT_CONNECTED_ZOOM_GAP_MS, DEFAULT_CROP_REGION, DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, DEFAULT_CURSOR_CLICK_EFFECT_SCALE, @@ -67,6 +68,7 @@ import { DEFAULT_ZOOM_OUT_EASING, DEFAULT_ZOOM_SMOOTHNESS, getDefaultCaptionFontFamily, + normalizeCursorClickEffectColor, normalizeCursorClickEffectStyle, type Padding, type SpeedRegion, @@ -104,6 +106,7 @@ export interface ProjectEditorState { loopCursor: boolean; cursorStyle: CursorStyle; cursorClickEffect: CursorClickEffectStyle; + cursorClickEffectColor: string; cursorClickEffectScale: number; cursorClickEffectOpacity: number; cursorClickEffectDurationMs: number; @@ -880,6 +883,10 @@ export function normalizeProjectEditor(editor: Partial): Pro 1200, ) : DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS; + const normalizedCursorClickEffectColor = normalizeCursorClickEffectColor( + (editor as Partial).cursorClickEffectColor, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, + ); const normalizedMotionPreset = CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)]; @@ -911,6 +918,7 @@ export function normalizeProjectEditor(editor: Partial): Pro (editor as Partial).cursorClickEffect, DEFAULT_CURSOR_CLICK_EFFECT, ), + cursorClickEffectColor: normalizedCursorClickEffectColor, cursorClickEffectScale: normalizedCursorClickEffectScale, cursorClickEffectOpacity: normalizedCursorClickEffectOpacity, cursorClickEffectDurationMs: normalizedCursorClickEffectDurationMs, diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts index 41a75095..c7452a69 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -27,9 +27,7 @@ export function useTimelineSelection({ totalMs, currentTimeMs, zoomRegions, - clipRegions, annotationRegions, - audioRegions, selectedZoomId, selectedClipId, selectedAnnotationId, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 7721738b..9bc401d5 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -47,6 +47,7 @@ export interface CursorVisualSettings { clickBounce: number; clickBounceDuration: number; clickEffect: CursorClickEffectStyle; + clickEffectColor: string; clickEffectScale: number; clickEffectOpacity: number; clickEffectDurationMs: number; @@ -57,8 +58,9 @@ export interface CursorVisualSettings { export type CursorStyle = "macos" | "tahoe" | "tahoe-inverted" | "dot" | "figma" | (string & {}); // extension-contributed cursor styles export const DEFAULT_CURSOR_STYLE: CursorStyle = "tahoe"; -export type CursorClickEffectStyle = "none" | "ripple" | "spotlight" | "burst"; -export const DEFAULT_CURSOR_CLICK_EFFECT: CursorClickEffectStyle = "ripple"; +export type CursorClickEffectStyle = "none" | "spotlight" | "ripple" | "echo"; +export const DEFAULT_CURSOR_CLICK_EFFECT: CursorClickEffectStyle = "none"; +export const DEFAULT_CURSOR_CLICK_EFFECT_COLOR = "#2563EB"; export const DEFAULT_CURSOR_CLICK_EFFECT_SCALE = 1; export const DEFAULT_CURSOR_CLICK_EFFECT_OPACITY = 1; export const DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS = 600; @@ -67,11 +69,36 @@ export function normalizeCursorClickEffectStyle( value: unknown, fallback: CursorClickEffectStyle = DEFAULT_CURSOR_CLICK_EFFECT, ): CursorClickEffectStyle { - return value === "none" || value === "ripple" || value === "spotlight" || value === "burst" + if (value === "burst") { + return "echo"; + } + + return value === "none" || value === "spotlight" || value === "ripple" || value === "echo" ? value : fallback; } +export function normalizeCursorClickEffectColor( + value: unknown, + fallback: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, +): string { + if (typeof value !== "string") { + return fallback; + } + + const trimmed = value.trim(); + if (!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) { + return fallback; + } + + if (trimmed.length === 4) { + const [red, green, blue] = trimmed.slice(1).split(""); + return `#${red}${red}${green}${green}${blue}${blue}`.toUpperCase(); + } + + return trimmed.toUpperCase(); +} + export type EditorEffectSection = | "scene" | "cursor" diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index ed7070a7..3ede3d17 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -9,10 +9,12 @@ import { type CursorTelemetryPoint, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_CLICK_EFFECT, + DEFAULT_CURSOR_CLICK_EFFECT_COLOR, DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, DEFAULT_CURSOR_CLICK_EFFECT_SCALE, DEFAULT_CURSOR_STYLE, + normalizeCursorClickEffectColor, } from "../types"; import { computeCursorSwayRotation } from "./cursorSway"; import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; @@ -92,6 +94,8 @@ export interface CursorRenderConfig { clickBounceDuration: number; /** Click effect graphics rendered around the pointer. */ clickEffect: CursorClickEffectStyle; + /** Click effect base color. */ + clickEffectColor: string; /** Click effect size multiplier. */ clickEffectScale: number; /** Click effect opacity multiplier. */ @@ -119,6 +123,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { clickBounce: 1, clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, clickEffect: DEFAULT_CURSOR_CLICK_EFFECT, + clickEffectColor: DEFAULT_CURSOR_CLICK_EFFECT_COLOR, clickEffectScale: DEFAULT_CURSOR_CLICK_EFFECT_SCALE, clickEffectOpacity: DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, clickEffectDurationMs: DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, @@ -274,6 +279,53 @@ async function createCursorPackAsset( }; } +async function createRasterizedCursorAsset( + url: string, + anchor: { x: number; y: number }, +): Promise { + const image = await loadImage(url); + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = image.naturalWidth; + sourceCanvas.height = image.naturalHeight; + const sourceCtx = sourceCanvas.getContext("2d"); + if (!sourceCtx) { + await Assets.load(url); + const texture = configureCursorTexture(Texture.from(url)); + return { + texture, + image, + aspectRatio: image.naturalHeight > 0 ? image.naturalWidth / image.naturalHeight : 1, + anchorX: clamp(anchor.x, 0, 1), + anchorY: clamp(anchor.y, 0, 1), + }; + } + + sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height); + sourceCtx.drawImage(image, 0, 0); + + const trimmed = trimCanvasToAlpha(sourceCanvas, { + x: sourceCanvas.width * clamp(anchor.x, 0, 1), + y: sourceCanvas.height * clamp(anchor.y, 0, 1), + }); + await Assets.load(trimmed.dataUrl); + const trimmedImage = await loadImage(trimmed.dataUrl); + const texture = configureCursorTexture(Texture.from(trimmed.dataUrl)); + + return { + texture, + image: trimmedImage, + aspectRatio: trimmed.height > 0 ? trimmed.width / trimmed.height : 1, + anchorX: + trimmed.hotspot && trimmed.width > 0 + ? clamp(trimmed.hotspot.x / trimmed.width, 0, 1) + : clamp(anchor.x, 0, 1), + anchorY: + trimmed.hotspot && trimmed.height > 0 + ? clamp(trimmed.hotspot.y / trimmed.height, 0, 1) + : clamp(anchor.y, 0, 1), + }; +} + function loadImage(dataUrl: string) { return new Promise((resolve, reject) => { const image = new Image(); @@ -290,7 +342,7 @@ function clamp(value: number, min: number, max: number) { function configureCursorTexture(texture: Texture) { texture.source.scaleMode = "linear"; - texture.source.autoGenerateMipmaps = true; + texture.source.autoGenerateMipmaps = false; return texture; } @@ -499,22 +551,14 @@ export async function preloadCursorAssets() { } try { - await Assets.load(sourceAsset.url); - const image = await loadImage(sourceAsset.url); - const texture = configureCursorTexture(Texture.from(sourceAsset.url)); + const asset = await createRasterizedCursorAsset( + sourceAsset.url, + sourceAsset.fallbackAnchor, + ); return [ key, - { - texture, - image, - aspectRatio: - image.naturalHeight > 0 - ? image.naturalWidth / image.naturalHeight - : 1, - anchorX: clamp(sourceAsset.fallbackAnchor.x, 0, 1), - anchorY: clamp(sourceAsset.fallbackAnchor.y, 0, 1), - } satisfies LoadedCursorAsset, + asset, ] as const; } catch (error) { console.warn( @@ -764,8 +808,7 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num } function getCursorViewportScale(viewport: CursorViewportRect) { - const renderWidth = viewport.renderWidth ?? viewport.width; - return Math.max(MIN_CURSOR_VIEWPORT_SCALE, renderWidth / REFERENCE_WIDTH); + return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) { @@ -789,17 +832,9 @@ function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: Cursor }; } -function getClickEffectColor(interactionType?: CursorTelemetryPoint["interactionType"]) { - switch (interactionType) { - case "right-click": - return 0xf59e0b; - case "middle-click": - return 0xa855f7; - case "double-click": - return 0x22c55e; - default: - return 0x2563eb; - } +function getClickEffectColor(clickEffectColor: string) { + const normalized = normalizeCursorClickEffectColor(clickEffectColor); + return Number.parseInt(normalized.slice(1), 16); } function getExtensionStyleRippleMetrics( @@ -827,7 +862,7 @@ function drawClickEffectGraphics( clickProgress: number, effectScale: number, effectOpacity: number, - interactionType?: CursorTelemetryPoint["interactionType"], + effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, ) { graphics.clear(); if (effect === "none" || clickProgress <= 0) { @@ -836,7 +871,7 @@ function drawClickEffectGraphics( const reveal = 1 - clickProgress; const alpha = clickProgress * effectOpacity; - const color = getClickEffectColor(interactionType); + const color = getClickEffectColor(effectColor); const baseRadius = Math.max(12, cursorSize * 0.55 * effectScale); const strokeWidth = Math.max(2, cursorSize * 0.08); @@ -861,17 +896,14 @@ function drawClickEffectGraphics( return; } - const burstRadius = baseRadius + reveal * cursorSize * 1.15 * effectScale; - graphics.circle(px, py, burstRadius * 0.78); - graphics.stroke({ width: strokeWidth, color, alpha: alpha * 0.7 }); - for (let index = 0; index < 8; index += 1) { - const angle = (Math.PI * 2 * index) / 8 + reveal * 0.35; - const startRadius = burstRadius * 0.95; - const endRadius = burstRadius * 1.4; - graphics.moveTo(px + Math.cos(angle) * startRadius, py + Math.sin(angle) * startRadius); - graphics.lineTo(px + Math.cos(angle) * endRadius, py + Math.sin(angle) * endRadius); - } - graphics.stroke({ width: Math.max(1.25, strokeWidth * 0.58), color, alpha: alpha * 0.88 }); + const echoOuterRadius = baseRadius + reveal * cursorSize * 1.22 * effectScale; + const echoInnerRadius = Math.max(baseRadius * 0.58, echoOuterRadius * 0.62); + graphics.circle(px, py, echoOuterRadius); + graphics.stroke({ width: strokeWidth, color, alpha: alpha * 0.72 }); + graphics.circle(px, py, echoInnerRadius); + graphics.stroke({ width: Math.max(1.4, strokeWidth * 0.72), color, alpha: alpha * 0.42 }); + graphics.circle(px, py, Math.max(3, baseRadius * 0.18)); + graphics.fill({ color, alpha: alpha * 0.14 }); } function drawClickEffectOnCanvas( @@ -883,7 +915,7 @@ function drawClickEffectOnCanvas( clickProgress: number, effectScale: number, effectOpacity: number, - interactionType?: CursorTelemetryPoint["interactionType"], + effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, ) { if (effect === "none" || clickProgress <= 0) { return; @@ -891,7 +923,7 @@ function drawClickEffectOnCanvas( const reveal = 1 - clickProgress; const alpha = clickProgress * effectOpacity; - const color = getClickEffectColor(interactionType); + const color = getClickEffectColor(effectColor); const strokeColor = `rgba(${(color >> 16) & 255}, ${(color >> 8) & 255}, ${color & 255}, `; const baseRadius = Math.max(12, cursorSize * 0.55 * effectScale); const strokeWidth = Math.max(2, cursorSize * 0.08); @@ -929,23 +961,22 @@ function drawClickEffectOnCanvas( return; } - const burstRadius = baseRadius + reveal * cursorSize * 1.15 * effectScale; + const echoOuterRadius = baseRadius + reveal * cursorSize * 1.22 * effectScale; + const echoInnerRadius = Math.max(baseRadius * 0.58, echoOuterRadius * 0.62); ctx.lineWidth = strokeWidth; - ctx.strokeStyle = `${strokeColor}${(alpha * 0.7).toFixed(3)})`; + ctx.strokeStyle = `${strokeColor}${(alpha * 0.72).toFixed(3)})`; ctx.beginPath(); - ctx.arc(px, py, burstRadius * 0.78, 0, Math.PI * 2); + ctx.arc(px, py, echoOuterRadius, 0, Math.PI * 2); ctx.stroke(); - ctx.lineWidth = Math.max(1.25, strokeWidth * 0.58); - ctx.strokeStyle = `${strokeColor}${(alpha * 0.88).toFixed(3)})`; + ctx.lineWidth = Math.max(1.4, strokeWidth * 0.72); + ctx.strokeStyle = `${strokeColor}${(alpha * 0.42).toFixed(3)})`; ctx.beginPath(); - for (let index = 0; index < 8; index += 1) { - const angle = (Math.PI * 2 * index) / 8 + reveal * 0.35; - const startRadius = burstRadius * 0.95; - const endRadius = burstRadius * 1.4; - ctx.moveTo(px + Math.cos(angle) * startRadius, py + Math.sin(angle) * startRadius); - ctx.lineTo(px + Math.cos(angle) * endRadius, py + Math.sin(angle) * endRadius); - } + ctx.arc(px, py, echoInnerRadius, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = `${strokeColor}${(alpha * 0.14).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, Math.max(3, baseRadius * 0.18), 0, Math.PI * 2); + ctx.fill(); ctx.restore(); } @@ -973,6 +1004,7 @@ function getCursorVisualState( return { cursorType: findLatestStableCursorType(samples, timeMs), interactionType, + clickSample: latestClick && isClickEvent ? latestClick : null, clickBounceProgress, clickProgress: latestClick && @@ -1112,6 +1144,7 @@ export class PixiCursorOverlay { initialCustomAsset.anchorY, ); this.customCursorShadowSprite.visible = false; + this.customCursorShadowSprite.roundPixels = true; this.customCursorShadowSprite.tint = CURSOR_SHADOW_COLOR; this.customCursorShadowSprite.alpha = CURSOR_SHADOW_ALPHA; this.customCursorShadowFilter = new BlurFilter(); @@ -1123,6 +1156,7 @@ export class PixiCursorOverlay { this.customCursorSprite = new Sprite(initialCustomAsset.texture); this.customCursorSprite.anchor.set(initialCustomAsset.anchorX, initialCustomAsset.anchorY); this.customCursorSprite.visible = false; + this.customCursorSprite.roundPixels = true; this.cursorShadowSprites = {}; this.cursorShadowFilters = {}; this.cursorSprites = {}; @@ -1131,6 +1165,7 @@ export class PixiCursorOverlay { const shadowSprite = new Sprite(asset.texture); shadowSprite.anchor.set(asset.anchorX, asset.anchorY); shadowSprite.visible = false; + shadowSprite.roundPixels = true; shadowSprite.tint = CURSOR_SHADOW_COLOR; shadowSprite.alpha = CURSOR_SHADOW_ALPHA; const shadowFilter = new BlurFilter(); @@ -1144,6 +1179,7 @@ export class PixiCursorOverlay { const sprite = new Sprite(asset.texture); sprite.anchor.set(asset.anchorX, asset.anchorY); sprite.visible = false; + sprite.roundPixels = true; this.cursorSprites[key] = sprite; } @@ -1196,6 +1232,10 @@ export class PixiCursorOverlay { this.config.clickEffect = clickEffect; } + setClickEffectColor(clickEffectColor: string) { + this.config.clickEffectColor = normalizeCursorClickEffectColor(clickEffectColor); + } + setClickEffectScale(clickEffectScale: number) { this.config.clickEffectScale = clamp(clickEffectScale, 0.5, 2); } @@ -1313,13 +1353,24 @@ export class PixiCursorOverlay { const px = viewport.x + this.state.x * viewport.width; const py = viewport.y + this.state.y * viewport.height; const h = this.config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, interactionType, clickBounceProgress, clickProgress } = + const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( samples, timeMs, this.config.clickBounceDuration, this.config.clickEffectDurationMs, ); + const projectedClickSample = clickSample + ? projectCursorPositionToViewport(clickSample, viewport.sourceCrop) + : null; + const clickEffectPx = + projectedClickSample && projectedClickSample.visible + ? viewport.x + projectedClickSample.cx * viewport.width + : px; + const clickEffectPy = + projectedClickSample && projectedClickSample.visible + ? viewport.y + projectedClickSample.cy * viewport.height + : py; const bounceScale = Math.max( 0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce), @@ -1330,13 +1381,13 @@ export class PixiCursorOverlay { drawClickEffectGraphics( this.clickRingGraphics, this.config.clickEffect, - px, - py, + clickEffectPx, + clickEffectPy, scaledH, clickProgress, this.config.clickEffectScale, this.config.clickEffectOpacity, - interactionType, + this.config.clickEffectColor, ); const spriteKey = ( @@ -1534,13 +1585,24 @@ export function drawCursorOnCanvas( const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; const h = config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, interactionType, clickBounceProgress, clickProgress } = + const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( samples, timeMs, config.clickBounceDuration, config.clickEffectDurationMs, ); + const projectedClickSample = clickSample + ? projectCursorPositionToViewport(clickSample, viewport.sourceCrop) + : null; + const clickEffectPx = + projectedClickSample && projectedClickSample.visible + ? viewport.x + projectedClickSample.cx * viewport.width + : px; + const clickEffectPy = + projectedClickSample && projectedClickSample.visible + ? viewport.y + projectedClickSample.cy * viewport.height + : py; const spriteKey = ( cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow" ) as CursorAssetKey; @@ -1557,13 +1619,13 @@ export function drawCursorOnCanvas( drawClickEffectOnCanvas( ctx, config.clickEffect, - px, - py, + clickEffectPx, + clickEffectPy, drawHeight, clickProgress, config.clickEffectScale, config.clickEffectOpacity, - interactionType, + config.clickEffectColor, ); ctx.save(); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 1b2aef7b..66af0254 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -3,6 +3,8 @@ import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; import type { CropRegion, Padding } from "../types"; export const PADDING_SCALE_FACTOR = 0.2; +export const BASE_PREVIEW_WIDTH = 1920; +export const BASE_PREVIEW_HEIGHT = 1080; export function isZeroPadding(padding: Padding | number): boolean { if (typeof padding === "number") { @@ -196,12 +198,13 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { videoSprite.position.set(layout.spriteX, layout.spriteY); maskGraphics.clear(); + const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT); drawSquircleOnGraphics(maskGraphics, { x: layout.centerOffsetX, y: layout.centerOffsetY, width: layout.croppedDisplayWidth, height: layout.croppedDisplayHeight, - radius: borderRadius, + radius: Math.max(0, borderRadius * canvasScaleFactor), }); maskGraphics.fill({ color: 0xffffff }); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 97746633..4aa92078 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -102,6 +102,7 @@ "advanced": "Advanced", "advancedShow": "Show advanced click effect controls", "advancedHide": "Hide advanced click effect controls", + "color": "Effect Color", "size": "Effect Size", "opacity": "Effect Opacity", "duration": "Effect Duration", diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 4ebc1205..8b2e335c 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -5,6 +5,7 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, + CursorClickEffectStyle, CropRegion, CursorStyle, CursorTelemetryPoint, @@ -130,6 +131,11 @@ interface FrameRenderConfig { zoomSmoothness?: number; zoomClassicMode?: boolean; cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; @@ -442,6 +448,17 @@ export class FrameRenderer { massMultiplier: this.config.cursorSpringMassMultiplier, }, motionBlur: this.config.cursorMotionBlur ?? 0, + clickEffect: + this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, + clickEffectColor: + this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor, + clickEffectScale: + this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale, + clickEffectOpacity: + this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity, + clickEffectDurationMs: + this.config.cursorClickEffectDurationMs ?? + DEFAULT_CURSOR_CONFIG.clickEffectDurationMs, clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, clickBounceDuration: this.config.cursorClickBounceDuration ?? diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 4c833852..eb5fa6b6 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -3,6 +3,7 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, + CursorClickEffectStyle, CropRegion, CursorStyle, CursorTelemetryPoint, @@ -79,6 +80,11 @@ interface GifExporterConfig { zoomSmoothness?: number; zoomClassicMode?: boolean; cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 5372937f..6ab918d8 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -15,6 +15,7 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, + CursorClickEffectStyle, CropRegion, CursorStyle, CursorTelemetryPoint, @@ -146,6 +147,11 @@ interface FrameRenderConfig { cameraSpringDampingMultiplier?: number; cameraSpringMassMultiplier?: number; cursorMotionBlur?: number; + cursorClickEffect?: CursorClickEffectStyle; + cursorClickEffectColor?: string; + cursorClickEffectScale?: number; + cursorClickEffectOpacity?: number; + cursorClickEffectDurationMs?: number; cursorClickBounce?: number; cursorClickBounceDuration?: number; cursorSway?: number; @@ -616,6 +622,17 @@ export class FrameRenderer { massMultiplier: this.config.cursorSpringMassMultiplier, }, motionBlur: this.config.cursorMotionBlur ?? 0, + clickEffect: + this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, + clickEffectColor: + this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor, + clickEffectScale: + this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale, + clickEffectOpacity: + this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity, + clickEffectDurationMs: + this.config.cursorClickEffectDurationMs ?? + DEFAULT_CURSOR_CONFIG.clickEffectDurationMs, clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, clickBounceDuration: this.config.cursorClickBounceDuration ??