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:
webadderall
2026-05-30 16:38:01 +10:00
committed by GitHub
26 changed files with 1856 additions and 583 deletions
+506 -99
View File
@@ -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}
+224 -174
View File
@@ -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
+144 -17
View File
@@ -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";
+50 -1
View File
@@ -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 });
+26
View File
@@ -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",
+17
View File
@@ -5,6 +5,7 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
CursorClickEffectStyle,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
@@ -130,6 +131,11 @@ interface FrameRenderConfig {
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
cursorClickEffect?: CursorClickEffectStyle;
cursorClickEffectColor?: string;
cursorClickEffectScale?: number;
cursorClickEffectOpacity?: number;
cursorClickEffectDurationMs?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
cursorSway?: number;
@@ -442,6 +448,17 @@ export class FrameRenderer {
massMultiplier: this.config.cursorSpringMassMultiplier,
},
motionBlur: this.config.cursorMotionBlur ?? 0,
clickEffect:
this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect,
clickEffectColor:
this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor,
clickEffectScale:
this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale,
clickEffectOpacity:
this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity,
clickEffectDurationMs:
this.config.cursorClickEffectDurationMs ??
DEFAULT_CURSOR_CONFIG.clickEffectDurationMs,
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
clickBounceDuration:
this.config.cursorClickBounceDuration ??
+40 -1
View File
@@ -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
*
+72 -54
View File
@@ -3,6 +3,7 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
CursorClickEffectStyle,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
@@ -79,6 +80,11 @@ interface GifExporterConfig {
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
cursorClickEffect?: CursorClickEffectStyle;
cursorClickEffectColor?: string;
cursorClickEffectScale?: number;
cursorClickEffectOpacity?: number;
cursorClickEffectDurationMs?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
cursorSway?: number;
@@ -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
+17
View File
@@ -15,6 +15,7 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
CursorClickEffectStyle,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
@@ -146,6 +147,11 @@ interface FrameRenderConfig {
cameraSpringDampingMultiplier?: number;
cameraSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickEffect?: CursorClickEffectStyle;
cursorClickEffectColor?: string;
cursorClickEffectScale?: number;
cursorClickEffectOpacity?: number;
cursorClickEffectDurationMs?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
cursorSway?: number;
@@ -616,6 +622,17 @@ export class FrameRenderer {
massMultiplier: this.config.cursorSpringMassMultiplier,
},
motionBlur: this.config.cursorMotionBlur ?? 0,
clickEffect:
this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect,
clickEffectColor:
this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor,
clickEffectScale:
this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale,
clickEffectOpacity:
this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity,
clickEffectDurationMs:
this.config.cursorClickEffectDurationMs ??
DEFAULT_CURSOR_CONFIG.clickEffectDurationMs,
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
clickBounceDuration:
this.config.cursorClickBounceDuration ??
@@ -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" });
+18
View File
@@ -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(