mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
Polish click effects and preview rendering
This commit is contained in:
1 parent
41d5291467
commit
e046150165
13 files changed
+668
-275
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
Reference in new issue
Block a user