mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge pull request #630 from webadderallorg/feature/click-effects-auto-zoom-fps-fixes
Add click effects and fix auto zoom and FPS bitrate behavior
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -40,7 +41,6 @@ import {
|
||||
isVideoWallpaperSource,
|
||||
} from "@/lib/wallpapers";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg";
|
||||
import { useI18n, useScopedT } from "../../contexts/I18nContext";
|
||||
import type { AppLocale } from "../../i18n/config";
|
||||
import { SUPPORTED_LOCALES } from "../../i18n/config";
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
AutoCaptionSettings,
|
||||
CaptionCue,
|
||||
CropRegion,
|
||||
CursorClickEffectStyle,
|
||||
CursorStyle,
|
||||
EditorEffectSection,
|
||||
FigureData,
|
||||
@@ -76,6 +77,11 @@ import {
|
||||
DEFAULT_CROP_REGION,
|
||||
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,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
@@ -153,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";
|
||||
@@ -277,7 +306,11 @@ function ExtensionSettingsSection({
|
||||
<div key={field.id} className="mt-1">
|
||||
<SliderControl
|
||||
label={field.label}
|
||||
value={typeof value === "number" ? value : (field.defaultValue as number)}
|
||||
value={
|
||||
typeof value === "number"
|
||||
? value
|
||||
: (field.defaultValue as number)
|
||||
}
|
||||
defaultValue={field.defaultValue as number}
|
||||
min={field.min ?? 0}
|
||||
max={field.max ?? 1}
|
||||
@@ -388,6 +421,33 @@ function ExtensionSettingsSection({
|
||||
|
||||
const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"];
|
||||
|
||||
const CURSOR_CLICK_EFFECT_OPTIONS: Array<{
|
||||
id: CursorClickEffectStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "none",
|
||||
label: "Off",
|
||||
description: "No click animation. Keeps the pointer steady on every tap.",
|
||||
},
|
||||
{
|
||||
id: "spotlight",
|
||||
label: "Spotlight",
|
||||
description: "A soft pulse that blooms behind the cursor on click.",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
function MotionPresetCards({
|
||||
title,
|
||||
activePresetId,
|
||||
@@ -446,6 +506,169 @@ function MotionPresetCards({
|
||||
);
|
||||
}
|
||||
|
||||
function CursorClickEffectPreview({
|
||||
effect,
|
||||
color = DEFAULT_CURSOR_CLICK_EFFECT_COLOR,
|
||||
}: {
|
||||
effect: CursorClickEffectStyle;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{
|
||||
width: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`,
|
||||
height: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`,
|
||||
}}
|
||||
>
|
||||
{effect === "none" ? (
|
||||
<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"
|
||||
style={{ color }}
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
opacity="0.72"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
{effect === "spotlight" ? (
|
||||
<svg
|
||||
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">
|
||||
<circle cx="24" cy="24" r="13.5" strokeWidth="1.5" opacity="0.3" />
|
||||
<circle cx="24" cy="24" r="9.75" strokeWidth="1.7" opacity="0.56" />
|
||||
</g>
|
||||
</svg>
|
||||
) : null}
|
||||
{effect === "echo" ? (
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CursorClickEffectCards({
|
||||
title,
|
||||
activeEffectId,
|
||||
effectColor,
|
||||
onApply,
|
||||
showAdvanced,
|
||||
onToggleAdvanced,
|
||||
tSettings,
|
||||
}: {
|
||||
title: string;
|
||||
activeEffectId: CursorClickEffectStyle;
|
||||
effectColor: string;
|
||||
onApply: (effectId: CursorClickEffectStyle) => void;
|
||||
showAdvanced: boolean;
|
||||
onToggleAdvanced: () => void;
|
||||
tSettings: (key: string, fallback?: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] text-muted-foreground">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAdvanced}
|
||||
aria-pressed={showAdvanced}
|
||||
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
|
||||
title={
|
||||
showAdvanced
|
||||
? tSettings(
|
||||
"effects.cursorClickEffects.advancedHide",
|
||||
"Hide advanced click effect controls",
|
||||
)
|
||||
: tSettings(
|
||||
"effects.cursorClickEffects.advancedShow",
|
||||
"Show advanced click effect controls",
|
||||
)
|
||||
}
|
||||
>
|
||||
{tSettings("effects.cursorClickEffects.advanced", "Advanced")}
|
||||
</button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeEffectId}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
onApply(value as CursorClickEffectStyle);
|
||||
}
|
||||
}}
|
||||
className="grid grid-cols-4 gap-2"
|
||||
aria-label={title}
|
||||
>
|
||||
{CURSOR_CLICK_EFFECT_OPTIONS.map((effect) => {
|
||||
const label = tSettings(
|
||||
`effects.cursorClickEffects.${effect.id}.label`,
|
||||
effect.label,
|
||||
);
|
||||
const description = tSettings(
|
||||
`effects.cursorClickEffects.${effect.id}.description`,
|
||||
effect.description,
|
||||
);
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={effect.id}
|
||||
value={effect.id}
|
||||
aria-label={label}
|
||||
title={`${label} - ${description}`}
|
||||
className={cn(
|
||||
"group aspect-square h-auto min-w-0 rounded-[10px] border border-foreground/10 bg-foreground/[0.03] p-3 text-left text-foreground shadow-none transition-all hover:border-foreground/20 hover:bg-foreground/[0.06]",
|
||||
"data-[state=on]:border-[#2563EB]/70 data-[state=on]:bg-[#2563EB]/12 data-[state=on]:text-foreground",
|
||||
)}
|
||||
>
|
||||
<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 overflow-hidden rounded-[8px] px-2 py-1.5">
|
||||
<CursorClickEffectPreview effect={effect.id} color={effectColor} />
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
panelMode?: "editor" | "background";
|
||||
activeEffectSection?: EditorEffectSection;
|
||||
@@ -534,6 +757,16 @@ interface SettingsPanelProps {
|
||||
onZoomClassicModeChange?: (enabled: boolean) => void;
|
||||
cursorMotionBlur?: number;
|
||||
onCursorMotionBlurChange?: (amount: number) => void;
|
||||
cursorClickEffect?: CursorClickEffectStyle;
|
||||
onCursorClickEffectChange?: (effect: CursorClickEffectStyle) => void;
|
||||
cursorClickEffectColor?: string;
|
||||
onCursorClickEffectColorChange?: (color: string) => void;
|
||||
cursorClickEffectScale?: number;
|
||||
onCursorClickEffectScaleChange?: (scale: number) => void;
|
||||
cursorClickEffectOpacity?: number;
|
||||
onCursorClickEffectOpacityChange?: (opacity: number) => void;
|
||||
cursorClickEffectDurationMs?: number;
|
||||
onCursorClickEffectDurationMsChange?: (duration: number) => void;
|
||||
cursorClickBounce?: number;
|
||||
onCursorClickBounceChange?: (amount: number) => void;
|
||||
cursorClickBounceDuration?: number;
|
||||
@@ -790,9 +1023,13 @@ async function createInvertedPreview(url: string) {
|
||||
function CursorStylePreview({
|
||||
style,
|
||||
previewUrls,
|
||||
frameSize = BUILTIN_CURSOR_PREVIEW_FRAME_SIZE,
|
||||
previewSize,
|
||||
}: {
|
||||
style: CursorStyle;
|
||||
previewUrls: Partial<Record<string, string>>;
|
||||
frameSize?: number;
|
||||
previewSize?: number;
|
||||
}) {
|
||||
const previewSrc =
|
||||
style === "macos"
|
||||
@@ -806,13 +1043,14 @@ function CursorStylePreview({
|
||||
: previewUrls[style];
|
||||
|
||||
if (style === "macos" || style === "tahoe" || style === "tahoe-inverted") {
|
||||
const previewSize = BUILTIN_CURSOR_PREVIEW_SIZE * getCursorStyleSizeMultiplier(style);
|
||||
const resolvedPreviewSize =
|
||||
(previewSize ?? BUILTIN_CURSOR_PREVIEW_SIZE) * getCursorStyleSizeMultiplier(style);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`,
|
||||
height: `${BUILTIN_CURSOR_PREVIEW_FRAME_SIZE}px`,
|
||||
width: `${frameSize}px`,
|
||||
height: `${frameSize}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -821,8 +1059,8 @@ function CursorStylePreview({
|
||||
className="max-w-none object-contain drop-shadow-[0_8px_12px_rgba(15,23,42,0.18)]"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: `${previewSize}px`,
|
||||
height: `${previewSize}px`,
|
||||
width: `${resolvedPreviewSize}px`,
|
||||
height: `${resolvedPreviewSize}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -830,22 +1068,58 @@ function CursorStylePreview({
|
||||
}
|
||||
|
||||
if (style === "figma") {
|
||||
return <img src={previewSrc} alt="" className="h-7 w-7 object-contain" draggable={false} />;
|
||||
}
|
||||
|
||||
if (style === "dot") {
|
||||
const resolvedPreviewSize = previewSize ?? 28;
|
||||
return (
|
||||
<span className="h-[14px] w-[14px] rounded-full border-[2.5px] border-neutral-800 bg-white shadow-[0_8px_12px_rgba(15,23,42,0.16)]" />
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: `${frameSize}px`, height: `${frameSize}px` }}
|
||||
>
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt=""
|
||||
className="object-contain"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: `${resolvedPreviewSize}px`,
|
||||
height: `${resolvedPreviewSize}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "dot") {
|
||||
const resolvedPreviewSize = previewSize ?? 14;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: `${frameSize}px`, height: `${frameSize}px` }}
|
||||
>
|
||||
<span
|
||||
className="rounded-full border-[2.5px] border-neutral-800 bg-white shadow-[0_8px_12px_rgba(15,23,42,0.16)]"
|
||||
style={{
|
||||
width: `${resolvedPreviewSize}px`,
|
||||
height: `${resolvedPreviewSize}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPreviewSize = previewSize ?? 28;
|
||||
return (
|
||||
<img
|
||||
src={previewSrc ?? tahoeCursorUrl}
|
||||
alt=""
|
||||
className="h-7 w-7 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: `${frameSize}px`, height: `${frameSize}px` }}
|
||||
>
|
||||
<img
|
||||
src={previewSrc ?? tahoeCursorUrl}
|
||||
alt=""
|
||||
className="object-contain"
|
||||
draggable={false}
|
||||
style={{ width: `${resolvedPreviewSize}px`, height: `${resolvedPreviewSize}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -919,6 +1193,16 @@ export function SettingsPanel({
|
||||
onZoomClassicModeChange,
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
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,
|
||||
onCursorClickEffectOpacityChange,
|
||||
cursorClickEffectDurationMs = DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS,
|
||||
onCursorClickEffectDurationMsChange,
|
||||
cursorClickBounce = 1,
|
||||
onCursorClickBounceChange,
|
||||
cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
|
||||
@@ -1152,6 +1436,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;
|
||||
@@ -1164,6 +1449,7 @@ export function SettingsPanel({
|
||||
const [extensionCursorPreviewUrls, setExtensionCursorPreviewUrls] = useState<
|
||||
Partial<Record<string, string>>
|
||||
>({});
|
||||
const [showCursorClickEffectAdvanced, setShowCursorClickEffectAdvanced] = useState(false);
|
||||
const cursorPreviewUrls = useMemo(
|
||||
() => ({ ...builtInCursorPreviewUrls, ...extensionCursorPreviewUrls }),
|
||||
[builtInCursorPreviewUrls, extensionCursorPreviewUrls],
|
||||
@@ -1241,12 +1527,7 @@ export function SettingsPanel({
|
||||
if (!isKnownWallpaper && isVideoWallpaperSource(selected)) {
|
||||
setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev]));
|
||||
}
|
||||
}, [
|
||||
builtInWallpaperPaths,
|
||||
extensionWallpaperPaths,
|
||||
selected,
|
||||
wallpaperPreviewPaths,
|
||||
]);
|
||||
}, [builtInWallpaperPaths, extensionWallpaperPaths, selected, wallpaperPreviewPaths]);
|
||||
|
||||
const imageWallpaperTiles = useMemo<WallpaperTile[]>(() => {
|
||||
const imageWallpapers = builtInWallpapers.filter(
|
||||
@@ -1534,8 +1815,13 @@ export function SettingsPanel({
|
||||
);
|
||||
onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier);
|
||||
onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur);
|
||||
onCursorClickEffectChange?.(initialEditorPreferences.cursorClickEffect);
|
||||
onCursorClickEffectColorChange?.(initialEditorPreferences.cursorClickEffectColor);
|
||||
onCursorClickEffectScaleChange?.(initialEditorPreferences.cursorClickEffectScale);
|
||||
onCursorClickEffectOpacityChange?.(initialEditorPreferences.cursorClickEffectOpacity);
|
||||
onCursorClickEffectDurationMsChange?.(initialEditorPreferences.cursorClickEffectDurationMs);
|
||||
onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce);
|
||||
onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION);
|
||||
onCursorClickBounceDurationChange?.(initialEditorPreferences.cursorClickBounceDuration);
|
||||
onCursorSwayChange?.(initialEditorPreferences.cursorSway);
|
||||
};
|
||||
|
||||
@@ -3069,8 +3355,8 @@ export function SettingsPanel({
|
||||
</section>
|
||||
);
|
||||
|
||||
const audioSectionContent = (
|
||||
<section className="flex flex-col gap-3">
|
||||
const audioSectionContent = (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel>
|
||||
<button
|
||||
@@ -3084,8 +3370,8 @@ export function SettingsPanel({
|
||||
{t("common.actions.reset", "Reset")}
|
||||
</button>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings("audio.volume", "Volume")}
|
||||
<SliderControl
|
||||
label={tSettings("audio.volume", "Volume")}
|
||||
value={selectedAudioVolume ?? 1}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
@@ -3093,20 +3379,20 @@ export function SettingsPanel({
|
||||
step={0.01}
|
||||
onChange={(v) => onAudioVolumeChange?.(v)}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
|
||||
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tSettings("audio.normalize", "Normalize")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(selectedAudioNormalize)}
|
||||
onCheckedChange={(v) => onAudioNormalizeChange?.(v)}
|
||||
className="data-[state=checked]:bg-[#2563EB] scale-75"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tSettings("audio.normalize", "Normalize")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(selectedAudioNormalize)}
|
||||
onCheckedChange={(v) => onAudioNormalizeChange?.(v)}
|
||||
className="data-[state=checked]:bg-[#2563EB] scale-75"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const clipSectionContent = (
|
||||
<section className="flex flex-col gap-2">
|
||||
@@ -3183,7 +3469,10 @@ export function SettingsPanel({
|
||||
{hasClipSourceAudio && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tSettings("clip.separateClipFromAudio", "Separate clip from audio")}
|
||||
{tSettings(
|
||||
"clip.separateClipFromAudio",
|
||||
"Separate clip from audio",
|
||||
)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={selectedClipShowSourceAudio ?? false}
|
||||
@@ -3194,65 +3483,68 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedClipId &&
|
||||
hasClipSourceAudio &&
|
||||
sourceAudioTrackMeta.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-3">
|
||||
{sourceAudioTrackMeta.map((track) => {
|
||||
const settings = sourceAudioTrackSettings[track.id] ?? {
|
||||
volume: 1,
|
||||
normalize: false,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{track.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSourceAudioTrackVolumeChange?.(track.id, 1);
|
||||
onSourceAudioTrackNormalizeChange?.(track.id, false);
|
||||
}}
|
||||
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
|
||||
>
|
||||
{t("common.actions.reset", "Reset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tSettings("audio.normalize", "Normalize")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={settings.normalize}
|
||||
onCheckedChange={(v) =>
|
||||
onSourceAudioTrackNormalizeChange?.(track.id, v)
|
||||
}
|
||||
className="data-[state=checked]:bg-[#06b6d4] scale-75"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
label={tSettings("audio.volume", "Volume")}
|
||||
value={settings.volume}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={(v) => onSourceAudioTrackVolumeChange?.(track.id, v)}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
parseInput={(text) =>
|
||||
parseFloat(text.replace(/%$/, "")) / 100
|
||||
{selectedClipId && hasClipSourceAudio && sourceAudioTrackMeta.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-3">
|
||||
{sourceAudioTrackMeta.map((track) => {
|
||||
const settings = sourceAudioTrackSettings[track.id] ?? {
|
||||
volume: 1,
|
||||
normalize: false,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{track.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSourceAudioTrackVolumeChange?.(track.id, 1);
|
||||
onSourceAudioTrackNormalizeChange?.(
|
||||
track.id,
|
||||
false,
|
||||
);
|
||||
}}
|
||||
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
|
||||
>
|
||||
{t("common.actions.reset", "Reset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tSettings("audio.normalize", "Normalize")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={settings.normalize}
|
||||
onCheckedChange={(v) =>
|
||||
onSourceAudioTrackNormalizeChange?.(track.id, v)
|
||||
}
|
||||
className="data-[state=checked]:bg-[#06b6d4] scale-75"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<SliderControl
|
||||
label={tSettings("audio.volume", "Volume")}
|
||||
value={settings.volume}
|
||||
defaultValue={1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={(v) =>
|
||||
onSourceAudioTrackVolumeChange?.(track.id, v)
|
||||
}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
parseInput={(text) =>
|
||||
parseFloat(text.replace(/%$/, "")) / 100
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3376,6 +3668,121 @@ export function SettingsPanel({
|
||||
formatValue={(v) => `${v.toFixed(2)}×`}
|
||||
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
|
||||
/>
|
||||
<CursorClickEffectCards
|
||||
title={tSettings(
|
||||
"effects.cursorClickEffects.title",
|
||||
"Click Effects",
|
||||
)}
|
||||
activeEffectId={cursorClickEffect}
|
||||
effectColor={cursorClickEffectColor}
|
||||
onApply={(effectId) => onCursorClickEffectChange?.(effectId)}
|
||||
showAdvanced={showCursorClickEffectAdvanced}
|
||||
onToggleAdvanced={() =>
|
||||
setShowCursorClickEffectAdvanced((current) => !current)
|
||||
}
|
||||
tSettings={tSettings}
|
||||
/>
|
||||
{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",
|
||||
"Effect Size",
|
||||
)}
|
||||
value={cursorClickEffectScale}
|
||||
defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_SCALE}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.05}
|
||||
onChange={(v) => onCursorClickEffectScaleChange?.(v)}
|
||||
formatValue={(v) => `${v.toFixed(2)}×`}
|
||||
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorClickEffects.opacity",
|
||||
"Effect Opacity",
|
||||
)}
|
||||
value={cursorClickEffectOpacity}
|
||||
defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_OPACITY}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={(v) => onCursorClickEffectOpacityChange?.(v)}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
parseInput={(text) =>
|
||||
parseFloat(text.replace(/%$/, "")) / 100
|
||||
}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings(
|
||||
"effects.cursorClickEffects.duration",
|
||||
"Effect Duration",
|
||||
)}
|
||||
value={cursorClickEffectDurationMs}
|
||||
defaultValue={DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS}
|
||||
min={120}
|
||||
max={1200}
|
||||
step={10}
|
||||
onChange={(v) => onCursorClickEffectDurationMsChange?.(v)}
|
||||
formatValue={(v) => `${Math.round(v)} ms`}
|
||||
parseInput={(text) =>
|
||||
parseFloat(text.replace(/ms$/i, "").trim())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SliderControl
|
||||
label={tSettings("effects.cursorClickBounce")}
|
||||
value={cursorClickBounce}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BookmarkSimple,
|
||||
Check,
|
||||
CaretDown as ChevronDown,
|
||||
CaretUp as ChevronUp,
|
||||
ClosedCaptioning,
|
||||
Crop,
|
||||
Cursor,
|
||||
@@ -174,6 +173,7 @@ import {
|
||||
type CaptionCue,
|
||||
type ClipRegion,
|
||||
type CropRegion,
|
||||
type CursorClickEffectStyle,
|
||||
type CursorStyle,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
@@ -224,10 +224,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;
|
||||
};
|
||||
@@ -467,6 +463,21 @@ export default function VideoEditor() {
|
||||
const [cursorMotionBlur, setCursorMotionBlur] = useState(
|
||||
initialEditorPreferences.cursorMotionBlur,
|
||||
);
|
||||
const [cursorClickEffect, setCursorClickEffect] = useState<CursorClickEffectStyle>(
|
||||
initialEditorPreferences.cursorClickEffect,
|
||||
);
|
||||
const [cursorClickEffectColor, setCursorClickEffectColor] = useState(
|
||||
initialEditorPreferences.cursorClickEffectColor,
|
||||
);
|
||||
const [cursorClickEffectScale, setCursorClickEffectScale] = useState(
|
||||
initialEditorPreferences.cursorClickEffectScale,
|
||||
);
|
||||
const [cursorClickEffectOpacity, setCursorClickEffectOpacity] = useState(
|
||||
initialEditorPreferences.cursorClickEffectOpacity,
|
||||
);
|
||||
const [cursorClickEffectDurationMs, setCursorClickEffectDurationMs] = useState(
|
||||
initialEditorPreferences.cursorClickEffectDurationMs,
|
||||
);
|
||||
const [cursorClickBounce, setCursorClickBounce] = useState(
|
||||
initialEditorPreferences.cursorClickBounce,
|
||||
);
|
||||
@@ -634,8 +645,6 @@ export default function VideoEditor() {
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const [timelineCollapsed, setTimelineCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void window.electronAPI?.getPlatform?.()?.then((platform) => {
|
||||
setAppPlatform(platform);
|
||||
@@ -699,6 +708,11 @@ export default function VideoEditor() {
|
||||
cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -751,6 +765,11 @@ export default function VideoEditor() {
|
||||
cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -844,6 +863,11 @@ export default function VideoEditor() {
|
||||
setCameraSpringDampingMultiplier(snapshot.cameraSpringDampingMultiplier);
|
||||
setCameraSpringMassMultiplier(snapshot.cameraSpringMassMultiplier);
|
||||
setCursorMotionBlur(snapshot.cursorMotionBlur);
|
||||
setCursorClickEffect(snapshot.cursorClickEffect);
|
||||
setCursorClickEffectColor(snapshot.cursorClickEffectColor);
|
||||
setCursorClickEffectScale(snapshot.cursorClickEffectScale);
|
||||
setCursorClickEffectOpacity(snapshot.cursorClickEffectOpacity);
|
||||
setCursorClickEffectDurationMs(snapshot.cursorClickEffectDurationMs);
|
||||
setCursorClickBounce(snapshot.cursorClickBounce);
|
||||
setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
|
||||
setCursorSway(snapshot.cursorSway);
|
||||
@@ -1117,6 +1141,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -1186,6 +1215,11 @@ export default function VideoEditor() {
|
||||
currentTime,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorMotionBlur,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
@@ -1583,6 +1617,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness: number;
|
||||
zoomClassicMode: boolean;
|
||||
cursorMotionBlur: number;
|
||||
cursorClickEffect: CursorClickEffectStyle;
|
||||
cursorClickEffectColor: string;
|
||||
cursorClickEffectScale: number;
|
||||
cursorClickEffectOpacity: number;
|
||||
cursorClickEffectDurationMs: number;
|
||||
cursorClickBounce: number;
|
||||
cursorClickBounceDuration: number;
|
||||
cursorSway: number;
|
||||
@@ -1686,6 +1725,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -1748,6 +1792,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -1929,6 +1978,11 @@ export default function VideoEditor() {
|
||||
setCameraSpringStiffnessMultiplier(normalizedEditor.cameraSpringStiffnessMultiplier);
|
||||
setCameraSpringDampingMultiplier(normalizedEditor.cameraSpringDampingMultiplier);
|
||||
setCameraSpringMassMultiplier(normalizedEditor.cameraSpringMassMultiplier);
|
||||
setCursorClickEffect(normalizedEditor.cursorClickEffect);
|
||||
setCursorClickEffectColor(normalizedEditor.cursorClickEffectColor);
|
||||
setCursorClickEffectScale(normalizedEditor.cursorClickEffectScale);
|
||||
setCursorClickEffectOpacity(normalizedEditor.cursorClickEffectOpacity);
|
||||
setCursorClickEffectDurationMs(normalizedEditor.cursorClickEffectDurationMs);
|
||||
setZoomSmoothness(normalizedEditor.zoomSmoothness);
|
||||
setZoomClassicMode(normalizedEditor.zoomClassicMode);
|
||||
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
|
||||
@@ -2429,6 +2483,11 @@ export default function VideoEditor() {
|
||||
cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -2480,6 +2539,11 @@ export default function VideoEditor() {
|
||||
cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -3239,17 +3303,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;
|
||||
|
||||
@@ -3266,7 +3332,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;
|
||||
|
||||
@@ -3276,7 +3342,7 @@ export default function VideoEditor() {
|
||||
|
||||
video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000;
|
||||
},
|
||||
[mapTimelineTimeToSourceTime],
|
||||
[getActivePlayback, mapTimelineTimeToSourceTime],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
@@ -3286,6 +3352,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) {
|
||||
@@ -4160,6 +4242,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -4338,6 +4425,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -4590,6 +4682,11 @@ export default function VideoEditor() {
|
||||
zoomSmoothness,
|
||||
zoomClassicMode,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -4973,6 +5070,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={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
|
||||
@@ -5730,6 +5921,16 @@ export default function VideoEditor() {
|
||||
onZoomClassicModeChange={setZoomClassicMode}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickEffect={cursorClickEffect}
|
||||
cursorClickEffectColor={cursorClickEffectColor}
|
||||
onCursorClickEffectChange={setCursorClickEffect}
|
||||
onCursorClickEffectColorChange={setCursorClickEffectColor}
|
||||
cursorClickEffectScale={cursorClickEffectScale}
|
||||
onCursorClickEffectScaleChange={setCursorClickEffectScale}
|
||||
cursorClickEffectOpacity={cursorClickEffectOpacity}
|
||||
onCursorClickEffectOpacityChange={setCursorClickEffectOpacity}
|
||||
cursorClickEffectDurationMs={cursorClickEffectDurationMs}
|
||||
onCursorClickEffectDurationMsChange={setCursorClickEffectDurationMs}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
cursorClickBounceDuration={cursorClickBounceDuration}
|
||||
@@ -5846,130 +6047,21 @@ 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]"
|
||||
className="relative"
|
||||
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;
|
||||
})(),
|
||||
),
|
||||
aspectRatio: previewAspectRatioValue,
|
||||
maxWidth: "100%",
|
||||
margin: "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}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClickBounceDuration={
|
||||
cursorClickBounceDuration
|
||||
}
|
||||
cursorSway={cursorSway}
|
||||
volume={
|
||||
audio.shouldMutePreviewVideo ||
|
||||
audio.isCurrentClipMuted
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
previewVolume *
|
||||
audio.embeddedSourcePreviewGain,
|
||||
),
|
||||
)
|
||||
}
|
||||
suspendRendering={shouldSuspendPreviewRendering}
|
||||
/>
|
||||
{renderPreviewPlayback(
|
||||
videoPlaybackRef,
|
||||
shouldSuspendPreviewRendering,
|
||||
"inline",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6072,18 +6164,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>
|
||||
@@ -6105,19 +6186,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>
|
||||
@@ -6128,25 +6197,6 @@ export default function VideoEditor() {
|
||||
</div>
|
||||
{/* Right: collapse + volume */}
|
||||
<div className="z-10 ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={
|
||||
timelineCollapsed
|
||||
? t("editor.timeline.expand")
|
||||
: t("editor.timeline.collapse")
|
||||
}
|
||||
className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setTimelineCollapsed((p) => !p);
|
||||
}}
|
||||
>
|
||||
{timelineCollapsed ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -6201,8 +6251,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
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type AnnotationRegion,
|
||||
type AutoCaptionSettings,
|
||||
type CaptionCue,
|
||||
type CursorClickEffectStyle,
|
||||
type CursorStyle,
|
||||
type Padding,
|
||||
type SpeedRegion,
|
||||
@@ -133,10 +134,15 @@ import {
|
||||
DEFAULT_CONNECTED_ZOOM_GAP_MS,
|
||||
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,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_PADDING,
|
||||
DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
@@ -209,7 +215,10 @@ const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000;
|
||||
|
||||
function isCanvasRenderer(application: Application): boolean {
|
||||
const rendererName = application?.renderer?.constructor?.name?.toLowerCase();
|
||||
return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")));
|
||||
return Boolean(
|
||||
rendererName &&
|
||||
(rendererName.includes("canvasrenderer") || rendererName.includes("canvas")),
|
||||
);
|
||||
}
|
||||
|
||||
function toRendererErrorMessage(error: unknown): string {
|
||||
@@ -218,7 +227,10 @@ function toRendererErrorMessage(error: unknown): string {
|
||||
|
||||
function isRendererUnavailableError(error: unknown): boolean {
|
||||
const message = toRendererErrorMessage(error).toLowerCase();
|
||||
return message.includes("canvasrenderer is not yet implemented") || message.includes("no available renderer");
|
||||
return (
|
||||
message.includes("canvasrenderer is not yet implemented") ||
|
||||
message.includes("no available renderer")
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string {
|
||||
@@ -366,6 +378,11 @@ interface VideoPlaybackProps {
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickEffect?: CursorClickEffectStyle;
|
||||
cursorClickEffectColor?: string;
|
||||
cursorClickEffectScale?: number;
|
||||
cursorClickEffectOpacity?: number;
|
||||
cursorClickEffectDurationMs?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClickBounceDuration?: number;
|
||||
cursorSway?: number;
|
||||
@@ -444,6 +461,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
zoomMotionBlur = DEFAULT_ZOOM_MOTION_BLUR,
|
||||
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,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
|
||||
cursorSway = DEFAULT_CURSOR_SWAY,
|
||||
@@ -453,6 +475,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const previewFrameRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||
@@ -513,6 +536,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
renderWidth?: number;
|
||||
renderHeight?: number;
|
||||
sourceCrop?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -564,6 +589,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cameraSpringDampingMultiplierRef = useRef(cameraSpringDampingMultiplier);
|
||||
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);
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
|
||||
const cursorSwayRef = useRef(cursorSway);
|
||||
@@ -583,7 +613,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
);
|
||||
|
||||
const initializePixiRenderer = useCallback(
|
||||
async (container: HTMLDivElement): Promise<{
|
||||
async (
|
||||
container: HTMLDivElement,
|
||||
): Promise<{
|
||||
app: Application;
|
||||
backend: PixiPreviewBackend;
|
||||
}> => {
|
||||
@@ -603,7 +635,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
|
||||
const rendererApp = new Application();
|
||||
const initStarted = typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
const initStarted =
|
||||
typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
try {
|
||||
await initApplicationWithTimeout(
|
||||
rendererApp,
|
||||
@@ -622,7 +655,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
backend,
|
||||
);
|
||||
const elapsed = Math.round(
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted,
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) -
|
||||
initStarted,
|
||||
);
|
||||
if (isCanvasRenderer(rendererApp)) {
|
||||
throw new Error(
|
||||
@@ -632,9 +666,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return { app: rendererApp, backend };
|
||||
} catch (error) {
|
||||
const elapsed = Math.round(
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted,
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) -
|
||||
initStarted,
|
||||
);
|
||||
attempts.push({ backend, message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)` });
|
||||
attempts.push({
|
||||
backend,
|
||||
message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)`,
|
||||
});
|
||||
const statusMessage = isRendererUnavailableError(error)
|
||||
? "renderer backend unavailable in this runtime"
|
||||
: "renderer init failed";
|
||||
@@ -950,7 +988,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoSizeRef.current = result.videoSize;
|
||||
baseScaleRef.current = result.baseScale;
|
||||
baseOffsetRef.current = result.baseOffset;
|
||||
baseMaskRef.current = result.maskRect;
|
||||
const renderResolution = app.renderer.resolution || window.devicePixelRatio || 1;
|
||||
baseMaskRef.current = {
|
||||
...result.maskRect,
|
||||
renderWidth: result.maskRect.width * renderResolution,
|
||||
renderHeight: result.maskRect.height * renderResolution,
|
||||
};
|
||||
cropBoundsRef.current = result.cropBounds;
|
||||
|
||||
// Sync extension cursor effects canvas resolution with renderer
|
||||
@@ -1061,7 +1104,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const activeFrameData = frame
|
||||
? extensionHost.getFrames().find((registeredFrame) => registeredFrame.id === frame)
|
||||
: null;
|
||||
const shouldRedrawDynamicFrame = Boolean(activeFrameData?.draw && frameSpriteRef.current);
|
||||
const shouldRedrawDynamicFrame = Boolean(
|
||||
activeFrameData?.draw && frameSpriteRef.current,
|
||||
);
|
||||
|
||||
// Layout-only changes should not force texture/sprite recreation.
|
||||
if (frameReloadKeyRef.current === nextFrameReloadKey && !shouldRedrawDynamicFrame) {
|
||||
@@ -1575,6 +1620,26 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorMotionBlurRef.current = cursorMotionBlur;
|
||||
}, [cursorMotionBlur]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickEffectRef.current = cursorClickEffect;
|
||||
}, [cursorClickEffect]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickEffectColorRef.current = cursorClickEffectColor;
|
||||
}, [cursorClickEffectColor]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickEffectScaleRef.current = cursorClickEffectScale;
|
||||
}, [cursorClickEffectScale]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickEffectOpacityRef.current = cursorClickEffectOpacity;
|
||||
}, [cursorClickEffectOpacity]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickEffectDurationMsRef.current = cursorClickEffectDurationMs;
|
||||
}, [cursorClickEffectDurationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickBounceRef.current = cursorClickBounce;
|
||||
}, [cursorClickBounce]);
|
||||
@@ -1665,6 +1730,53 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
});
|
||||
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
const previewFrame = previewFrameRef.current;
|
||||
if (!previewFrame) {
|
||||
return;
|
||||
}
|
||||
let frameId: number | null = null;
|
||||
|
||||
const applyPreviewFrameSquircle = () => {
|
||||
const width = previewFrame.offsetWidth;
|
||||
const height = previewFrame.offsetHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const squirclePath = getSquircleSvgPath({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
radius: 12,
|
||||
});
|
||||
previewFrame.style.clipPath = `path('${squirclePath}')`;
|
||||
previewFrame.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
|
||||
};
|
||||
|
||||
applyPreviewFrameSquircle();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = requestAnimationFrame(applyPreviewFrameSquircle);
|
||||
});
|
||||
|
||||
observer.observe(previewFrame);
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const container = containerRef.current;
|
||||
@@ -1873,6 +1985,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
massMultiplier: cursorSpringMassMultiplierRef.current,
|
||||
},
|
||||
motionBlur: cursorMotionBlurRef.current,
|
||||
clickEffect: cursorClickEffectRef.current,
|
||||
clickEffectColor: cursorClickEffectColorRef.current,
|
||||
clickEffectScale: cursorClickEffectScaleRef.current,
|
||||
clickEffectOpacity: cursorClickEffectOpacityRef.current,
|
||||
clickEffectDurationMs: cursorClickEffectDurationMsRef.current,
|
||||
clickBounce: cursorClickBounceRef.current,
|
||||
clickBounceDuration: cursorClickBounceDurationRef.current,
|
||||
sway: cursorSwayRef.current,
|
||||
@@ -2236,10 +2353,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
resetSpringState(springYRef.current, appliedY);
|
||||
}
|
||||
|
||||
applyTransform(
|
||||
{ scale: appliedScale, x: appliedX, y: appliedY },
|
||||
targetFocus,
|
||||
);
|
||||
applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus);
|
||||
|
||||
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
|
||||
|
||||
@@ -2459,6 +2573,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
massMultiplier: cursorSpringMassMultiplier,
|
||||
});
|
||||
overlay.setMotionBlur(cursorMotionBlur);
|
||||
overlay.setClickEffect(cursorClickEffect);
|
||||
overlay.setClickEffectColor(cursorClickEffectColor);
|
||||
overlay.setClickEffectScale(cursorClickEffectScale);
|
||||
overlay.setClickEffectOpacity(cursorClickEffectOpacity);
|
||||
overlay.setClickEffectDurationMs(cursorClickEffectDurationMs);
|
||||
overlay.setClickBounce(cursorClickBounce);
|
||||
overlay.setClickBounceDuration(cursorClickBounceDuration);
|
||||
overlay.setSway(cursorSway);
|
||||
@@ -2490,6 +2609,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorSpringDampingMultiplier,
|
||||
cursorSpringMassMultiplier,
|
||||
cursorMotionBlur,
|
||||
cursorClickEffect,
|
||||
cursorClickEffectColor,
|
||||
cursorClickEffectScale,
|
||||
cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
@@ -2706,10 +2830,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
ref={previewFrameRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio),
|
||||
borderRadius: "12px",
|
||||
}}
|
||||
>
|
||||
{/* Background layer */}
|
||||
@@ -2742,7 +2868,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
filter:
|
||||
showShadow && shadowIntensity > 0
|
||||
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
|
||||
: "none",
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{hasRendererFallback && (
|
||||
@@ -2750,7 +2876,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs text-white">
|
||||
{`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`}
|
||||
<br />
|
||||
Fallback to 2D native preview so you can continue working while the GPU path is unavailable.
|
||||
Fallback to 2D native preview so you can continue working while the GPU
|
||||
path is unavailable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,11 @@ type PersistedEditorControls = Pick<
|
||||
| "cameraSpringDampingMultiplier"
|
||||
| "cameraSpringMassMultiplier"
|
||||
| "cursorMotionBlur"
|
||||
| "cursorClickEffect"
|
||||
| "cursorClickEffectColor"
|
||||
| "cursorClickEffectScale"
|
||||
| "cursorClickEffectOpacity"
|
||||
| "cursorClickEffectDurationMs"
|
||||
| "cursorClickBounce"
|
||||
| "cursorClickBounceDuration"
|
||||
| "cursorSway"
|
||||
@@ -122,6 +127,11 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
|
||||
cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier,
|
||||
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,
|
||||
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
|
||||
cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration,
|
||||
cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway,
|
||||
@@ -311,6 +321,15 @@ function normalizeEditorControls(
|
||||
cameraSpringMassMultiplier:
|
||||
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:
|
||||
sanitizedRaw.cursorClickEffectOpacity ?? fallback.cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs:
|
||||
sanitizedRaw.cursorClickEffectDurationMs ?? fallback.cursorClickEffectDurationMs,
|
||||
cursorClickBounce: sanitizedRaw.cursorClickBounce ?? fallback.cursorClickBounce,
|
||||
cursorClickBounceDuration:
|
||||
sanitizedRaw.cursorClickBounceDuration ?? fallback.cursorClickBounceDuration,
|
||||
@@ -372,6 +391,11 @@ function normalizeEditorControls(
|
||||
cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier,
|
||||
cursorMotionBlur: normalized.cursorMotionBlur,
|
||||
cursorClickEffect: normalized.cursorClickEffect,
|
||||
cursorClickEffectColor: normalized.cursorClickEffectColor,
|
||||
cursorClickEffectScale: normalized.cursorClickEffectScale,
|
||||
cursorClickEffectOpacity: normalized.cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs: normalized.cursorClickEffectDurationMs,
|
||||
cursorClickBounce: normalized.cursorClickBounce,
|
||||
cursorClickBounceDuration: normalized.cursorClickBounceDuration,
|
||||
cursorSway: normalized.cursorSway,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes";
|
||||
import type {
|
||||
ExportBackendPreference,
|
||||
ExportEncodingMode,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
|
||||
import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AudioRegion,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type CaptionCueWord,
|
||||
type ClipRegion,
|
||||
type CropRegion,
|
||||
type CursorClickEffectStyle,
|
||||
type CursorStyle,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
@@ -39,6 +40,11 @@ import {
|
||||
DEFAULT_CONNECTED_ZOOM_EASING,
|
||||
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,
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
@@ -62,6 +68,8 @@ import {
|
||||
DEFAULT_ZOOM_OUT_EASING,
|
||||
DEFAULT_ZOOM_SMOOTHNESS,
|
||||
getDefaultCaptionFontFamily,
|
||||
normalizeCursorClickEffectColor,
|
||||
normalizeCursorClickEffectStyle,
|
||||
type Padding,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
@@ -97,6 +105,11 @@ export interface ProjectEditorState {
|
||||
showCursor: boolean;
|
||||
loopCursor: boolean;
|
||||
cursorStyle: CursorStyle;
|
||||
cursorClickEffect: CursorClickEffectStyle;
|
||||
cursorClickEffectColor: string;
|
||||
cursorClickEffectScale: number;
|
||||
cursorClickEffectOpacity: number;
|
||||
cursorClickEffectDurationMs: number;
|
||||
cursorSize: number;
|
||||
cursorSmoothing: number;
|
||||
cursorSpringStiffnessMultiplier: number;
|
||||
@@ -163,10 +176,7 @@ type PersistedDevMotionBlurSettings = {
|
||||
export function stripPersistedDevMotionBlurSettings<T extends PersistedDevMotionBlurSettings>(
|
||||
editor: T,
|
||||
): Omit<T, keyof PersistedDevMotionBlurSettings> {
|
||||
const {
|
||||
zoomMotionBlurTuning: _zoomMotionBlurTuning,
|
||||
...persistedEditor
|
||||
} = editor;
|
||||
const { zoomMotionBlurTuning: _zoomMotionBlurTuning, ...persistedEditor } = editor;
|
||||
|
||||
return persistedEditor;
|
||||
}
|
||||
@@ -669,17 +679,17 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
|
||||
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
|
||||
normalize: Boolean(region.normalize),
|
||||
trackIndex: isFiniteNumber(region.trackIndex)
|
||||
? Math.max(0, Math.floor(region.trackIndex))
|
||||
: 0,
|
||||
};
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
|
||||
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
|
||||
normalize: Boolean(region.normalize),
|
||||
trackIndex: isFiniteNumber(region.trackIndex)
|
||||
? Math.max(0, Math.floor(region.trackIndex))
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -854,6 +864,29 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
)
|
||||
: DEFAULT_MOTION_PRESET.cursorClickBounceDuration,
|
||||
};
|
||||
const normalizedCursorClickEffectScale = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).cursorClickEffectScale,
|
||||
)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorClickEffectScale as number, 0.5, 2)
|
||||
: DEFAULT_CURSOR_CLICK_EFFECT_SCALE;
|
||||
const normalizedCursorClickEffectOpacity = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).cursorClickEffectOpacity,
|
||||
)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorClickEffectOpacity as number, 0, 1)
|
||||
: DEFAULT_CURSOR_CLICK_EFFECT_OPACITY;
|
||||
const normalizedCursorClickEffectDurationMs = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).cursorClickEffectDurationMs,
|
||||
)
|
||||
? clamp(
|
||||
(editor as Partial<ProjectEditorState>).cursorClickEffectDurationMs as number,
|
||||
120,
|
||||
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)];
|
||||
|
||||
@@ -881,6 +914,14 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
|
||||
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
|
||||
cursorStyle: normalizedCursorStyle,
|
||||
cursorClickEffect: normalizeCursorClickEffectStyle(
|
||||
(editor as Partial<ProjectEditorState>).cursorClickEffect,
|
||||
DEFAULT_CURSOR_CLICK_EFFECT,
|
||||
),
|
||||
cursorClickEffectColor: normalizedCursorClickEffectColor,
|
||||
cursorClickEffectScale: normalizedCursorClickEffectScale,
|
||||
cursorClickEffectOpacity: normalizedCursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs: normalizedCursorClickEffectDurationMs,
|
||||
cursorSize: normalizedMotionPreset.cursorSize,
|
||||
cursorSmoothing: normalizedMotionPreset.cursorSmoothing,
|
||||
cursorSpringStiffnessMultiplier: normalizedMotionPreset.cursorSpringStiffnessMultiplier,
|
||||
|
||||
@@ -62,9 +62,7 @@ interface TimelineCanvasProps {
|
||||
onClearBlockSelection?: () => void;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
sourceAudioTracks?: SourceAudioTrackWithPeaks[];
|
||||
getSourceAudioTrackSettingsForClip?: (
|
||||
clipId: string | null,
|
||||
) => SourceAudioTrackSettings;
|
||||
getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings;
|
||||
showSourceAudioTrack?: boolean;
|
||||
liveSpanPreviewById?: Record<string, { start: number; end: number }>;
|
||||
liveHiddenItemIds?: string[];
|
||||
@@ -103,7 +101,9 @@ function useTimelineHover({
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
const contentWidth = Math.max(1, rect.width - sidebarWidth);
|
||||
const contentX =
|
||||
direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth;
|
||||
direction === "rtl"
|
||||
? rect.right - sidebarWidth - clientX
|
||||
: clientX - rect.left - sidebarWidth;
|
||||
const clampedX = Math.max(0, Math.min(contentX, contentWidth));
|
||||
const ratio = clampedX / contentWidth;
|
||||
const nextMs = rangeStart + ratio * visibleDurationMs;
|
||||
@@ -194,7 +194,8 @@ function useTimelineHover({
|
||||
: Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
|
||||
const ghostStartOffsetPx =
|
||||
ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart));
|
||||
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
|
||||
const ghostEndOffsetPx =
|
||||
ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
|
||||
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
|
||||
const timelineGhostOffsetPx =
|
||||
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
|
||||
@@ -235,9 +236,7 @@ interface TimelineCanvasRowsProps {
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectAudio?: (id: string | null) => void;
|
||||
sourceAudioTracks?: SourceAudioTrackWithPeaks[];
|
||||
getSourceAudioTrackSettingsForClip?: (
|
||||
clipId: string | null,
|
||||
) => SourceAudioTrackSettings;
|
||||
getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings;
|
||||
showSourceAudioTrack?: boolean;
|
||||
liveSpanPreviewById?: Record<string, { start: number; end: number }>;
|
||||
liveHiddenItemIds?: string[];
|
||||
@@ -274,18 +273,18 @@ function AudioItemWithWaveform({
|
||||
return { start: 0, end: duration };
|
||||
}, [waveformSpan.end, waveformSpan.start]);
|
||||
return (
|
||||
<Item
|
||||
id={item.id}
|
||||
rowId={item.rowId}
|
||||
span={span}
|
||||
isSelected={isSelected}
|
||||
onSelectId={onSelectAudio}
|
||||
variant="audio"
|
||||
waveformPeaks={peaks}
|
||||
waveformSegmentSpan={normalizedWaveformSpan}
|
||||
waveformGain={Math.max(0, Math.min(1, item.audioGain ?? 1))}
|
||||
waveformNormalize={Boolean(item.audioNormalize)}
|
||||
>
|
||||
<Item
|
||||
id={item.id}
|
||||
rowId={item.rowId}
|
||||
span={span}
|
||||
isSelected={isSelected}
|
||||
onSelectId={onSelectAudio}
|
||||
variant="audio"
|
||||
waveformPeaks={peaks}
|
||||
waveformSegmentSpan={normalizedWaveformSpan}
|
||||
waveformGain={Math.max(0, Math.min(1, item.audioGain ?? 1))}
|
||||
waveformNormalize={Boolean(item.audioNormalize)}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
);
|
||||
@@ -381,7 +380,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedClipId}
|
||||
isSelected={item.id === selectedClipId}
|
||||
onSelectId={onSelectClip}
|
||||
variant="clip"
|
||||
speedValue={item.speedValue}
|
||||
@@ -393,30 +392,32 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
{showSourceAudioTrack &&
|
||||
sourceAudioTracks.map((track) => (
|
||||
<Row key={track.id} id={`${SOURCE_AUDIO_ROW_ID}-${track.id}`}>
|
||||
{clipItems.filter(item => item.showSourceAudio).map((item) => {
|
||||
const settings = getSourceAudioTrackSettingsForClip?.(item.id)?.[
|
||||
track.id
|
||||
] ?? { volume: 1, normalize: false };
|
||||
return (
|
||||
<Item
|
||||
key={`source-audio-${track.id}-${item.id}`}
|
||||
id={`source-audio-${track.id}-${item.id}`}
|
||||
rowId={`${SOURCE_AUDIO_ROW_ID}-${track.id}`}
|
||||
span={liveSpanPreviewById?.[item.id] ?? item.span}
|
||||
disabled
|
||||
isSelected={selectAllBlocksActive || item.id === selectedClipId}
|
||||
onSelect={() => onSelectClip?.(item.id)}
|
||||
variant="audio"
|
||||
waveformPeaks={track.peaks}
|
||||
waveformSegmentSpan={item.sourceSpan ?? item.span}
|
||||
waveformGain={Math.max(0, Math.min(1, settings.volume))}
|
||||
waveformNormalize={Boolean(settings.normalize)}
|
||||
muted={item.muted}
|
||||
>
|
||||
{track.label}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
{clipItems
|
||||
.filter((item) => item.showSourceAudio)
|
||||
.map((item) => {
|
||||
const settings = getSourceAudioTrackSettingsForClip?.(item.id)?.[
|
||||
track.id
|
||||
] ?? { volume: 1, normalize: false };
|
||||
return (
|
||||
<Item
|
||||
key={`source-audio-${track.id}-${item.id}`}
|
||||
id={`source-audio-${track.id}-${item.id}`}
|
||||
rowId={`${SOURCE_AUDIO_ROW_ID}-${track.id}`}
|
||||
span={liveSpanPreviewById?.[item.id] ?? item.span}
|
||||
disabled
|
||||
isSelected={item.id === selectedClipId}
|
||||
onSelect={() => onSelectClip?.(item.id)}
|
||||
variant="audio"
|
||||
waveformPeaks={track.peaks}
|
||||
waveformSegmentSpan={item.sourceSpan ?? item.span}
|
||||
waveformGain={Math.max(0, Math.min(1, settings.volume))}
|
||||
waveformNormalize={Boolean(settings.normalize)}
|
||||
muted={item.muted}
|
||||
>
|
||||
{track.label}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
@@ -435,8 +436,14 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
|
||||
style={
|
||||
direction === "rtl"
|
||||
? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
|
||||
: { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
|
||||
? {
|
||||
right: `${ghostStartOffsetPx}px`,
|
||||
width: `${ghostWidthPx}px`,
|
||||
}
|
||||
: {
|
||||
left: `${ghostStartOffsetPx}px`,
|
||||
width: `${ghostWidthPx}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -457,31 +464,36 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
{zoomItems
|
||||
.filter((item) => !hiddenIds.has(item.id))
|
||||
.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
|
||||
onSelectId={onSelectZoom}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomMode={item.zoomMode}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
|
||||
onSelectId={onSelectZoom}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomMode={item.zoomMode}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{annotationRows.map(({ rowId, items: rowItems }, index) => (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_ANNOTATION : undefined}>
|
||||
<Row
|
||||
key={rowId}
|
||||
id={rowId}
|
||||
isEmpty={rowItems.length === 0}
|
||||
hint={index === 0 ? HINT_ANNOTATION : undefined}
|
||||
>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
|
||||
isSelected={item.id === selectedAnnotationId}
|
||||
onSelectId={onSelectAnnotation}
|
||||
variant="annotation"
|
||||
>
|
||||
@@ -492,14 +504,19 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
))}
|
||||
|
||||
{audioRows.map(({ rowId, items: rowItems }, index) => (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_AUDIO : undefined}>
|
||||
<Row
|
||||
key={rowId}
|
||||
id={rowId}
|
||||
isEmpty={rowItems.length === 0}
|
||||
hint={index === 0 ? HINT_AUDIO : undefined}
|
||||
>
|
||||
{rowItems.map((item) => (
|
||||
<AudioItemWithWaveform
|
||||
key={item.id}
|
||||
item={item}
|
||||
span={item.span}
|
||||
waveformSpan={liveSpanPreviewById?.[item.id] ?? item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
|
||||
isSelected={item.id === selectedAudioId}
|
||||
onSelectAudio={onSelectAudio}
|
||||
/>
|
||||
))}
|
||||
@@ -603,7 +620,8 @@ export default function TimelineCanvas({
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return;
|
||||
if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current)
|
||||
return;
|
||||
if ((e.target as HTMLElement).closest("[data-timeline-item]")) {
|
||||
return;
|
||||
}
|
||||
@@ -639,7 +657,8 @@ export default function TimelineCanvas({
|
||||
|
||||
const flushSeek = () => {
|
||||
seekRafRef.current = null;
|
||||
if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) return;
|
||||
if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null)
|
||||
return;
|
||||
const rect = localTimelineRef.current.getBoundingClientRect();
|
||||
onSeek(getAbsoluteMsFromClientX(pendingSeekClientXRef.current, rect) / 1000);
|
||||
};
|
||||
@@ -744,14 +763,21 @@ export default function TimelineCanvas({
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-[45] pointer-events-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]:
|
||||
`${sidebarWidth - 1}px`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 w-px bg-foreground/35" style={{ [sideProperty]: `${timelineGhostOffsetPx}px` }} />
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-foreground/35"
|
||||
style={{ [sideProperty]: `${timelineGhostOffsetPx}px` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-1 min-h-0 flex-col" style={{ minHeight: timelineRowsMinHeightPx }}>
|
||||
<div
|
||||
className="relative z-10 flex flex-1 min-h-0 flex-col"
|
||||
style={{ minHeight: timelineRowsMinHeightPx }}
|
||||
>
|
||||
<TimelineCanvasRows
|
||||
items={items}
|
||||
videoDurationMs={videoDurationMs}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { useCallback, useImperativeHandle } from "react";
|
||||
import type { ForwardedRef, RefObject } from "react";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import { useTimelineDndBindings } from "./useTimelineDndBindings";
|
||||
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
|
||||
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
|
||||
import { useTimelineNormalization } from "./useTimelineNormalization";
|
||||
import { useTimelineSelection } from "./useTimelineSelection";
|
||||
import { useTimelineZoomActions } from "./actions/useTimelineZoomActions";
|
||||
import { useCallback, useImperativeHandle } from "react";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AudioRegion,
|
||||
@@ -18,7 +11,14 @@ import type {
|
||||
ZoomFocus,
|
||||
ZoomRegion,
|
||||
} from "../../types";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import type { TimelineEditorHandle } from "../TimelineEditor";
|
||||
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
|
||||
import { useTimelineZoomActions } from "./actions/useTimelineZoomActions";
|
||||
import { useTimelineDndBindings } from "./useTimelineDndBindings";
|
||||
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
|
||||
import { useTimelineNormalization } from "./useTimelineNormalization";
|
||||
import { useTimelineSelection } from "./useTimelineSelection";
|
||||
|
||||
interface UseTimelineEditorRuntimeParams {
|
||||
ref: ForwardedRef<TimelineEditorHandle>;
|
||||
@@ -113,7 +113,8 @@ export function useTimelineEditorRuntime({
|
||||
setSelectedKeyframeId,
|
||||
selectAllBlocksActive,
|
||||
setSelectAllBlocksActive,
|
||||
hasAnyTimelineBlocks,
|
||||
hasAnyZoomBlocks,
|
||||
activateSelectAllZooms,
|
||||
addKeyframe,
|
||||
deleteSelectedKeyframe,
|
||||
handleKeyframeMove,
|
||||
@@ -122,7 +123,6 @@ export function useTimelineEditorRuntime({
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedAudio,
|
||||
clearSelectedBlocks,
|
||||
deleteAllBlocks,
|
||||
handleSelectZoom,
|
||||
handleSelectClip,
|
||||
handleSelectAnnotation,
|
||||
@@ -162,33 +162,43 @@ export function useTimelineEditorRuntime({
|
||||
onAudioSpanChange,
|
||||
});
|
||||
|
||||
const { hasOverlap, timelineItems, allRegionSpans, getResolvedDropRowId, handleItemSpanChange } =
|
||||
useTimelineDndBindings({
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
clipRegions,
|
||||
annotationRegions,
|
||||
speedRegions,
|
||||
audioRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onClipSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAudioSpanChange,
|
||||
});
|
||||
const {
|
||||
hasOverlap,
|
||||
timelineItems,
|
||||
allRegionSpans,
|
||||
getResolvedDropRowId,
|
||||
handleItemSpanChange,
|
||||
} = useTimelineDndBindings({
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
clipRegions,
|
||||
annotationRegions,
|
||||
speedRegions,
|
||||
audioRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onClipSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAudioSpanChange,
|
||||
});
|
||||
|
||||
const { defaultRegionDurationMs, canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, handleSuggestZooms } =
|
||||
useTimelineZoomActions({
|
||||
timeline: { videoDuration, totalMs, currentTimeMs },
|
||||
regions: { zoom: zoomRegions, clip: clipRegions },
|
||||
cursorTelemetry,
|
||||
options: { disableSuggestedZooms },
|
||||
autoSuggestZoomsTrigger,
|
||||
onAutoSuggestZoomsConsumed,
|
||||
onZoomAdded,
|
||||
onZoomSuggested,
|
||||
});
|
||||
const {
|
||||
defaultRegionDurationMs,
|
||||
canPlaceZoomAtMs,
|
||||
addZoomAtMs,
|
||||
handleAddZoom,
|
||||
handleSuggestZooms,
|
||||
} = useTimelineZoomActions({
|
||||
timeline: { videoDuration, totalMs, currentTimeMs },
|
||||
regions: { zoom: zoomRegions, clip: clipRegions },
|
||||
cursorTelemetry,
|
||||
options: { disableSuggestedZooms },
|
||||
autoSuggestZoomsTrigger,
|
||||
onAutoSuggestZoomsConsumed,
|
||||
onZoomAdded,
|
||||
onZoomSuggested,
|
||||
});
|
||||
|
||||
const handleSplitClip = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
|
||||
@@ -226,7 +236,8 @@ export function useTimelineEditorRuntime({
|
||||
isMac,
|
||||
keyShortcuts,
|
||||
isTimelineFocusedRef,
|
||||
hasAnyTimelineBlocks,
|
||||
hasAnyZoomBlocks,
|
||||
activateSelectAllZooms,
|
||||
annotationCount: annotationRegions.length,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
@@ -234,13 +245,10 @@ export function useTimelineEditorRuntime({
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
selectAllBlocksActive,
|
||||
setSelectAllBlocksActive,
|
||||
setSelectedKeyframeId,
|
||||
addKeyframe,
|
||||
handleAddZoom,
|
||||
handleSplitClip,
|
||||
handleAddAnnotation: () => handleAddAnnotation(),
|
||||
deleteAllBlocks,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedClip,
|
||||
@@ -259,7 +267,14 @@ export function useTimelineEditorRuntime({
|
||||
addAudio: handleAddAudio,
|
||||
keyframes,
|
||||
}),
|
||||
[handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes],
|
||||
[
|
||||
handleAddAnnotation,
|
||||
handleAddAudio,
|
||||
handleAddZoom,
|
||||
handleSuggestZooms,
|
||||
handleSplitClip,
|
||||
keyframes,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils";
|
||||
@@ -7,7 +7,8 @@ interface UseTimelineKeyboardShortcutsParams {
|
||||
isMac: boolean;
|
||||
keyShortcuts: TimelineShortcutBindings;
|
||||
isTimelineFocusedRef: RefObject<boolean>;
|
||||
hasAnyTimelineBlocks: boolean;
|
||||
hasAnyZoomBlocks: boolean;
|
||||
activateSelectAllZooms: () => void;
|
||||
annotationCount: number;
|
||||
selectedKeyframeId: string | null;
|
||||
selectedZoomId: string | null;
|
||||
@@ -15,13 +16,10 @@ interface UseTimelineKeyboardShortcutsParams {
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedAudioId?: string | null;
|
||||
selectAllBlocksActive: boolean;
|
||||
setSelectAllBlocksActive: (active: boolean) => void;
|
||||
setSelectedKeyframeId: (id: string | null) => void;
|
||||
addKeyframe: () => void;
|
||||
handleAddZoom: () => void;
|
||||
handleSplitClip: () => void;
|
||||
handleAddAnnotation: () => void;
|
||||
deleteAllBlocks: () => void;
|
||||
deleteSelectedKeyframe: () => void;
|
||||
deleteSelectedZoom: () => void;
|
||||
deleteSelectedClip: () => void;
|
||||
@@ -34,7 +32,8 @@ export function useTimelineKeyboardShortcuts({
|
||||
isMac,
|
||||
keyShortcuts,
|
||||
isTimelineFocusedRef,
|
||||
hasAnyTimelineBlocks,
|
||||
hasAnyZoomBlocks,
|
||||
activateSelectAllZooms,
|
||||
annotationCount,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
@@ -42,13 +41,10 @@ export function useTimelineKeyboardShortcuts({
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
selectAllBlocksActive,
|
||||
setSelectAllBlocksActive,
|
||||
setSelectedKeyframeId,
|
||||
addKeyframe,
|
||||
handleAddZoom,
|
||||
handleSplitClip,
|
||||
handleAddAnnotation,
|
||||
deleteAllBlocks,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedClip,
|
||||
@@ -73,12 +69,11 @@ export function useTimelineKeyboardShortcuts({
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) {
|
||||
if (!hasAnyTimelineBlocks) {
|
||||
if (!hasAnyZoomBlocks) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setSelectedKeyframeId(null);
|
||||
setSelectAllBlocksActive(true);
|
||||
activateSelectAllZooms();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,9 +106,7 @@ export function useTimelineKeyboardShortcuts({
|
||||
if (target !== "none") {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (target === "all") {
|
||||
deleteAllBlocks();
|
||||
} else if (target === "keyframe") {
|
||||
if (target === "keyframe") {
|
||||
deleteSelectedKeyframe();
|
||||
} else if (target === "zoom") {
|
||||
deleteSelectedZoom();
|
||||
@@ -130,10 +123,10 @@ export function useTimelineKeyboardShortcuts({
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
activateSelectAllZooms,
|
||||
addKeyframe,
|
||||
annotationCount,
|
||||
cycleAnnotationsAtCurrentTime,
|
||||
deleteAllBlocks,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedAudio,
|
||||
deleteSelectedClip,
|
||||
@@ -142,7 +135,7 @@ export function useTimelineKeyboardShortcuts({
|
||||
handleAddAnnotation,
|
||||
handleAddZoom,
|
||||
handleSplitClip,
|
||||
hasAnyTimelineBlocks,
|
||||
hasAnyZoomBlocks,
|
||||
isMac,
|
||||
isTimelineFocusedRef,
|
||||
keyShortcuts,
|
||||
@@ -152,7 +145,5 @@ export function useTimelineKeyboardShortcuts({
|
||||
selectedClipId,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
setSelectAllBlocksActive,
|
||||
setSelectedKeyframeId,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ export function useTimelineSelection({
|
||||
totalMs,
|
||||
currentTimeMs,
|
||||
zoomRegions,
|
||||
clipRegions,
|
||||
annotationRegions,
|
||||
audioRegions,
|
||||
selectedZoomId,
|
||||
selectedClipId,
|
||||
selectedAnnotationId,
|
||||
@@ -46,6 +44,7 @@ export function useTimelineSelection({
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false);
|
||||
const hasAnyZoomBlocks = useMemo(() => zoomRegions.length > 0, [zoomRegions.length]);
|
||||
|
||||
const addKeyframe = useCallback(() => {
|
||||
if (totalMs === 0) return;
|
||||
@@ -72,10 +71,29 @@ export function useTimelineSelection({
|
||||
);
|
||||
|
||||
const deleteSelectedZoom = useCallback(() => {
|
||||
if (!selectedZoomId) return;
|
||||
onZoomDelete(selectedZoomId);
|
||||
if (selectAllBlocksActive) {
|
||||
zoomRegions.map((region) => region.id).forEach((id) => onZoomDelete(id));
|
||||
} else if (selectedZoomId) {
|
||||
onZoomDelete(selectedZoomId);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectZoom(null);
|
||||
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectAudio?.(null);
|
||||
setSelectAllBlocksActive(false);
|
||||
}, [
|
||||
selectAllBlocksActive,
|
||||
zoomRegions,
|
||||
onZoomDelete,
|
||||
selectedZoomId,
|
||||
onSelectZoom,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectAudio,
|
||||
]);
|
||||
|
||||
const deleteSelectedClip = useCallback(() => {
|
||||
if (!selectedClipId || !onClipDelete || !onSelectClip) return;
|
||||
@@ -103,33 +121,14 @@ export function useTimelineSelection({
|
||||
setSelectAllBlocksActive(false);
|
||||
}, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
|
||||
|
||||
const hasAnyTimelineBlocks = useMemo(
|
||||
() =>
|
||||
zoomRegions.length > 0 ||
|
||||
clipRegions.length > 0 ||
|
||||
annotationRegions.length > 0 ||
|
||||
audioRegions.length > 0,
|
||||
[zoomRegions.length, clipRegions.length, annotationRegions.length, audioRegions.length],
|
||||
);
|
||||
|
||||
const deleteAllBlocks = useCallback(() => {
|
||||
zoomRegions.map((r) => r.id).forEach((id) => onZoomDelete(id));
|
||||
clipRegions.map((r) => r.id).forEach((id) => onClipDelete?.(id));
|
||||
annotationRegions.map((r) => r.id).forEach((id) => onAnnotationDelete?.(id));
|
||||
audioRegions.map((r) => r.id).forEach((id) => onAudioDelete?.(id));
|
||||
clearSelectedBlocks();
|
||||
const activateSelectAllZooms = useCallback(() => {
|
||||
onSelectZoom(null);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectAudio?.(null);
|
||||
setSelectedKeyframeId(null);
|
||||
}, [
|
||||
zoomRegions,
|
||||
clipRegions,
|
||||
annotationRegions,
|
||||
audioRegions,
|
||||
onZoomDelete,
|
||||
onClipDelete,
|
||||
onAnnotationDelete,
|
||||
onAudioDelete,
|
||||
clearSelectedBlocks,
|
||||
]);
|
||||
setSelectAllBlocksActive(true);
|
||||
}, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
|
||||
|
||||
const handleSelectZoom = useCallback(
|
||||
(id: string | null) => {
|
||||
@@ -193,7 +192,8 @@ export function useTimelineSelection({
|
||||
setSelectedKeyframeId,
|
||||
selectAllBlocksActive,
|
||||
setSelectAllBlocksActive,
|
||||
hasAnyTimelineBlocks,
|
||||
hasAnyZoomBlocks,
|
||||
activateSelectAllZooms,
|
||||
addKeyframe,
|
||||
deleteSelectedKeyframe,
|
||||
handleKeyframeMove,
|
||||
@@ -202,7 +202,6 @@ export function useTimelineSelection({
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedAudio,
|
||||
clearSelectedBlocks,
|
||||
deleteAllBlocks,
|
||||
handleSelectZoom,
|
||||
handleSelectClip,
|
||||
handleSelectAnnotation,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils";
|
||||
|
||||
describe("timelineSelectionUtils", () => {
|
||||
it("prioritizes select-all over any individual selection", () => {
|
||||
it("treats zoom select-all as a zoom deletion target", () => {
|
||||
expect(
|
||||
resolveDeleteSelectionTarget({
|
||||
selectAllBlocksActive: true,
|
||||
@@ -12,7 +12,7 @@ describe("timelineSelectionUtils", () => {
|
||||
selectedAnnotationId: "a-1",
|
||||
selectedAudioId: "au-1",
|
||||
}),
|
||||
).toBe("all");
|
||||
).toBe("zoom");
|
||||
});
|
||||
|
||||
it("follows selection priority order", () => {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export type DeleteSelectionTarget =
|
||||
| "all"
|
||||
| "keyframe"
|
||||
| "zoom"
|
||||
| "clip"
|
||||
| "annotation"
|
||||
| "audio"
|
||||
| "none";
|
||||
export type DeleteSelectionTarget = "keyframe" | "zoom" | "clip" | "annotation" | "audio" | "none";
|
||||
|
||||
interface ResolveDeleteSelectionTargetParams {
|
||||
selectAllBlocksActive: boolean;
|
||||
@@ -24,7 +17,7 @@ export function resolveDeleteSelectionTarget({
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
}: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget {
|
||||
if (selectAllBlocksActive) return "all";
|
||||
if (selectAllBlocksActive) return "zoom";
|
||||
if (selectedKeyframeId) return "keyframe";
|
||||
if (selectedZoomId) return "zoom";
|
||||
if (selectedClipId) return "clip";
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface CursorVisualSettings {
|
||||
motionBlur: number;
|
||||
clickBounce: number;
|
||||
clickBounceDuration: number;
|
||||
clickEffect: CursorClickEffectStyle;
|
||||
clickEffectColor: string;
|
||||
clickEffectScale: number;
|
||||
clickEffectOpacity: number;
|
||||
clickEffectDurationMs: number;
|
||||
sway: number;
|
||||
style: CursorStyle;
|
||||
}
|
||||
@@ -53,6 +58,47 @@ 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" | "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;
|
||||
|
||||
export function normalizeCursorClickEffectStyle(
|
||||
value: unknown,
|
||||
fallback: CursorClickEffectStyle = DEFAULT_CURSOR_CLICK_EFFECT,
|
||||
): CursorClickEffectStyle {
|
||||
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"
|
||||
@@ -466,7 +512,10 @@ export const DEFAULT_PADDING: Padding = {
|
||||
right: 20,
|
||||
linked: true,
|
||||
};
|
||||
export type { SourceAudioTrackSetting, SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes";
|
||||
export type {
|
||||
SourceAudioTrackSetting,
|
||||
SourceAudioTrackSettings,
|
||||
} from "@/components/video-editor/audio/audioTypes";
|
||||
|
||||
export interface AudioRegion {
|
||||
id: string;
|
||||
|
||||
@@ -107,4 +107,24 @@ describe("computeCursorFollowFocus", () => {
|
||||
|
||||
expect(clampedFocus).toEqual({ cx: 0.75, cy: 0.75 });
|
||||
});
|
||||
|
||||
it("starts auto zooms from the saved region focus before following the cursor", () => {
|
||||
const state = createCursorFollowCameraState();
|
||||
const cursorSamples = [
|
||||
{ timeMs: 0, cx: 0.8, cy: 0.2, interactionType: "move" as const },
|
||||
{ timeMs: 100, cx: 0.82, cy: 0.22, interactionType: "move" as const },
|
||||
];
|
||||
|
||||
const initialFocus = computeCursorFollowFocus(
|
||||
state,
|
||||
cursorSamples,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
{ cx: 0.3, cy: 0.7 },
|
||||
{ snapToEdgesRatio: 0.25 },
|
||||
);
|
||||
|
||||
expect(initialFocus).toEqual({ cx: 0.3, cy: 0.7 });
|
||||
});
|
||||
});
|
||||
@@ -152,9 +152,11 @@ export function computeCursorFollowFocus(
|
||||
|
||||
const cursorPos = interpolateCursorPosition(cursorSamples, timeMs);
|
||||
if (!cursorPos) {
|
||||
return state.initialized
|
||||
? { cx: state.focusX, cy: state.focusY }
|
||||
: clampedRegionFocus;
|
||||
if (state.initialized) {
|
||||
return { cx: state.focusX, cy: state.focusY };
|
||||
}
|
||||
|
||||
return clampedRegionFocus;
|
||||
}
|
||||
|
||||
// Track when zoom reaches full strength
|
||||
@@ -170,12 +172,7 @@ export function computeCursorFollowFocus(
|
||||
const timeWentBackwards = state.initialized && timeMs + 0.5 < state.lastTimeMs;
|
||||
|
||||
if (!state.initialized || !state.wasZoomed || timeWentBackwards) {
|
||||
const initialFocus = recenterFocusWhenCursorLeavesSafeZone(
|
||||
clampedRegionFocus,
|
||||
{ cx: cursorPos.cx, cy: cursorPos.cy },
|
||||
zoomScale,
|
||||
config.snapToEdgesRatio,
|
||||
);
|
||||
const initialFocus = clampedRegionFocus;
|
||||
state.lastTimeMs = timeMs;
|
||||
state.initialized = true;
|
||||
state.wasZoomed = true;
|
||||
|
||||
@@ -4,10 +4,17 @@ import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg";
|
||||
import { getRenderableAssetUrl } from "@/lib/assetPath";
|
||||
import { extensionHost } from "@/lib/extensions";
|
||||
import {
|
||||
type CursorClickEffectStyle,
|
||||
type CursorStyle,
|
||||
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";
|
||||
@@ -85,6 +92,16 @@ export interface CursorRenderConfig {
|
||||
clickBounce: number;
|
||||
/** Click bounce duration in milliseconds. */
|
||||
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. */
|
||||
clickEffectOpacity: number;
|
||||
/** Click effect duration in milliseconds. */
|
||||
clickEffectDurationMs: number;
|
||||
/** Cursor sway multiplier. */
|
||||
sway: number;
|
||||
/** Cursor visual style. */
|
||||
@@ -105,13 +122,17 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
|
||||
motionBlur: 0,
|
||||
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,
|
||||
sway: 0,
|
||||
style: DEFAULT_CURSOR_STYLE,
|
||||
};
|
||||
|
||||
const REFERENCE_WIDTH = 1920;
|
||||
const MIN_CURSOR_VIEWPORT_SCALE = 0.55;
|
||||
const CLICK_RING_FADE_MS = 600;
|
||||
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
|
||||
const CURSOR_TIME_DISCONTINUITY_MS = 100;
|
||||
const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7;
|
||||
@@ -200,7 +221,7 @@ async function createCursorStyleAsset(style: SingleCursorStyle): Promise<LoadedC
|
||||
const trimmed = trimCanvasToAlpha(sourceCanvas, { x: 40, y: 22 });
|
||||
await Assets.load(trimmed.dataUrl);
|
||||
const trimmedImage = await loadImage(trimmed.dataUrl);
|
||||
const texture = Texture.from(trimmed.dataUrl);
|
||||
const texture = configureCursorTexture(Texture.from(trimmed.dataUrl));
|
||||
|
||||
return {
|
||||
texture,
|
||||
@@ -229,7 +250,7 @@ async function createCursorStyleAsset(style: SingleCursorStyle): Promise<LoadedC
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
await Assets.load(dataUrl);
|
||||
const image = await loadImage(dataUrl);
|
||||
const texture = Texture.from(dataUrl);
|
||||
const texture = configureCursorTexture(Texture.from(dataUrl));
|
||||
|
||||
return {
|
||||
texture,
|
||||
@@ -247,7 +268,7 @@ async function createCursorPackAsset(
|
||||
const renderableUrl = await getRenderableAssetUrl(url);
|
||||
await Assets.load(renderableUrl);
|
||||
const image = await loadImage(renderableUrl);
|
||||
const texture = Texture.from(renderableUrl);
|
||||
const texture = configureCursorTexture(Texture.from(renderableUrl));
|
||||
|
||||
return {
|
||||
texture,
|
||||
@@ -258,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();
|
||||
@@ -272,6 +340,12 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function configureCursorTexture(texture: Texture) {
|
||||
texture.source.scaleMode = "linear";
|
||||
texture.source.autoGenerateMipmaps = false;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
@@ -367,7 +441,7 @@ async function createInvertedCursorAsset(asset: LoadedCursorAsset): Promise<Load
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
await Assets.load(dataUrl);
|
||||
const image = await loadImage(dataUrl);
|
||||
const texture = Texture.from(dataUrl);
|
||||
const texture = configureCursorTexture(Texture.from(dataUrl));
|
||||
|
||||
return {
|
||||
texture,
|
||||
@@ -477,22 +551,14 @@ export async function preloadCursorAssets() {
|
||||
}
|
||||
|
||||
try {
|
||||
await Assets.load(sourceAsset.url);
|
||||
const image = await loadImage(sourceAsset.url);
|
||||
const texture = 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(
|
||||
@@ -745,10 +811,7 @@ function getCursorViewportScale(viewport: CursorViewportRect) {
|
||||
return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
|
||||
}
|
||||
|
||||
function getCursorSwaySpringConfig(
|
||||
smoothingFactor: number,
|
||||
springTuning: CursorSpringTuning,
|
||||
) {
|
||||
function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) {
|
||||
const baseConfig = getCursorSpringConfig(
|
||||
Math.min(
|
||||
2,
|
||||
@@ -769,14 +832,168 @@ function getCursorSwaySpringConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function getClickEffectColor(clickEffectColor: string) {
|
||||
const normalized = normalizeCursorClickEffectColor(clickEffectColor);
|
||||
return Number.parseInt(normalized.slice(1), 16);
|
||||
}
|
||||
|
||||
function getExtensionStyleRippleMetrics(
|
||||
cursorSize: number,
|
||||
clickProgress: number,
|
||||
effectScale: number,
|
||||
effectOpacity: number,
|
||||
) {
|
||||
const eased = 1 - Math.pow(clickProgress, 3);
|
||||
const fade = Math.pow(clickProgress, 3);
|
||||
|
||||
return {
|
||||
radius: Math.max(0.5, eased * cursorSize * 1.95 * effectScale),
|
||||
alpha: Math.max(0, Math.min(1, fade * 0.6 * effectOpacity)),
|
||||
strokeWidth: Math.max(1, 2 * fade),
|
||||
};
|
||||
}
|
||||
|
||||
function drawClickEffectGraphics(
|
||||
graphics: Graphics,
|
||||
effect: CursorClickEffectStyle,
|
||||
px: number,
|
||||
py: number,
|
||||
cursorSize: number,
|
||||
clickProgress: number,
|
||||
effectScale: number,
|
||||
effectOpacity: number,
|
||||
effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR,
|
||||
) {
|
||||
graphics.clear();
|
||||
if (effect === "none" || clickProgress <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reveal = 1 - clickProgress;
|
||||
const alpha = clickProgress * effectOpacity;
|
||||
const color = getClickEffectColor(effectColor);
|
||||
const baseRadius = Math.max(12, cursorSize * 0.55 * effectScale);
|
||||
const strokeWidth = Math.max(2, cursorSize * 0.08);
|
||||
|
||||
if (effect === "ripple") {
|
||||
const ripple = getExtensionStyleRippleMetrics(
|
||||
cursorSize,
|
||||
clickProgress,
|
||||
effectScale,
|
||||
effectOpacity,
|
||||
);
|
||||
graphics.circle(px, py, ripple.radius);
|
||||
graphics.stroke({ width: ripple.strokeWidth, color, alpha: ripple.alpha });
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect === "spotlight") {
|
||||
const glowRadius = baseRadius + reveal * cursorSize * effectScale;
|
||||
const innerRadius = Math.max(baseRadius * 0.72, glowRadius * 0.76);
|
||||
graphics.circle(px, py, glowRadius);
|
||||
graphics.stroke({ width: Math.max(1.25, strokeWidth * 0.68), color, alpha: alpha * 0.28 });
|
||||
graphics.circle(px, py, innerRadius);
|
||||
graphics.stroke({ width: Math.max(1.5, strokeWidth * 0.75), color, alpha: alpha * 0.5 });
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
effect: CursorClickEffectStyle,
|
||||
px: number,
|
||||
py: number,
|
||||
cursorSize: number,
|
||||
clickProgress: number,
|
||||
effectScale: number,
|
||||
effectOpacity: number,
|
||||
effectColor: string = DEFAULT_CURSOR_CLICK_EFFECT_COLOR,
|
||||
) {
|
||||
if (effect === "none" || clickProgress <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reveal = 1 - clickProgress;
|
||||
const alpha = clickProgress * effectOpacity;
|
||||
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);
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (effect === "ripple") {
|
||||
const ripple = getExtensionStyleRippleMetrics(
|
||||
cursorSize,
|
||||
clickProgress,
|
||||
effectScale,
|
||||
effectOpacity,
|
||||
);
|
||||
ctx.lineWidth = ripple.strokeWidth;
|
||||
ctx.strokeStyle = `${strokeColor}${ripple.alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, ripple.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (effect === "spotlight") {
|
||||
const glowRadius = baseRadius + reveal * cursorSize * effectScale;
|
||||
const innerRadius = Math.max(baseRadius * 0.72, glowRadius * 0.76);
|
||||
ctx.lineWidth = Math.max(1.25, strokeWidth * 0.68);
|
||||
ctx.strokeStyle = `${strokeColor}${(alpha * 0.28).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, glowRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = Math.max(1.5, strokeWidth * 0.75);
|
||||
ctx.strokeStyle = `${strokeColor}${(alpha * 0.5).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, innerRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
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.72).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, echoOuterRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = Math.max(1.4, strokeWidth * 0.72);
|
||||
ctx.strokeStyle = `${strokeColor}${(alpha * 0.42).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
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();
|
||||
}
|
||||
|
||||
function getCursorVisualState(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
clickBounceDuration: number,
|
||||
clickEffectDurationMs: number,
|
||||
) {
|
||||
const latestClick = findLatestInteractionSample(samples, timeMs);
|
||||
const interactionType = latestClick?.interactionType;
|
||||
const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY;
|
||||
const clickEffectDelayMs = clickBounceDuration * 0.5;
|
||||
const clickEffectAgeMs = ageMs - clickEffectDelayMs;
|
||||
const isClickEvent =
|
||||
interactionType === "click" ||
|
||||
interactionType === "double-click" ||
|
||||
@@ -789,10 +1006,15 @@ function getCursorVisualState(
|
||||
|
||||
return {
|
||||
cursorType: findLatestStableCursorType(samples, timeMs),
|
||||
interactionType,
|
||||
clickSample: latestClick && isClickEvent ? latestClick : null,
|
||||
clickBounceProgress,
|
||||
clickProgress:
|
||||
latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
|
||||
? 1 - ageMs / CLICK_RING_FADE_MS
|
||||
latestClick &&
|
||||
isClickEvent &&
|
||||
clickEffectAgeMs >= 0 &&
|
||||
clickEffectAgeMs <= clickEffectDurationMs
|
||||
? 1 - clickEffectAgeMs / clickEffectDurationMs
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
@@ -812,7 +1034,9 @@ export class SmoothedCursorState {
|
||||
private xSpring = createSpringState(0.5);
|
||||
private ySpring = createSpringState(0.5);
|
||||
|
||||
constructor(config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength" | "springTuning">) {
|
||||
constructor(
|
||||
config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength" | "springTuning">,
|
||||
) {
|
||||
this.smoothingFactor = config.smoothingFactor;
|
||||
this.springTuning = config.springTuning;
|
||||
this.trailLength = config.trailLength;
|
||||
@@ -923,6 +1147,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();
|
||||
@@ -934,6 +1159,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 = {};
|
||||
@@ -942,6 +1168,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();
|
||||
@@ -955,6 +1182,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;
|
||||
}
|
||||
|
||||
@@ -1003,6 +1231,26 @@ export class PixiCursorOverlay {
|
||||
this.config.clickBounce = Math.max(0, clickBounce);
|
||||
}
|
||||
|
||||
setClickEffect(clickEffect: CursorClickEffectStyle) {
|
||||
this.config.clickEffect = clickEffect;
|
||||
}
|
||||
|
||||
setClickEffectColor(clickEffectColor: string) {
|
||||
this.config.clickEffectColor = normalizeCursorClickEffectColor(clickEffectColor);
|
||||
}
|
||||
|
||||
setClickEffectScale(clickEffectScale: number) {
|
||||
this.config.clickEffectScale = clamp(clickEffectScale, 0.5, 2);
|
||||
}
|
||||
|
||||
setClickEffectOpacity(clickEffectOpacity: number) {
|
||||
this.config.clickEffectOpacity = clamp(clickEffectOpacity, 0, 1);
|
||||
}
|
||||
|
||||
setClickEffectDurationMs(clickEffectDurationMs: number) {
|
||||
this.config.clickEffectDurationMs = clamp(clickEffectDurationMs, 120, 1200);
|
||||
}
|
||||
|
||||
setClickBounceDuration(clickBounceDuration: number) {
|
||||
this.config.clickBounceDuration = clamp(clickBounceDuration, 60, 500);
|
||||
}
|
||||
@@ -1108,11 +1356,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, clickBounceProgress } = getCursorVisualState(
|
||||
samples,
|
||||
timeMs,
|
||||
this.config.clickBounceDuration,
|
||||
);
|
||||
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),
|
||||
@@ -1120,7 +1381,17 @@ export class PixiCursorOverlay {
|
||||
const scaledH = h * getCursorStyleSizeMultiplier(this.config.style);
|
||||
const swayRotation = this.updateCursorSway(px, py, timeMs, shouldFreezeCursorMotion);
|
||||
|
||||
this.clickRingGraphics.clear();
|
||||
drawClickEffectGraphics(
|
||||
this.clickRingGraphics,
|
||||
this.config.clickEffect,
|
||||
clickEffectPx,
|
||||
clickEffectPy,
|
||||
scaledH,
|
||||
clickProgress,
|
||||
this.config.clickEffectScale,
|
||||
this.config.clickEffectOpacity,
|
||||
this.config.clickEffectColor,
|
||||
);
|
||||
|
||||
const spriteKey = (
|
||||
cursorType in this.cursorSprites ? cursorType : "arrow"
|
||||
@@ -1317,11 +1588,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, clickBounceProgress } = getCursorVisualState(
|
||||
samples,
|
||||
timeMs,
|
||||
config.clickBounceDuration,
|
||||
);
|
||||
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;
|
||||
@@ -1334,13 +1618,25 @@ export function drawCursorOnCanvas(
|
||||
0.72,
|
||||
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce),
|
||||
);
|
||||
const effectHeight = h * getCursorStyleSizeMultiplier(config.style);
|
||||
const drawHeight = effectHeight * bounceScale;
|
||||
drawClickEffectOnCanvas(
|
||||
ctx,
|
||||
config.clickEffect,
|
||||
clickEffectPx,
|
||||
clickEffectPy,
|
||||
effectHeight,
|
||||
clickProgress,
|
||||
config.clickEffectScale,
|
||||
config.clickEffectOpacity,
|
||||
config.clickEffectColor,
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
if (config.style !== "figma") {
|
||||
ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
|
||||
}
|
||||
|
||||
const drawHeight = h * bounceScale * getCursorStyleSizeMultiplier(config.style);
|
||||
const drawWidth = drawHeight * asset.aspectRatio;
|
||||
const hotspotX = asset.anchorX * drawWidth;
|
||||
const hotspotY = asset.anchorY * drawHeight;
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface CursorViewportRect {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
renderWidth?: number;
|
||||
renderHeight?: number;
|
||||
sourceCrop?: CropRegion;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scalePreviewBorderRadius } from "./layoutUtils";
|
||||
|
||||
describe("scalePreviewBorderRadius", () => {
|
||||
it("matches export scaling against the logical preview size", () => {
|
||||
expect(scalePreviewBorderRadius(1920, 1080, 16)).toBeCloseTo(16, 6);
|
||||
expect(scalePreviewBorderRadius(960, 540, 16)).toBeCloseTo(8, 6);
|
||||
expect(scalePreviewBorderRadius(1440, 810, 16)).toBeCloseTo(12, 6);
|
||||
});
|
||||
|
||||
it("clamps invalid or empty preview sizes to zero", () => {
|
||||
expect(scalePreviewBorderRadius(0, 540, 16)).toBe(0);
|
||||
expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0);
|
||||
expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,21 @@ 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 scalePreviewBorderRadius(
|
||||
width: number,
|
||||
height: number,
|
||||
borderRadius = 0,
|
||||
): number {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const canvasScaleFactor = Math.min(width / BASE_PREVIEW_WIDTH, height / BASE_PREVIEW_HEIGHT);
|
||||
return Math.max(0, borderRadius * canvasScaleFactor);
|
||||
}
|
||||
|
||||
export function isZeroPadding(padding: Padding | number): boolean {
|
||||
if (typeof padding === "number") {
|
||||
@@ -201,7 +216,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
y: layout.centerOffsetY,
|
||||
width: layout.croppedDisplayWidth,
|
||||
height: layout.croppedDisplayHeight,
|
||||
radius: borderRadius,
|
||||
radius: scalePreviewBorderRadius(width, height, borderRadius),
|
||||
});
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
|
||||
@@ -97,6 +97,32 @@
|
||||
"cursorSpringDamping": "Cursor Spring Damping",
|
||||
"cursorSpringMass": "Cursor Spring Mass",
|
||||
"off": "Off",
|
||||
"cursorClickEffects": {
|
||||
"title": "Click Effects",
|
||||
"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",
|
||||
"none": {
|
||||
"label": "Off",
|
||||
"description": "No click graphic. Only the cursor motion changes when you click."
|
||||
},
|
||||
"ripple": {
|
||||
"label": "Ripple",
|
||||
"description": "Expanding rings radiate from each click so taps read clearly in motion."
|
||||
},
|
||||
"spotlight": {
|
||||
"label": "Spotlight",
|
||||
"description": "A soft halo flashes around the pointer to emphasize the clicked area."
|
||||
},
|
||||
"echo": {
|
||||
"label": "Echo",
|
||||
"description": "A pair of soft rings that spread outward with a cleaner pulse."
|
||||
}
|
||||
},
|
||||
"cursorMotionBlur": "Cursor Motion Blur",
|
||||
"cursorClickBounce": "Cursor Click Bounce",
|
||||
"cursorClickBounceDuration": "Bounce Speed",
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateOutputDimensions, getGifRepeat } from "./gifExporter";
|
||||
import {
|
||||
buildGifFrameRendererConfig,
|
||||
calculateOutputDimensions,
|
||||
getGifRepeat,
|
||||
} from "./gifExporter";
|
||||
import { GIF_SIZE_PRESETS, GifSizePreset } from "./types";
|
||||
|
||||
/**
|
||||
@@ -249,6 +253,41 @@ describe("Property 3: Size Preset Resolution Mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GIF renderer config", () => {
|
||||
it("forwards cursor click-effect settings into the frame renderer config", () => {
|
||||
const config = buildGifFrameRendererConfig(
|
||||
{
|
||||
videoUrl: "file:///recording.mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 30,
|
||||
loop: true,
|
||||
sizePreset: "original",
|
||||
wallpaper: "#101010",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
backgroundBlur: 0,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
cursorClickEffect: "echo",
|
||||
cursorClickEffectColor: "#22C55E",
|
||||
cursorClickEffectScale: 1.4,
|
||||
cursorClickEffectOpacity: 0.65,
|
||||
cursorClickEffectDurationMs: 720,
|
||||
} as never,
|
||||
{ width: 1920, height: 1080 },
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
cursorClickEffect: "echo",
|
||||
cursorClickEffectColor: "#22C55E",
|
||||
cursorClickEffectScale: 1.4,
|
||||
cursorClickEffectOpacity: 0.65,
|
||||
cursorClickEffectDurationMs: 720,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 6: Frame Count Consistency
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
@@ -128,6 +134,71 @@ export function getGifRepeat(loop: boolean): 0 | 1 {
|
||||
return loop ? 0 : 1;
|
||||
}
|
||||
|
||||
export function buildGifFrameRendererConfig(
|
||||
config: GifExporterConfig,
|
||||
videoInfo: { width: number; height: number },
|
||||
) {
|
||||
return {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
wallpaper: config.wallpaper,
|
||||
zoomRegions: config.zoomRegions,
|
||||
showShadow: config.showShadow,
|
||||
shadowIntensity: config.shadowIntensity,
|
||||
backgroundBlur: config.backgroundBlur,
|
||||
zoomMotionBlur: config.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: config.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: config.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: config.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: config.zoomMotionBlurShutterFraction,
|
||||
connectZooms: config.connectZooms,
|
||||
zoomInDurationMs: config.zoomInDurationMs,
|
||||
zoomInOverlapMs: config.zoomInOverlapMs,
|
||||
zoomOutDurationMs: config.zoomOutDurationMs,
|
||||
connectedZoomGapMs: config.connectedZoomGapMs,
|
||||
connectedZoomDurationMs: config.connectedZoomDurationMs,
|
||||
zoomInEasing: config.zoomInEasing,
|
||||
zoomOutEasing: config.zoomOutEasing,
|
||||
connectedZoomEasing: config.connectedZoomEasing,
|
||||
borderRadius: config.borderRadius,
|
||||
padding: config.padding,
|
||||
cropRegion: config.cropRegion,
|
||||
webcam: config.webcam,
|
||||
webcamUrl: config.webcamUrl,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: config.annotationRegions,
|
||||
autoCaptions: config.autoCaptions,
|
||||
autoCaptionSettings: config.autoCaptionSettings,
|
||||
speedRegions: config.speedRegions,
|
||||
previewWidth: config.previewWidth,
|
||||
previewHeight: config.previewHeight,
|
||||
cursorTelemetry: config.cursorTelemetry,
|
||||
showCursor: config.showCursor,
|
||||
cursorStyle: config.cursorStyle,
|
||||
cursorSize: config.cursorSize,
|
||||
cursorSmoothing: config.cursorSmoothing,
|
||||
cursorSpringStiffnessMultiplier: config.cursorSpringStiffnessMultiplier,
|
||||
cursorSpringDampingMultiplier: config.cursorSpringDampingMultiplier,
|
||||
cursorSpringMassMultiplier: config.cursorSpringMassMultiplier,
|
||||
cameraSpringStiffnessMultiplier: config.cameraSpringStiffnessMultiplier,
|
||||
cameraSpringDampingMultiplier: config.cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier: config.cameraSpringMassMultiplier,
|
||||
zoomSmoothness: config.zoomSmoothness,
|
||||
zoomClassicMode: config.zoomClassicMode,
|
||||
cursorMotionBlur: config.cursorMotionBlur,
|
||||
cursorClickEffect: config.cursorClickEffect,
|
||||
cursorClickEffectColor: config.cursorClickEffectColor,
|
||||
cursorClickEffectScale: config.cursorClickEffectScale,
|
||||
cursorClickEffectOpacity: config.cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs: config.cursorClickEffectDurationMs,
|
||||
cursorClickBounce: config.cursorClickBounce,
|
||||
cursorClickBounceDuration: config.cursorClickBounceDuration,
|
||||
cursorSway: config.cursorSway,
|
||||
frame: config.frame,
|
||||
};
|
||||
}
|
||||
|
||||
export class GifExporter {
|
||||
private config: GifExporterConfig;
|
||||
private streamingDecoder: StreamingVideoDecoder | null = null;
|
||||
@@ -160,60 +231,7 @@ export class GifExporter {
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
wallpaper: this.config.wallpaper,
|
||||
zoomRegions: this.config.zoomRegions,
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
backgroundBlur: this.config.backgroundBlur,
|
||||
zoomMotionBlur: this.config.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
|
||||
connectZooms: this.config.connectZooms,
|
||||
zoomInDurationMs: this.config.zoomInDurationMs,
|
||||
zoomInOverlapMs: this.config.zoomInOverlapMs,
|
||||
zoomOutDurationMs: this.config.zoomOutDurationMs,
|
||||
connectedZoomGapMs: this.config.connectedZoomGapMs,
|
||||
connectedZoomDurationMs: this.config.connectedZoomDurationMs,
|
||||
zoomInEasing: this.config.zoomInEasing,
|
||||
zoomOutEasing: this.config.zoomOutEasing,
|
||||
connectedZoomEasing: this.config.connectedZoomEasing,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
webcam: this.config.webcam,
|
||||
webcamUrl: this.config.webcamUrl,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
autoCaptions: this.config.autoCaptions,
|
||||
autoCaptionSettings: this.config.autoCaptionSettings,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
showCursor: this.config.showCursor,
|
||||
cursorStyle: this.config.cursorStyle,
|
||||
cursorSize: this.config.cursorSize,
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
|
||||
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
|
||||
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
|
||||
cameraSpringStiffnessMultiplier: this.config.cameraSpringStiffnessMultiplier,
|
||||
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
|
||||
zoomSmoothness: this.config.zoomSmoothness,
|
||||
zoomClassicMode: this.config.zoomClassicMode,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
|
||||
cursorSway: this.config.cursorSway,
|
||||
frame: this.config.frame,
|
||||
});
|
||||
this.renderer = new FrameRenderer(buildGifFrameRendererConfig(this.config, videoInfo));
|
||||
await this.renderer.initialize();
|
||||
|
||||
// Initialize GIF encoder
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -289,4 +289,52 @@ describe("ModernVideoExporter native fallback routing", () => {
|
||||
expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards cursor click-effect settings into the modern frame renderer", async () => {
|
||||
const { ModernVideoExporter } = await import("./modernVideoExporter");
|
||||
const { FrameRenderer } = await import("./modernFrameRenderer");
|
||||
mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1);
|
||||
|
||||
const exporter = new ModernVideoExporter({
|
||||
videoUrl: "file:///recording.mp4",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 30,
|
||||
bitrate: 8_000_000,
|
||||
wallpaper: "#101010",
|
||||
padding: 0,
|
||||
borderRadius: 24,
|
||||
backgroundBlur: 0,
|
||||
shadowIntensity: 0,
|
||||
showShadow: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
backendPreference: "webcodecs",
|
||||
cursorClickEffect: "echo",
|
||||
cursorClickEffectColor: "#22C55E",
|
||||
cursorClickEffectScale: 1.4,
|
||||
cursorClickEffectOpacity: 0.65,
|
||||
cursorClickEffectDurationMs: 720,
|
||||
} as never) as unknown as {
|
||||
export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>;
|
||||
initializeEncoder: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({
|
||||
codec: "avc1.640034",
|
||||
hardwareAcceleration: "prefer-hardware",
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(FrameRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursorClickEffect: "echo",
|
||||
cursorClickEffectColor: "#22C55E",
|
||||
cursorClickEffectScale: 1.4,
|
||||
cursorClickEffectOpacity: 0.65,
|
||||
cursorClickEffectDurationMs: 720,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +235,28 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("skips native static-layout when cursor click effects are enabled", () => {
|
||||
const exporter = createExporter({
|
||||
showCursor: true,
|
||||
cursorClickEffect: "echo",
|
||||
cursorTelemetry: [
|
||||
{ timeMs: 0, cx: 0.25, cy: 0.35 },
|
||||
{ timeMs: 1_000, cx: 0.5, cy: 0.55, interactionType: "click" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
exporter.getNativeStaticLayoutSkipReason(
|
||||
{
|
||||
audioMode: "copy-source",
|
||||
audioSourcePath: "recording.mp4",
|
||||
},
|
||||
videoInfo,
|
||||
60,
|
||||
),
|
||||
).toBe("unsupported-cursor-click-effect");
|
||||
});
|
||||
|
||||
it("reports frame overlays as the remaining native overlay blocker", () => {
|
||||
const exporter = createExporter({ frame: "macbook" });
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CaptionCue,
|
||||
ClipRegion,
|
||||
CropRegion,
|
||||
CursorClickEffectStyle,
|
||||
CursorStyle,
|
||||
CursorTelemetryPoint,
|
||||
Padding,
|
||||
@@ -135,6 +136,11 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
cameraSpringDampingMultiplier?: number;
|
||||
cameraSpringMassMultiplier?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickEffect?: CursorClickEffectStyle;
|
||||
cursorClickEffectColor?: string;
|
||||
cursorClickEffectScale?: number;
|
||||
cursorClickEffectOpacity?: number;
|
||||
cursorClickEffectDurationMs?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorClickBounceDuration?: number;
|
||||
cursorSway?: number;
|
||||
@@ -624,6 +630,11 @@ export class ModernVideoExporter {
|
||||
cameraSpringDampingMultiplier: this.config.cameraSpringDampingMultiplier,
|
||||
cameraSpringMassMultiplier: this.config.cameraSpringMassMultiplier,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickEffect: this.config.cursorClickEffect,
|
||||
cursorClickEffectColor: this.config.cursorClickEffectColor,
|
||||
cursorClickEffectScale: this.config.cursorClickEffectScale,
|
||||
cursorClickEffectOpacity: this.config.cursorClickEffectOpacity,
|
||||
cursorClickEffectDurationMs: this.config.cursorClickEffectDurationMs,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
|
||||
cursorSway: this.config.cursorSway,
|
||||
@@ -1507,10 +1518,17 @@ export class ModernVideoExporter {
|
||||
}
|
||||
|
||||
const speedRegions = this.config.speedRegions ?? [];
|
||||
const hasCursorClickEffect =
|
||||
this.config.showCursor === true &&
|
||||
(this.config.cursorTelemetry?.length ?? 0) > 0 &&
|
||||
(this.config.cursorClickEffect ?? "none") !== "none";
|
||||
const configuredWallpaper = this.config.wallpaper?.trim() ?? "";
|
||||
if (isVideoWallpaperSource(configuredWallpaper)) {
|
||||
reasons.push("unsupported-background-video");
|
||||
}
|
||||
if (hasCursorClickEffect) {
|
||||
reasons.push("unsupported-cursor-click-effect");
|
||||
}
|
||||
|
||||
const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0;
|
||||
const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap(
|
||||
|
||||
Reference in New Issue
Block a user