Polish click effects and preview rendering

This commit is contained in:
webadderall committed 2026-05-30 14:16:21 +10:00
1 parent 41d5291467
commit e046150165
13 files changed
+668 -275

No files matched your search

+128 -32
View File
@@ -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 (
<div
className="relative flex items-center justify-center"
@@ -492,14 +522,19 @@ function CursorClickEffectPreview({ effect }: { effect: CursorClickEffectStyle }
}}
>
{effect === "none" ? (
<div className="relative flex h-10 w-10 items-center justify-center">
<span className="absolute h-7 w-7 rounded-full border border-foreground/12" />
<span className="absolute h-px w-8 -rotate-12 rounded-full bg-foreground/35" />
</div>
<svg
className="absolute h-10 w-10 text-foreground/40"
viewBox="0 0 40 40"
aria-hidden="true"
>
<circle cx="20" cy="20" r="11.5" fill="none" stroke="currentColor" strokeWidth="1.8" opacity="0.75" />
<path d="M12.5 27.5 27.5 12.5" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="2.2" opacity="0.92" />
</svg>
) : null}
{effect === "ripple" ? (
<svg
className="absolute h-12 w-12 text-[#2563EB]"
className="absolute h-12 w-12"
style={{ color }}
viewBox="0 0 48 48"
aria-hidden="true"
>
@@ -516,32 +551,34 @@ function CursorClickEffectPreview({ effect }: { effect: CursorClickEffectStyle }
) : null}
{effect === "spotlight" ? (
<>
<span className="absolute h-12 w-12 rounded-full bg-[#2563EB]/18 blur-[8px]" />
<span className="absolute h-9 w-9 rounded-full bg-[#2563EB]/10" />
<span className="absolute h-7 w-7 rounded-full border-[1.5px] border-[#2563EB]/42" />
<span
className="absolute h-12 w-12 rounded-full blur-[8px]"
style={{ backgroundColor: hexToRgba(color, 0.18) }}
/>
<span
className="absolute h-9 w-9 rounded-full"
style={{ backgroundColor: hexToRgba(color, 0.1) }}
/>
<span
className="absolute h-7 w-7 rounded-full border-[1.5px]"
style={{ borderColor: hexToRgba(color, 0.42) }}
/>
</>
) : null}
{effect === "burst" ? (
{effect === "echo" ? (
<svg
className="absolute h-12 w-12 text-[#2563EB]/90"
className="absolute h-12 w-12"
style={{ color: hexToRgba(color, 0.92) }}
viewBox="0 0 48 48"
aria-hidden="true"
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="24" cy="24" r="8.5" strokeWidth="1.8" opacity="0.54" />
<path d="M24 7.5v6.5" strokeWidth="1.8" opacity="0.9" />
<path d="M24 34v6.5" strokeWidth="1.8" opacity="0.9" />
<path d="M7.5 24H14" strokeWidth="1.8" opacity="0.9" />
<path d="M34 24h6.5" strokeWidth="1.8" opacity="0.9" />
<path d="M12.3 12.3l4.6 4.6" strokeWidth="1.7" opacity="0.82" />
<path d="M31.1 31.1l4.6 4.6" strokeWidth="1.7" opacity="0.82" />
<path d="M35.7 12.3l-4.6 4.6" strokeWidth="1.7" opacity="0.82" />
<path d="M16.9 31.1l-4.6 4.6" strokeWidth="1.7" opacity="0.82" />
<circle cx="24" cy="24" r="9" strokeWidth="1.8" opacity="0.72" />
<circle cx="24" cy="24" r="14.5" strokeWidth="1.5" opacity="0.4" />
<circle cx="24" cy="24" r="4.25" fill="currentColor" opacity="0.22" stroke="none" />
</g>
</svg>
) : 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({
)}
>
<div className="flex h-full flex-col items-center justify-between gap-3">
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg px-2 py-1.5">
<CursorClickEffectPreview effect={effect.id} />
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-[8px] px-2 py-1.5">
<CursorClickEffectPreview effect={effect.id} color={effectColor} />
</div>
</div>
</ToggleGroupItem>
@@ -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<HTMLInputElement | null>(null);
const cursorClickEffectColorInputRef = useRef<HTMLInputElement | null>(null);
const defaultWebcam = initialEditorPreferences.webcam;
const [internalActiveEffectSection] = useState<EditorEffectSection>("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 ? (
<div className="grid gap-1.5">
<input
ref={cursorClickEffectColorInputRef}
type="color"
value={cursorClickEffectColor}
onChange={(event) =>
onCursorClickEffectColorChange?.(event.target.value)
}
className="sr-only"
/>
<div className="grid gap-1">
<div className="text-[10px] text-muted-foreground">
{tSettings(
"effects.cursorClickEffects.color",
"Effect Color",
)}
</div>
<div className="flex flex-wrap gap-1.5">
{CLICK_EFFECT_COLOR_OPTIONS.map((color) => {
const isSelected =
cursorClickEffectColor.toLowerCase() === color.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => onCursorClickEffectColorChange?.(color)}
className={cn(
"h-6 w-6 rounded-[8px] border transition-transform hover:scale-[1.04]",
isSelected
? "border-foreground/80 ring-1 ring-[#2563EB]/50"
: "border-foreground/10",
)}
style={{ backgroundColor: color }}
aria-label={`Effect color ${color}`}
/>
);
})}
<button
type="button"
onClick={() => cursorClickEffectColorInputRef.current?.click()}
className="relative h-6 w-10 overflow-hidden rounded-[8px] border border-foreground/10 text-[8px] font-semibold uppercase tracking-[0.18em] text-foreground"
style={{
background: `linear-gradient(135deg, ${cursorClickEffectColor} 0%, ${cursorClickEffectColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`,
}}
aria-label="Custom effect color picker"
>
<div className="absolute inset-0 flex items-center justify-center">
Pick
</div>
</button>
</div>
</div>
<SliderControl
label={tSettings(
"effects.cursorClickEffects.size",
+315 -174
View File
@@ -1,8 +1,9 @@
import {
ArrowsInSimple,
ArrowsOutSimple,
BookmarkSimple,
Check,
CaretDown as ChevronDown,
CaretUp as ChevronUp,
ClosedCaptioning,
Crop,
Cursor,
@@ -188,7 +189,6 @@ import {
DEFAULT_CONNECTED_ZOOM_EASING,
DEFAULT_CONNECTED_ZOOM_GAP_MS,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_EFFECT,
DEFAULT_CURSOR_STYLE,
DEFAULT_FIGURE_DATA,
DEFAULT_WEBCAM_OVERLAY,
@@ -226,10 +226,6 @@ import {
type PendingExportSave = {
fileName: string;
// Exactly one of these is populated. `tempFilePath` is the preferred form
// for MP4 exports — the main process holds the finished file on disk, so
// "Save Again" just renames it instead of round-tripping through the
// renderer's ArrayBuffer heap.
arrayBuffer?: ArrayBuffer;
tempFilePath?: string;
};
@@ -472,6 +468,9 @@ export default function VideoEditor() {
const [cursorClickEffect, setCursorClickEffect] = useState<CursorClickEffectStyle>(
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<Record<string, unknown>>({});
const [historyVersion, setHistoryVersion] = useState(0);
const timelineRef = useRef<TimelineEditorHandle>(null);
const previewShellRef = useRef<HTMLDivElement | null>(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",
) => (
<VideoPlayback
key={`${videoPath || "no-video"}:${previewVersion}:${keySuffix}`}
aspectRatio={aspectRatio}
ref={playbackRef}
videoPath={videoPath || ""}
onDurationChange={setDuration}
onPreviewReadyChange={setIsPreviewReady}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={effectiveZoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
showShadow={shadowIntensity > 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 = (
<ProjectBrowserDialog
@@ -5800,7 +5985,9 @@ export default function VideoEditor() {
cursorMotionBlur={cursorMotionBlur}
onCursorMotionBlurChange={setCursorMotionBlur}
cursorClickEffect={cursorClickEffect}
cursorClickEffectColor={cursorClickEffectColor}
onCursorClickEffectChange={setCursorClickEffect}
onCursorClickEffectColorChange={setCursorClickEffectColor}
cursorClickEffectScale={cursorClickEffectScale}
onCursorClickEffectScaleChange={setCursorClickEffectScale}
cursorClickEffectOpacity={cursorClickEffectOpacity}
@@ -5923,136 +6110,115 @@ export default function VideoEditor() {
>
<div className="flex min-w-0 flex-1 items-center justify-center px-1">
<div
className="relative overflow-hidden rounded-[30px]"
ref={previewShellRef}
className={cn(
"relative",
isPreviewPlayerOpen &&
"flex h-full w-full items-center justify-center",
)}
style={{
width: "auto",
height: "100%",
aspectRatio: getAspectRatioValue(
aspectRatio,
(() => {
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",
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${previewVersion}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
onDurationChange={setDuration}
onPreviewReadyChange={setIsPreviewReady}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={effectiveZoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
showShadow={shadowIntensity > 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}
/>
<div
className="relative"
style={{
width: isPreviewPlayerOpen ? "100%" : "auto",
height: isPreviewPlayerOpen ? "auto" : "100%",
aspectRatio: previewAspectRatioValue,
maxWidth: isPreviewPlayerOpen
? `min(100vw, calc(100vh * ${previewAspectRatioValue}))`
: "100%",
maxHeight: isPreviewPlayerOpen ? "100vh" : "100%",
margin: isPreviewPlayerOpen ? "0 auto" : "0",
}}
>
{renderPreviewPlayback(
videoPlaybackRef,
shouldSuspendPreviewRendering,
"inline",
)}
</div>
{isPreviewPlayerOpen ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center px-6 pb-6">
<div className="pointer-events-auto flex w-full max-w-5xl items-center gap-3 rounded-[20px] border border-white/10 bg-black/72 px-4 py-3 text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<Button
variant="ghost"
size="icon"
onClick={() => void handleTogglePreviewFullscreen()}
className="h-10 w-10 rounded-full text-white/82 transition-colors hover:bg-white/10 hover:text-white"
title={t("editor.preview.exitFullscreen", "Exit fullscreen preview")}
>
<ArrowsInSimple className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handlePreviewSkipBack}
className="h-10 w-10 rounded-full text-white/82 transition-colors hover:bg-white/10 hover:text-white"
title={t("editor.playback.skipBack")}
>
<SkipBack className="h-4 w-4" weight="fill" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={togglePlayPause}
className="h-11 w-11 rounded-full border border-white/12 bg-white/10 text-white transition-colors hover:bg-white/18"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className="h-4 w-4" weight="fill" />
) : (
<Play className="h-4 w-4" weight="fill" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handlePreviewSkipForward}
className="h-10 w-10 rounded-full text-white/82 transition-colors hover:bg-white/10 hover:text-white"
title={t("editor.playback.skipForward")}
>
<SkipForward className="h-4 w-4" weight="fill" />
</Button>
<span className="w-12 text-right text-xs font-medium tabular-nums text-white/78">
{formatTime(timelinePlayheadTime)}
</span>
<input
type="range"
min="0"
max={Math.max(0.01, timelineDuration)}
step="0.01"
value={Math.min(timelinePlayheadTime, Math.max(0.01, timelineDuration))}
onChange={(event) => handleSeek(Number(event.target.value))}
className="h-2 flex-1 cursor-pointer accent-white"
/>
<span className="w-12 text-xs font-medium tabular-nums text-white/58">
{formatTime(timelineDuration)}
</span>
<button
type="button"
className="text-white/82 transition-colors hover:text-white"
title={t("editor.playback.muteUnmute")}
onClick={() => setPreviewVolume(previewVolume <= 0.001 ? 1 : 0)}
>
{previewVolume <= 0.001 ? (
<VolumeX className="h-4 w-4" />
) : previewVolume < 0.5 ? (
<Volume1 className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
@@ -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}
>
<SkipBack className="w-3.5 h-3.5" weight="fill" />
</Button>
@@ -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}
>
<SkipForward className="w-3.5 h-3.5" weight="fill" />
</Button>
@@ -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 ? (
<ChevronUp className="w-3.5 h-3.5" />
{isPreviewPlayerOpen ? (
<ArrowsInSimple className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
<ArrowsOutSimple className="w-3.5 h-3.5" />
)}
</Button>
<div className="flex items-center gap-1.5">
@@ -6284,8 +6425,8 @@ export default function VideoEditor() {
<div
className="flex-shrink-0 flex flex-col"
style={{
height: timelineCollapsed ? undefined : "15%",
minHeight: timelineCollapsed ? 0 : 160,
height: "15%",
minHeight: 160,
}}
>
<TimelineEditor
+13 -1
View File
@@ -135,6 +135,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,
@@ -378,6 +379,7 @@ interface VideoPlaybackProps {
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
cursorMotionBlur?: number;
cursorClickEffect?: CursorClickEffectStyle;
cursorClickEffectColor?: string;
cursorClickEffectScale?: number;
cursorClickEffectOpacity?: number;
cursorClickEffectDurationMs?: number;
@@ -460,6 +462,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
cursorClickEffectRef.current = cursorClickEffect;
}, [cursorClickEffect]);
useEffect(() => {
cursorClickEffectColorRef.current = cursorClickEffectColor;
}, [cursorClickEffectColor]);
useEffect(() => {
cursorClickEffectScaleRef.current = cursorClickEffectScale;
}, [cursorClickEffectScale]);
@@ -1930,6 +1938,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
},
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<VideoPlaybackRef, VideoPlaybackProps>(
});
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<VideoPlaybackRef, VideoPlaybackProps>(
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickEffect,
cursorClickEffectColor,
cursorClickEffectScale,
cursorClickEffectOpacity,
cursorClickEffectDurationMs,
@@ -2771,10 +2782,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
className="relative rounded-sm overflow-hidden"
className="relative overflow-hidden"
style={{
width: "100%",
aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio),
borderRadius: `${Math.max(0, borderRadius)}px`,
}}
>
{/* Background layer */}
@@ -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,
@@ -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<ProjectEditorState>): Pro
1200,
)
: DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS;
const normalizedCursorClickEffectColor = normalizeCursorClickEffectColor(
(editor as Partial<ProjectEditorState>).cursorClickEffectColor,
DEFAULT_CURSOR_CLICK_EFFECT_COLOR,
);
const normalizedMotionPreset =
CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)];
@@ -911,6 +918,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
(editor as Partial<ProjectEditorState>).cursorClickEffect,
DEFAULT_CURSOR_CLICK_EFFECT,
),
cursorClickEffectColor: normalizedCursorClickEffectColor,
cursorClickEffectScale: normalizedCursorClickEffectScale,
cursorClickEffectOpacity: normalizedCursorClickEffectOpacity,
cursorClickEffectDurationMs: normalizedCursorClickEffectDurationMs,
@@ -27,9 +27,7 @@ export function useTimelineSelection({
totalMs,
currentTimeMs,
zoomRegions,
clipRegions,
annotationRegions,
audioRegions,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
+30 -3
View File
@@ -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"
@@ -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<LoadedCursorAsset> {
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<HTMLImageElement>((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();
@@ -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 });
+1
View File
@@ -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",
+17
View File
@@ -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 ??
+6
View File
@@ -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;
+17
View File
@@ -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 ??