Merge remote-tracking branch 'upstream/main' into codex/allow-png-background-upload
# Conflicts: # src/components/video-editor/SettingsPanel.tsx # src/i18n/locales/ja-JP/settings.json
This commit is contained in:
@@ -44,13 +44,13 @@
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 12rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
width: 11rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(8, 9, 12, 0.96);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -60,10 +60,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.425rem 0.5rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
@@ -72,12 +72,12 @@
|
||||
|
||||
.languageMenuItem:hover,
|
||||
.languageMenuItem:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.languageMenuItemActive {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(52, 178, 123, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -62,16 +62,16 @@ function getIcon(name: IconName, className?: string) {
|
||||
}
|
||||
|
||||
const hudGroupClasses =
|
||||
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
|
||||
"flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]";
|
||||
|
||||
const hudIconBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95";
|
||||
|
||||
const hudAuxIconBtnClasses =
|
||||
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
const windowBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||
|
||||
@@ -87,6 +87,7 @@ export function LaunchWindow() {
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase();
|
||||
|
||||
const {
|
||||
recording,
|
||||
@@ -248,6 +249,13 @@ export function LaunchWindow() {
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
|
||||
return () => {
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -314,7 +322,19 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
|
||||
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||
// 100vw can exceed the inner layout width when scrollbars affect the
|
||||
// viewport (notably on Windows), causing a horizontal scrollbar once the
|
||||
// recording toolbar widened (issue #305).
|
||||
<div
|
||||
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||
onPointerMove={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']"));
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture);
|
||||
}}
|
||||
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
|
||||
>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||
@@ -354,12 +374,13 @@ export function LaunchWindow() {
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
<div
|
||||
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
{/* Mic selector */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsMicHovered(true)}
|
||||
onMouseLeave={() => setIsMicHovered(false)}
|
||||
onFocus={() => setIsMicFocused(true)}
|
||||
@@ -403,7 +424,7 @@ export function LaunchWindow() {
|
||||
{/* Webcam selector */}
|
||||
{showWebcamControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsWebcamHovered(true)}
|
||||
onMouseLeave={() => setIsWebcamHovered(false)}
|
||||
onFocus={() => setIsWebcamFocused(true)}
|
||||
@@ -479,7 +500,8 @@ export function LaunchWindow() {
|
||||
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
@@ -488,13 +510,15 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
|
||||
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
@@ -542,7 +566,7 @@ export function LaunchWindow() {
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "bg-red-500/12 hover:bg-red-500/16"
|
||||
: "bg-white/5 hover:bg-white/[0.08]"
|
||||
: "bg-white/[0.06] hover:bg-white/[0.10]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
@@ -618,11 +642,12 @@ export function LaunchWindow() {
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
<Languages size={13} className="text-white/70" />
|
||||
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
|
||||
{activeLanguageLabel}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.glassContainer {
|
||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%);
|
||||
backdrop-filter: blur(24px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||
border-radius: 24px;
|
||||
corner-shape: squircle;
|
||||
/*
|
||||
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
|
||||
@@ -11,34 +11,36 @@
|
||||
/* box-shadow:
|
||||
0 0px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
|
||||
border: 1.5px solid rgba(60, 60, 80, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
corner-shape: squircle;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
border-radius: 13px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sourceCard:hover {
|
||||
border-color: rgba(120, 120, 160, 0.35);
|
||||
background: rgba(255, 255, 255, 0.065);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 1.5px solid #34b27b;
|
||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||
border-color: rgba(52, 178, 123, 0.68);
|
||||
background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045));
|
||||
box-shadow:
|
||||
0 0 12px rgba(52, 178, 123, 0.15),
|
||||
0 0 4px rgba(52, 178, 123, 0.1);
|
||||
0 0 0 1px rgba(52, 178, 123, 0.18) inset,
|
||||
0 12px 28px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
@@ -46,16 +48,16 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.72rem;
|
||||
color: #e4e4e7;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
@@ -65,14 +67,14 @@
|
||||
|
||||
/* Checkmark badge */
|
||||
.checkBadge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
background: #34b27b;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
|
||||
@@ -77,24 +77,24 @@ export function SourceSelector() {
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="relative mb-1.5">
|
||||
<div className="relative mb-1.5 overflow-hidden rounded-lg border border-white/[0.06] bg-black/30">
|
||||
<img
|
||||
src={source.thumbnail || ""}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
|
||||
className="w-full aspect-video object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="absolute right-1.5 top-1.5">
|
||||
<div className={styles.checkBadge}>
|
||||
<MdCheck size={12} className="text-white" />
|
||||
<MdCheck size={11} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 px-1 pb-0.5">
|
||||
{source.appIcon && (
|
||||
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
|
||||
)}
|
||||
@@ -106,21 +106,21 @@ export function SourceSelector() {
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full px-4 pt-4">
|
||||
<div className="flex-1 flex flex-col w-full px-3.5 pt-3.5">
|
||||
<Tabs
|
||||
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
|
||||
<TabsList className="mb-3 grid h-8 grid-cols-2 rounded-xl border border-white/[0.06] bg-white/[0.04] p-0.5">
|
||||
<TabsTrigger
|
||||
value="screens"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
|
||||
>
|
||||
{t("sourceSelector.screens", { count: String(screenSources.length) })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="windows"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
|
||||
>
|
||||
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||
</TabsTrigger>
|
||||
@@ -128,14 +128,14 @@ export function SourceSelector() {
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabsContent value="screens" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{screenSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
@@ -143,18 +143,18 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 justify-center flex gap-2">
|
||||
<div className="flex justify-center gap-2 border-t border-white/[0.06] p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
|
||||
className="h-8 rounded-lg px-5 text-[11px] text-zinc-400 transition-transform duration-150 hover:bg-white/5 hover:text-white active:scale-95"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
className="h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95 disabled:bg-zinc-700 disabled:opacity-30"
|
||||
>
|
||||
{tc("actions.share")}
|
||||
</Button>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AnnotationOverlay({
|
||||
);
|
||||
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
|
||||
const blurType = "mosaic";
|
||||
const blurOverlayColor =
|
||||
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
|
||||
const mosaicGridOverlayColor =
|
||||
@@ -106,7 +106,7 @@ export function AnnotationOverlay({
|
||||
const { x, y, width, height } = liveRect;
|
||||
|
||||
useEffect(() => {
|
||||
if (annotation.type !== "blur" || blurType !== "mosaic") {
|
||||
if (annotation.type !== "blur") {
|
||||
return;
|
||||
}
|
||||
void previewFrameVersion;
|
||||
@@ -173,7 +173,6 @@ export function AnnotationOverlay({
|
||||
);
|
||||
}, [
|
||||
annotation,
|
||||
blurType,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
height,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Italic,
|
||||
Trash2,
|
||||
Type,
|
||||
@@ -148,39 +147,39 @@ export function AnnotationSettingsPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-3">
|
||||
<div className="mb-4">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-100">{t("annotation.title")}</div>
|
||||
</div>
|
||||
|
||||
{/* Type Selector */}
|
||||
<Tabs
|
||||
value={annotation.type}
|
||||
onValueChange={(value) => onTypeChange(value as AnnotationType)}
|
||||
className="mb-6"
|
||||
className="mb-4"
|
||||
>
|
||||
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<TabsList className="mb-4 bg-white/[0.035] border border-white/[0.06] p-0.5 w-full grid grid-cols-3 h-9 rounded-xl">
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<Type className="w-4 h-4" />
|
||||
{t("annotation.typeText")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
{t("annotation.typeImage")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="figure"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
@@ -623,18 +622,6 @@ export function AnnotationSettingsPanel({
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
<li>{t("annotation.tipTabCycle")}</li>
|
||||
<li>{t("annotation.tipShiftTabCycle")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getBlurOverlayColor } from "@/lib/blurEffects";
|
||||
@@ -19,9 +12,7 @@ import {
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
interface BlurSettingsPanelProps {
|
||||
@@ -49,13 +40,15 @@ export function BlurSettingsPanel({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
{t("annotation.active")}
|
||||
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-3">
|
||||
<div className="mb-4">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{t("annotation.blurTypeMosaic")}
|
||||
</span>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-100">
|
||||
{t("annotation.typeBlur")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -69,6 +62,7 @@ export function BlurSettingsPanel({
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: "mosaic",
|
||||
shape: shape.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
@@ -77,7 +71,7 @@ export function BlurSettingsPanel({
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
|
||||
"h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
@@ -99,7 +93,7 @@ export function BlurSettingsPanel({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[10px] leading-none">
|
||||
<span className="text-[10px] leading-none font-medium">
|
||||
{t(`annotation.${shape.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -107,34 +101,6 @@ export function BlurSettingsPanel({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurType")}
|
||||
</label>
|
||||
<Select
|
||||
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
|
||||
onValueChange={(value) => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: value === "mosaic" ? "mosaic" : "blur",
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
|
||||
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurColor")}
|
||||
@@ -150,6 +116,7 @@ export function BlurSettingsPanel({
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: "mosaic",
|
||||
color: option.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
@@ -183,40 +150,29 @@ export function BlurSettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="mt-4 p-3 rounded-lg editor-control-surface">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{blurRegion.blurData?.type === "mosaic"
|
||||
? t("annotation.mosaicBlockSize")
|
||||
: t("annotation.blurIntensity")}
|
||||
{t("annotation.mosaicBlockSize")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
)}
|
||||
{Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)}
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
]}
|
||||
value={[blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
...(blurRegion.blurData?.type === "mosaic"
|
||||
? { blockSize: values[0] }
|
||||
: { intensity: values[0] }),
|
||||
type: "mosaic",
|
||||
blockSize: values[0],
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
|
||||
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
|
||||
min={MIN_BLUR_BLOCK_SIZE}
|
||||
max={MAX_BLUR_BLOCK_SIZE}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
@@ -231,16 +187,6 @@ export function BlurSettingsPanel({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
}: UnsavedChangesDialogProps) {
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-9 h-9 rounded-xl flex-shrink-0"
|
||||
/>
|
||||
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
|
||||
{td("unsavedChanges.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">
|
||||
{td("unsavedChanges.detail")}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{td("unsavedChanges.saveAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{td("unsavedChanges.discardAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||
import {
|
||||
getExportFolder,
|
||||
loadUserPreferences,
|
||||
parentDirectoryOf,
|
||||
saveUserPreferences,
|
||||
} from "@/lib/userPreferences";
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
@@ -67,13 +72,16 @@ import {
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type FigureData,
|
||||
type PlaybackSpeed,
|
||||
type Rotation3DPreset,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomFocusMode,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
|
||||
export default function VideoEditor() {
|
||||
@@ -103,6 +111,7 @@ export default function VideoEditor() {
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
cursorHighlight,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -121,6 +130,7 @@ export default function VideoEditor() {
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
@@ -144,6 +154,7 @@ export default function VideoEditor() {
|
||||
format: string;
|
||||
} | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
@@ -153,6 +164,12 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
|
||||
// renderers while keeping the persisted value intact for round-tripping.
|
||||
const effectiveCursorHighlight = useMemo(
|
||||
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
|
||||
[cursorHighlight, isMac],
|
||||
);
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
@@ -452,6 +469,7 @@ export default function VideoEditor() {
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
cursorHighlight,
|
||||
};
|
||||
const projectData = createProjectData(currentProjectMedia, editorState);
|
||||
|
||||
@@ -513,6 +531,7 @@ export default function VideoEditor() {
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
cursorHighlight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -527,6 +546,28 @@ export default function VideoEditor() {
|
||||
return () => cleanup();
|
||||
}, [saveProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
|
||||
setShowCloseConfirmDialog(true);
|
||||
});
|
||||
return () => cleanup();
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmSave = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("save");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmDiscard = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("discard");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmCancel = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("cancel");
|
||||
}, []);
|
||||
|
||||
const handleSaveProject = useCallback(async () => {
|
||||
await saveProject(false);
|
||||
}, [saveProject]);
|
||||
@@ -587,6 +628,7 @@ export default function VideoEditor() {
|
||||
if (!sourcePath) {
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -595,11 +637,13 @@ export default function VideoEditor() {
|
||||
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
|
||||
if (mounted) {
|
||||
setCursorTelemetry(result.success ? result.samples : []);
|
||||
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
|
||||
}
|
||||
} catch (telemetryError) {
|
||||
console.warn("Unable to load cursor telemetry:", telemetryError);
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,6 +733,7 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: { cx: 0.5, cy: 0.5 },
|
||||
};
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
@@ -708,6 +753,7 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
@@ -791,6 +837,7 @@ export default function VideoEditor() {
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
customScale: ZOOM_DEPTH_SCALES[depth],
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
: region,
|
||||
@@ -800,6 +847,24 @@ export default function VideoEditor() {
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomCustomScaleChange = useCallback(
|
||||
(scale: number) => {
|
||||
if (!selectedZoomId) return;
|
||||
const rounded = Math.round(scale * 100) / 100;
|
||||
if (!Number.isFinite(rounded)) return;
|
||||
updateState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId ? { ...region, customScale: rounded } : region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[selectedZoomId, updateState],
|
||||
);
|
||||
|
||||
const handleZoomCustomScaleCommit = useCallback(() => {
|
||||
commitState();
|
||||
}, [commitState]);
|
||||
|
||||
const handleZoomFocusModeChange = useCallback(
|
||||
(focusMode: ZoomFocusMode) => {
|
||||
if (!selectedZoomId) return;
|
||||
@@ -824,6 +889,23 @@ export default function VideoEditor() {
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomRotationPresetChange = useCallback(
|
||||
(preset: Rotation3DPreset | null) => {
|
||||
if (!selectedZoomId) return;
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) => {
|
||||
if (region.id !== selectedZoomId) return region;
|
||||
if (preset === null) {
|
||||
const { rotationPreset: _p, ...rest } = region;
|
||||
return rest;
|
||||
}
|
||||
return { ...region, rotationPreset: preset };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleTrimDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({
|
||||
@@ -1288,6 +1370,10 @@ export default function VideoEditor() {
|
||||
const handleExportSaved = useCallback(
|
||||
(formatLabel: "GIF" | "Video", filePath: string) => {
|
||||
setExportedFilePath(filePath);
|
||||
const folder = parentDirectoryOf(filePath);
|
||||
if (folder) {
|
||||
saveUserPreferences({ exportFolder: folder });
|
||||
}
|
||||
toast.success(
|
||||
t("export.exportedSuccessfully", {
|
||||
format: formatLabel,
|
||||
@@ -1309,13 +1395,19 @@ export default function VideoEditor() {
|
||||
const handleSaveUnsavedExport = useCallback(async () => {
|
||||
if (!unsavedExport) return;
|
||||
try {
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||
unsavedExport.arrayBuffer,
|
||||
const pickResult = await window.electronAPI.pickExportSavePath(
|
||||
unsavedExport.fileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
if (saveResult.canceled) {
|
||||
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
return;
|
||||
}
|
||||
const saveResult = await window.electronAPI.writeExportToPath(
|
||||
unsavedExport.arrayBuffer,
|
||||
pickResult.path,
|
||||
);
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
|
||||
} else {
|
||||
@@ -1340,6 +1432,21 @@ export default function VideoEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask the user where to save BEFORE starting the export. This avoids the
|
||||
// post-export save dialog getting hidden behind other windows after a
|
||||
// long-running export.
|
||||
const isGifFormat = settings.format === "gif";
|
||||
const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`;
|
||||
const pickResult = await window.electronAPI.pickExportSavePath(
|
||||
targetFileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
|
||||
setShowExportDialog(false);
|
||||
return;
|
||||
}
|
||||
const targetPath = pickResult.path;
|
||||
|
||||
setIsExporting(true);
|
||||
setExportProgress(null);
|
||||
setExportError(null);
|
||||
@@ -1394,6 +1501,8 @@ export default function VideoEditor() {
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1404,8 +1513,6 @@ export default function VideoEditor() {
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.gif`;
|
||||
|
||||
if (result.warnings) {
|
||||
for (const warning of result.warnings) {
|
||||
@@ -1413,15 +1520,13 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved("GIF", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
|
||||
setExportError(saveResult.message || "Failed to save GIF");
|
||||
toast.error(saveResult.message || "Failed to save GIF");
|
||||
}
|
||||
@@ -1437,18 +1542,19 @@ export default function VideoEditor() {
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === "source") {
|
||||
// Use source resolution
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Use the source's longer dimension as the long axis of the export so
|
||||
// a landscape recording can still fill a portrait target (and vice versa).
|
||||
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
// Square (1:1): use smaller dimension to avoid codec limits
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
// Landscape: find largest even dimensions that exactly match aspect ratio
|
||||
const baseWidth = Math.floor(sourceWidth / 2) * 2;
|
||||
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
@@ -1463,8 +1569,7 @@ export default function VideoEditor() {
|
||||
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
// Portrait: find largest even dimensions that exactly match aspect ratio
|
||||
const baseHeight = Math.floor(sourceHeight / 2) * 2;
|
||||
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
@@ -1480,7 +1585,6 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
@@ -1489,14 +1593,18 @@ export default function VideoEditor() {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Use quality-based target resolution
|
||||
const targetHeight = quality === "medium" ? 720 : 1080;
|
||||
// Quality presets target the SHORT side; the long side derives from the
|
||||
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
|
||||
const targetShortDim = quality === "medium" ? 720 : 1080;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
if (aspectRatioValue >= 1) {
|
||||
exportHeight = Math.floor(targetShortDim / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
} else {
|
||||
exportWidth = Math.floor(targetShortDim / 2) * 2;
|
||||
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
|
||||
// Adjust bitrate for lower resolutions
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
@@ -1534,6 +1642,8 @@ export default function VideoEditor() {
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1544,8 +1654,6 @@ export default function VideoEditor() {
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
if (result.warnings) {
|
||||
for (const warning of result.warnings) {
|
||||
@@ -1553,15 +1661,13 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved("Video", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
|
||||
setExportError(saveResult.message || "Failed to save video");
|
||||
toast.error(saveResult.message || "Failed to save video");
|
||||
}
|
||||
@@ -1617,6 +1723,8 @@ export default function VideoEditor() {
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
effectiveCursorHighlight,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1693,6 +1801,19 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveDiagnostic = useCallback(async () => {
|
||||
const result = await window.electronAPI.saveDiagnostic({
|
||||
error: exportError ?? "Manual diagnostic export",
|
||||
projectState: editorState,
|
||||
logs: [],
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Diagnostic file saved");
|
||||
} else if (!result.canceled) {
|
||||
toast.error("Failed to save diagnostic file");
|
||||
}
|
||||
}, [exportError, editorState]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
@@ -1748,7 +1869,7 @@ export default function VideoEditor() {
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
||||
className="h-11 flex-shrink-0 bg-[#070809]/85 backdrop-blur-xl border-b border-white/[0.07] flex items-center justify-between px-5 z-50 shadow-[0_1px_0_rgba(255,255,255,0.03)]"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
@@ -1756,7 +1877,7 @@ export default function VideoEditor() {
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
@@ -1775,7 +1896,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(true)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Video size={14} />
|
||||
{t("newRecording.title")}
|
||||
@@ -1783,7 +1904,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{ts("project.load")}
|
||||
@@ -1791,7 +1912,7 @@ export default function VideoEditor() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveProject}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Save size={14} />
|
||||
{ts("project.save")}
|
||||
@@ -1799,261 +1920,291 @@ export default function VideoEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
||||
{/* Left Column - Video & Timeline */}
|
||||
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
||||
<PanelGroup direction="vertical" className="gap-3">
|
||||
{/* Top section: video preview and controls */}
|
||||
<Panel defaultSize={70} maxSize={70} minSize={40}>
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
/>
|
||||
<div className="editor-workspace flex-1 min-h-0 relative">
|
||||
<PanelGroup direction="vertical" className="gap-3 min-h-0">
|
||||
{/* Top section: preview and contextual settings */}
|
||||
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
|
||||
<div className="editor-main-deck h-full min-h-0">
|
||||
<div className="editor-preview-zone min-w-0 h-full">
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
|
||||
<div className="w-full max-w-[700px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
|
||||
<div className="w-full max-w-[760px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Timeline section */}
|
||||
<Panel defaultSize={30} maxSize={60} minSize={30}>
|
||||
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
<div className="editor-settings-rail min-w-0 h-full">
|
||||
<SettingsPanel
|
||||
cursorHighlight={cursorHighlight}
|
||||
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
|
||||
cursorHighlightSupportsClicks={isMac}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomCustomScale={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomCustomScaleChange={handleZoomCustomScaleChange}
|
||||
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) =>
|
||||
selectedZoomId && handleZoomFocusModeChange(mode)
|
||||
}
|
||||
selectedZoomFocus={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomFocusCoordinateChange={(focus) =>
|
||||
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
|
||||
}
|
||||
onZoomFocusCoordinateCommit={commitState}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomRotationPreset={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomRotationPresetChange={handleZoomRotationPresetChange}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Right section: settings panel */}
|
||||
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
|
||||
<SettingsPanel
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
/>
|
||||
</div>
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="editor-resize-handle group">
|
||||
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Full-width timeline */}
|
||||
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
|
||||
<div className="editor-timeline-panel h-full overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
<ExportDialog
|
||||
@@ -2069,6 +2220,13 @@ export default function VideoEditor() {
|
||||
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
isOpen={showCloseConfirmDialog}
|
||||
onSaveAndClose={handleCloseConfirmSave}
|
||||
onDiscardAndClose={handleCloseConfirmDiscard}
|
||||
onCancel={handleCloseConfirmCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -51,8 +55,18 @@ import {
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import {
|
||||
adaptiveSmoothFactor,
|
||||
interpolateCursorAt,
|
||||
smoothCursorFocus,
|
||||
} from "./videoPlayback/cursorFollowUtils";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
clickEmphasisAlpha,
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
drawCursorHighlightGraphics,
|
||||
} from "./videoPlayback/cursorHighlight";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
@@ -66,6 +80,13 @@ import {
|
||||
type MotionBlurState,
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
|
||||
type BlurPreviewCanvasSource = {
|
||||
clientHeight?: number;
|
||||
clientWidth?: number;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
@@ -110,6 +131,8 @@ interface VideoPlaybackProps {
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -168,6 +191,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
|
||||
cursorClickTimestamps = [],
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -186,11 +211,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composite3DRef = useRef<HTMLDivElement | null>(null);
|
||||
const outerWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
|
||||
const cursorClickTimestampsRef = useRef<number[]>([]);
|
||||
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -215,6 +245,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const maskGraphicsRef = useRef<Graphics | null>(null);
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const isScrubbingRef = useRef(false);
|
||||
const scrubEndTimerRef = useRef<number | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{
|
||||
width: number;
|
||||
@@ -230,10 +263,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
}, []);
|
||||
const blurPreviewSnapshotRef = useRef<{
|
||||
bucket: number;
|
||||
canvas: BlurPreviewCanvasSource | null;
|
||||
height: number;
|
||||
width: number;
|
||||
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||||
@@ -415,7 +450,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -515,6 +550,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorHighlightRef.current = cursorHighlight;
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
|
||||
}
|
||||
}, [cursorHighlight]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickTimestampsRef.current = cursorClickTimestamps;
|
||||
}, [cursorClickTimestamps]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
@@ -583,6 +629,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
|
||||
// navigating, not previewing) and restore native DPR on play/idle so the
|
||||
// preview stays faithful. Mutating renderer.resolution per-frame would
|
||||
// thrash texture uploads; we only do it on scrub-state transitions.
|
||||
useEffect(() => {
|
||||
if (!pixiReady) return;
|
||||
const app = appRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!app || !container) return;
|
||||
|
||||
const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
|
||||
if (app.renderer.resolution === targetResolution) return;
|
||||
|
||||
app.renderer.resolution = targetResolution;
|
||||
app.renderer.resize(container.clientWidth, container.clientHeight);
|
||||
layoutVideoContentRef.current?.();
|
||||
}, [isScrubbing, pixiReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
updateOverlayForRegion(selectedZoom);
|
||||
@@ -738,6 +802,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.mask = maskGraphics;
|
||||
maskGraphicsRef.current = maskGraphics;
|
||||
|
||||
const cursorHighlightGraphics = new Graphics();
|
||||
cursorHighlightGraphics.visible = false;
|
||||
videoContainer.addChild(cursorHighlightGraphics);
|
||||
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
|
||||
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
@@ -770,6 +840,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
isScrubbingRef,
|
||||
scrubEndTimerRef,
|
||||
onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
|
||||
});
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
@@ -797,6 +870,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.removeChild(maskGraphics);
|
||||
maskGraphics.destroy();
|
||||
}
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
|
||||
cursorHighlightGraphicsRef.current.destroy();
|
||||
cursorHighlightGraphicsRef.current = null;
|
||||
}
|
||||
videoContainer.mask = null;
|
||||
maskGraphicsRef.current = null;
|
||||
if (blurFilterRef.current) {
|
||||
@@ -858,8 +936,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
let lastMotionBlurActive: boolean | null = null;
|
||||
let lastTransformIsIdentity = true;
|
||||
let lastPerspectiveValue = 0;
|
||||
const ticker = () => {
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{
|
||||
@@ -879,7 +959,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const regionFocus = region.focus;
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
@@ -1016,7 +1096,41 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionVector,
|
||||
);
|
||||
|
||||
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
|
||||
const cursorGraphics = cursorHighlightGraphicsRef.current;
|
||||
const cursorConfig = cursorHighlightRef.current;
|
||||
const lockedDims = lockedVideoDimensionsRef.current;
|
||||
if (cursorGraphics) {
|
||||
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
|
||||
const emphasisAlpha = clickEmphasisAlpha(
|
||||
currentTimeRef.current,
|
||||
cursorClickTimestampsRef.current,
|
||||
cursorConfig,
|
||||
);
|
||||
const cursorPoint =
|
||||
emphasisAlpha > 0
|
||||
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
|
||||
: null;
|
||||
if (cursorPoint) {
|
||||
const baseScale = baseScaleRef.current;
|
||||
const baseOffset = baseOffsetRef.current;
|
||||
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
|
||||
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
|
||||
cursorGraphics.position.set(
|
||||
baseOffset.x + cx * lockedDims.width * baseScale,
|
||||
baseOffset.y + cy * lockedDims.height * baseScale,
|
||||
);
|
||||
cursorGraphics.alpha = emphasisAlpha;
|
||||
cursorGraphics.visible = true;
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isMotionBlurActive =
|
||||
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
|
||||
|
||||
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
|
||||
if (isMotionBlurActive) {
|
||||
@@ -1032,6 +1146,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
lastMotionBlurActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
const composite3D = composite3DRef.current;
|
||||
const outerWrapper = outerWrapperRef.current;
|
||||
if (composite3D && outerWrapper) {
|
||||
const effectiveRotation =
|
||||
region && targetProgress > 0 && !shouldShowUnzoomedView
|
||||
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
|
||||
: DEFAULT_ROTATION_3D;
|
||||
const isIdentity = isRotation3DIdentity(effectiveRotation);
|
||||
if (isIdentity) {
|
||||
if (!lastTransformIsIdentity) {
|
||||
composite3D.style.transform = "";
|
||||
composite3D.style.willChange = "auto";
|
||||
lastTransformIsIdentity = true;
|
||||
}
|
||||
if (lastPerspectiveValue !== 0) {
|
||||
outerWrapper.style.perspective = "";
|
||||
lastPerspectiveValue = 0;
|
||||
}
|
||||
} else {
|
||||
const wrapperW = outerWrapper.clientWidth || 1;
|
||||
const wrapperH = outerWrapper.clientHeight || 1;
|
||||
const persp = rotation3DPerspective(wrapperW, wrapperH);
|
||||
const containScale = computeRotation3DContainScale(
|
||||
effectiveRotation,
|
||||
wrapperW,
|
||||
wrapperH,
|
||||
persp,
|
||||
);
|
||||
composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
|
||||
composite3D.style.willChange = "transform";
|
||||
lastTransformIsIdentity = false;
|
||||
if (persp !== lastPerspectiveValue) {
|
||||
outerWrapper.style.perspective = `${persp}px`;
|
||||
lastPerspectiveValue = persp;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.ticker.add(ticker);
|
||||
@@ -1153,6 +1305,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
if (scrubEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1169,6 +1325,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerWrapperRef}
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -1193,189 +1350,221 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
ref={composite3DRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
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",
|
||||
transformStyle: "preserve-3d",
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
>
|
||||
<div
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
onPointerLeave={handleOverlayPointerLeave}
|
||||
>
|
||||
<div
|
||||
ref={focusIndicatorRef}
|
||||
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
ref={containerRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
onPointerLeave={handleOverlayPointerLeave}
|
||||
>
|
||||
<div
|
||||
ref={focusIndicatorRef}
|
||||
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (
|
||||
typeof annotation.startMs !== "number" ||
|
||||
typeof annotation.endMs !== "number"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (
|
||||
typeof blurRegion.startMs !== "number" ||
|
||||
typeof blurRegion.endMs !== "number"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotBucket = Math.floor(currentTime * 10);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const cached = blurPreviewSnapshotRef.current;
|
||||
if (
|
||||
cached.bucket === previewSnapshotBucket &&
|
||||
cached.width === overlaySize.width &&
|
||||
cached.height === overlaySize.height
|
||||
) {
|
||||
return cached.canvas;
|
||||
}
|
||||
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return cached.canvas;
|
||||
try {
|
||||
const canvas = app.renderer.extract.canvas(app.stage);
|
||||
blurPreviewSnapshotRef.current = {
|
||||
bucket: previewSnapshotBucket,
|
||||
canvas,
|
||||
height: overlaySize.height,
|
||||
width: overlaySize.width,
|
||||
};
|
||||
return canvas;
|
||||
} catch {
|
||||
return cached.canvas;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={previewSnapshotBucket}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
|
||||
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
|
||||
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
|
||||
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
|
||||
expect(editor.annotationRegions[1].blurData?.type).toBe("mosaic");
|
||||
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
|
||||
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface ProjectEditorState {
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
@@ -99,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
|
||||
): WebcamLayoutPreset {
|
||||
switch (webcamLayoutPreset) {
|
||||
case "picture-in-picture":
|
||||
case "no-webcam":
|
||||
return webcamLayoutPreset;
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
@@ -250,6 +252,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const validPreset =
|
||||
region.rotationPreset === "iso" ||
|
||||
region.rotationPreset === "left" ||
|
||||
region.rotationPreset === "right"
|
||||
? region.rotationPreset
|
||||
: undefined;
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
@@ -260,6 +268,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||
...(validPreset ? { rotationPreset: validPreset } : {}),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -494,6 +503,52 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCursorHighlight(
|
||||
value: unknown,
|
||||
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
|
||||
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
|
||||
return {
|
||||
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
|
||||
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
|
||||
sizePx:
|
||||
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
|
||||
color:
|
||||
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
|
||||
? v.color
|
||||
: fallback.color,
|
||||
opacity:
|
||||
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
|
||||
? v.opacity
|
||||
: fallback.opacity,
|
||||
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
|
||||
clickEmphasisDurationMs:
|
||||
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
|
||||
? v.clickEmphasisDurationMs
|
||||
: fallback.clickEmphasisDurationMs,
|
||||
offsetXNorm:
|
||||
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetXNorm))
|
||||
: fallback.offsetXNorm,
|
||||
offsetYNorm:
|
||||
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetYNorm))
|
||||
: fallback.offsetYNorm,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
isAutoFocus?: boolean;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
@@ -46,6 +47,7 @@ export default function Item({
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
zoomCustomScale,
|
||||
speedValue,
|
||||
isAutoFocus = false,
|
||||
variant = "zoom",
|
||||
@@ -99,7 +101,7 @@ export default function Item({
|
||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
isSelected && glassStyles.selected,
|
||||
)}
|
||||
style={{ height: 40, color: "#fff", minWidth: 24 }}
|
||||
style={{ height: 30, color: "#fff", minWidth: 24 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
@@ -128,13 +130,15 @@ export default function Item({
|
||||
title="Resize right"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
|
||||
<div className="relative z-10 flex min-w-0 flex-col items-center justify-center text-white/90 opacity-85 group-hover:opacity-100 transition-opacity select-none overflow-hidden px-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{zoomCustomScale != null
|
||||
? `${zoomCustomScale.toFixed(2)}×`
|
||||
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
{isAutoFocus && (
|
||||
<MousePointer2
|
||||
@@ -146,21 +150,21 @@ export default function Item({
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{t("labels.trim")}
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
<span className="text-[11px] font-semibold truncate whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
.glassGreen {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(52, 178, 123, 0.15);
|
||||
border: 1px solid rgba(52, 178, 123, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.28), rgba(31, 115, 82, 0.2));
|
||||
border: 1px solid rgba(77, 221, 157, 0.36);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.1) inset,
|
||||
0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
margin: 3px 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassGreen:hover {
|
||||
background: rgba(52, 178, 123, 0.25);
|
||||
border-color: rgba(52, 178, 123, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.36), rgba(31, 115, 82, 0.25));
|
||||
border-color: rgba(77, 221, 157, 0.62);
|
||||
}
|
||||
|
||||
.glassGreen.selected {
|
||||
background: rgba(52, 178, 123, 0.35);
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.48), rgba(31, 115, 82, 0.32));
|
||||
border-color: #34b27b;
|
||||
box-shadow:
|
||||
0 0 0 1px #34b27b,
|
||||
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
|
||||
0 0 0 1px rgba(52, 178, 123, 0.95),
|
||||
0 0 0 4px rgba(52, 178, 123, 0.14),
|
||||
0 12px 26px rgba(0, 0, 0, 0.28);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassRed {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -56,12 +56,12 @@
|
||||
|
||||
.glassYellow {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(180, 160, 70, 0.15);
|
||||
border: 1px solid rgba(180, 160, 70, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -84,12 +84,12 @@
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -137,13 +137,13 @@
|
||||
.zoomEndCap.left {
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-left-radius: 7px;
|
||||
border-bottom-left-radius: 7px;
|
||||
border-top-left-radius: 9px;
|
||||
border-bottom-left-radius: 9px;
|
||||
}
|
||||
|
||||
.zoomEndCap.right {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
border-top-right-radius: 9px;
|
||||
border-bottom-right-radius: 9px;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,21 @@ import { useRow } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
|
||||
export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
|
||||
style={{ ...rowWrapperStyle, minHeight: 36 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
|
||||
style={{ color: labelColor, writingMode: "horizontal-tb" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CursorTelemetryPoint,
|
||||
@@ -102,6 +101,7 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
isAutoFocus?: boolean;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
@@ -377,7 +377,7 @@ function PlaybackCursor({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#6C55FF] shadow-[0_0_18px_rgba(108,85,255,0.68)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_24px_rgba(108,85,255,0.85)] transition-shadow"
|
||||
style={{
|
||||
[sideProperty]: `${offset}px`,
|
||||
}}
|
||||
@@ -388,10 +388,10 @@ function PlaybackCursor({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 hover:scale-110 transition-transform"
|
||||
style={{ width: "20px", height: "20px" }}
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
<div className="w-4 h-4 mx-auto mt-[2px] bg-[#6C55FF] rotate-45 rounded-[5px] shadow-lg shadow-[#6C55FF]/30 border border-white/30" />
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
|
||||
@@ -474,7 +474,7 @@ function TimelineAxis({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-[#09090b] border-b border-white/5 relative overflow-hidden select-none"
|
||||
className="h-9 bg-[#0c0d10] border-b border-white/[0.07] relative overflow-hidden select-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
@@ -485,7 +485,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute bottom-0 h-1 w-[1px] bg-white/5"
|
||||
className="absolute bottom-0 h-1.5 w-[1px] bg-white/[0.07]"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
/>
|
||||
);
|
||||
@@ -507,7 +507,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div className="flex flex-col items-center pb-1">
|
||||
<div className="h-2 w-[1px] bg-white/20 mb-1" />
|
||||
<div className="h-2.5 w-[1px] bg-white/20 mb-1" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium tabular-nums tracking-tight",
|
||||
@@ -658,11 +658,11 @@ function Timeline({
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
||||
onClick={handleTimelineClick}
|
||||
onWheel={handleTimelineWheel}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
@@ -683,6 +683,7 @@ function Timeline({
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomCustomScale={item.zoomCustomScale}
|
||||
isAutoFocus={item.isAutoFocus}
|
||||
variant="zoom"
|
||||
>
|
||||
@@ -1339,6 +1340,7 @@ export default function TimelineEditor({
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||
zoomDepth: region.depth,
|
||||
zoomCustomScale: region.customScale,
|
||||
isAutoFocus: region.focusMode === "auto",
|
||||
variant: "zoom",
|
||||
}));
|
||||
@@ -1445,14 +1447,14 @@ export default function TimelineEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-white/[0.06] bg-[#08090b]/95">
|
||||
<div className="flex items-center gap-0.5 rounded-xl border border-white/[0.06] bg-white/[0.025] p-0.5">
|
||||
<Button
|
||||
onClick={handleAddZoom}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.addZoom")}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
@@ -1461,7 +1463,7 @@ export default function TimelineEditor({
|
||||
onClick={handleSuggestZooms}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.suggestZooms")}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
@@ -1470,7 +1472,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddTrim}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
title={t("buttons.addTrim")}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
@@ -1479,7 +1481,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddAnnotation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title={t("buttons.addAnnotation")}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
@@ -1488,7 +1490,7 @@ export default function TimelineEditor({
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
title={t("buttons.addBlur")}
|
||||
>
|
||||
<svg
|
||||
@@ -1507,19 +1509,19 @@ export default function TimelineEditor({
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title={t("buttons.addSpeed")}
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
|
||||
className="h-7 px-2 rounded-lg text-[11px] text-slate-400 hover:text-slate-200 hover:bg-white/[0.07] transition-all gap-1"
|
||||
>
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@@ -1538,11 +1540,9 @@ export default function TimelineEditor({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-[1px] h-4 bg-white/10" />
|
||||
<TutorialHelp />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<div className="hidden md:flex items-center gap-3 text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||
{scrollLabels.pan}
|
||||
@@ -1559,7 +1559,7 @@ export default function TimelineEditor({
|
||||
</div>
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 overflow-hidden bg-[#09090b] relative"
|
||||
className="flex-1 min-h-0 overflow-auto custom-scrollbar bg-[#09090b] relative"
|
||||
onClick={() => setSelectedKeyframeId(null)}
|
||||
>
|
||||
<TimelineWrapper
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TimelineWrapper({
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
const end = Math.min(start + duration, totalMs);
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
@@ -26,6 +26,37 @@ export interface ZoomFocus {
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
}
|
||||
|
||||
export interface Rotation3D {
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROTATION_3D: Rotation3D = {
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
};
|
||||
|
||||
export type Rotation3DPreset = "iso" | "left" | "right";
|
||||
|
||||
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
|
||||
iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
|
||||
left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
|
||||
right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
|
||||
};
|
||||
|
||||
export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
|
||||
|
||||
/** Perspective distance in CSS px is computed at render-time as this factor times
|
||||
* min(viewport width, viewport height). Same factor used in preview and export so
|
||||
* the visual look is identical regardless of canvas resolution. */
|
||||
export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
|
||||
|
||||
export function rotation3DPerspective(width: number, height: number): number {
|
||||
return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
|
||||
}
|
||||
|
||||
export interface ZoomRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
@@ -33,6 +64,106 @@ export interface ZoomRegion {
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
rotationPreset?: Rotation3DPreset;
|
||||
/** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
|
||||
customScale?: number;
|
||||
}
|
||||
|
||||
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
|
||||
if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
|
||||
return ROTATION_3D_PRESETS[region.rotationPreset];
|
||||
}
|
||||
|
||||
export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
|
||||
return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
|
||||
}
|
||||
|
||||
export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
|
||||
return {
|
||||
rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
|
||||
rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
|
||||
rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
|
||||
* of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
|
||||
* inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
|
||||
*
|
||||
* Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case
|
||||
* |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
|
||||
* recording sit *inside* the zoom window instead of bleeding past it.
|
||||
*/
|
||||
export function computeRotation3DContainScale(
|
||||
rot: Rotation3D,
|
||||
width: number,
|
||||
height: number,
|
||||
perspective: number,
|
||||
): number {
|
||||
const a = (rot.rotationX * Math.PI) / 180;
|
||||
const b = (rot.rotationY * Math.PI) / 180;
|
||||
const g = (rot.rotationZ * Math.PI) / 180;
|
||||
const ca = Math.cos(a);
|
||||
const sa = Math.sin(a);
|
||||
const cb = Math.cos(b);
|
||||
const sb = Math.sin(b);
|
||||
const cg = Math.cos(g);
|
||||
const sg = Math.sin(g);
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const corners: Array<[number, number]> = [
|
||||
[-halfW, -halfH],
|
||||
[halfW, -halfH],
|
||||
[halfW, halfH],
|
||||
[-halfW, halfH],
|
||||
];
|
||||
|
||||
let maxAbsX = 0;
|
||||
let maxAbsY = 0;
|
||||
|
||||
for (const [x0, y0] of corners) {
|
||||
// CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
|
||||
let px = x0;
|
||||
let py = y0;
|
||||
let pz = 0;
|
||||
|
||||
// rotateZ
|
||||
const zx = px * cg - py * sg;
|
||||
const zy = px * sg + py * cg;
|
||||
px = zx;
|
||||
py = zy;
|
||||
|
||||
// rotateY
|
||||
const yx = px * cb + pz * sb;
|
||||
const yz = -px * sb + pz * cb;
|
||||
px = yx;
|
||||
pz = yz;
|
||||
|
||||
// rotateX
|
||||
const xy = py * ca - pz * sa;
|
||||
const xz = py * sa + pz * ca;
|
||||
py = xy;
|
||||
pz = xz;
|
||||
|
||||
// Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz
|
||||
// is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic.
|
||||
if (perspective > 0) {
|
||||
const denom = perspective - pz;
|
||||
if (denom <= 0) return 1; // pathological — skip scaling rather than crash
|
||||
const f = perspective / denom;
|
||||
px *= f;
|
||||
py *= f;
|
||||
}
|
||||
|
||||
if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
|
||||
if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
|
||||
}
|
||||
|
||||
if (maxAbsX === 0 || maxAbsY === 0) return 1;
|
||||
const sx = halfW / maxAbsX;
|
||||
const sy = halfH / maxAbsY;
|
||||
return Math.min(sx, sy, 1);
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
@@ -163,7 +294,7 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
type: "blur",
|
||||
type: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
@@ -227,8 +358,20 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
6: 5.0,
|
||||
};
|
||||
|
||||
export const MIN_ZOOM_SCALE = 1.0;
|
||||
export const MAX_ZOOM_SCALE = 5.0;
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
|
||||
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
|
||||
export function getZoomScale(region: ZoomRegion): number {
|
||||
if (region.customScale != null) {
|
||||
const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
|
||||
if (Number.isFinite(clamped)) return clamped;
|
||||
}
|
||||
return ZOOM_DEPTH_SCALES[region.depth];
|
||||
}
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Graphics } from "pixi.js";
|
||||
|
||||
export type CursorHighlightStyle = "dot" | "ring";
|
||||
|
||||
export interface CursorHighlightConfig {
|
||||
enabled: boolean;
|
||||
style: CursorHighlightStyle;
|
||||
sizePx: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
// Show only on clicks (macOS — depends on click telemetry from uiohook).
|
||||
onlyOnClicks: boolean;
|
||||
clickEmphasisDurationMs: number;
|
||||
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
|
||||
// but window recordings frame a subset of the display so the highlight
|
||||
// lands offset. Users dial these in once to align with the actual cursor.
|
||||
offsetXNorm: number;
|
||||
offsetYNorm: number;
|
||||
}
|
||||
|
||||
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
|
||||
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
|
||||
|
||||
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
|
||||
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
|
||||
|
||||
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
|
||||
// click-only mode; in click-only mode fades 1→0 across each click's window.
|
||||
export function clickEmphasisAlpha(
|
||||
timeMs: number,
|
||||
clickTimestampsMs: number[] | undefined,
|
||||
config: CursorHighlightConfig,
|
||||
): number {
|
||||
if (!config.onlyOnClicks) return 1;
|
||||
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
|
||||
const window = Math.max(1, config.clickEmphasisDurationMs);
|
||||
for (let i = 0; i < clickTimestampsMs.length; i++) {
|
||||
const dt = timeMs - clickTimestampsMs[i];
|
||||
if (dt >= 0 && dt <= window) {
|
||||
return 1 - dt / window;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): number {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length === 3) {
|
||||
const r = cleaned[0];
|
||||
const g = cleaned[1];
|
||||
const b = cleaned[2];
|
||||
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
|
||||
}
|
||||
return Number.parseInt(cleaned.slice(0, 6), 16);
|
||||
}
|
||||
|
||||
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
|
||||
g.clear();
|
||||
if (!config.enabled) return;
|
||||
|
||||
const color = parseHexColor(config.color);
|
||||
const radius = Math.max(1, config.sizePx / 2);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
g.circle(0, 0, radius);
|
||||
g.fill({ color, alpha });
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
g.circle(0, 0, radius);
|
||||
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawCursorHighlightCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
config: CursorHighlightConfig,
|
||||
pixelScale = 1,
|
||||
): void {
|
||||
if (!config.enabled) return;
|
||||
|
||||
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
const color = config.color;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Math.max(2, radius * 0.18);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ interface ViewportRatio {
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||
export function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||
const wr = viewportRatio?.widthRatio ?? 1;
|
||||
const hr = viewportRatio?.heightRatio ?? 1;
|
||||
const marginX = Math.min(0.5, wr / (2 * zoomScale));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToStage } from "./focusUtils";
|
||||
import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
|
||||
interface OverlayUpdateParams {
|
||||
overlayEl: HTMLDivElement;
|
||||
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
});
|
||||
const zoomScale = getZoomScale(region);
|
||||
const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
|
||||
|
||||
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
|
||||
const indicatorWidth = stageWidth / zoomScale;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type React from "react";
|
||||
import type { SpeedRegion, TrimRegion } from "../types";
|
||||
|
||||
// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
|
||||
// fires `seeking`/`seeked` dozens of times per second, and toggling effects
|
||||
// each time would flicker.
|
||||
const SCRUB_END_DEBOUNCE_MS = 150;
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
isScrubbingRef?: React.MutableRefObject<boolean>;
|
||||
scrubEndTimerRef?: React.MutableRefObject<number | null>;
|
||||
onScrubChange?: (scrubbing: boolean) => void;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
isScrubbingRef,
|
||||
scrubEndTimerRef,
|
||||
onScrubChange,
|
||||
} = params;
|
||||
|
||||
const clearScrubEndTimer = () => {
|
||||
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
|
||||
if (isScrubbingRef && scrubEndTimerRef) {
|
||||
clearScrubEndTimer();
|
||||
scrubEndTimerRef.current = window.setTimeout(() => {
|
||||
isScrubbingRef.current = false;
|
||||
scrubEndTimerRef.current = null;
|
||||
onScrubChange?.(false);
|
||||
}, SCRUB_END_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
|
||||
if (isScrubbingRef) {
|
||||
clearScrubEndTimer();
|
||||
if (!isScrubbingRef.current) {
|
||||
isScrubbingRef.current = true;
|
||||
onScrubChange?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
@@ -155,7 +155,7 @@ function getActiveRegion(
|
||||
}
|
||||
|
||||
const activeRegion = activeRegions[0].region;
|
||||
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
|
||||
const activeScale = getZoomScale(activeRegion);
|
||||
|
||||
return {
|
||||
region: {
|
||||
@@ -164,6 +164,7 @@ function getActiveRegion(
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(activeRegion),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +176,7 @@ function getConnectedRegionHold(
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||
const nextScale = getZoomScale(pair.nextRegion);
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
@@ -189,6 +190,7 @@ function getConnectedRegionHold(
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(pair.nextRegion),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -212,8 +214,8 @@ function getConnectedRegionTransition(
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const currentScale = getZoomScale(currentRegion);
|
||||
const nextScale = getZoomScale(nextRegion);
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||
const sharedCursorFocus =
|
||||
@@ -233,6 +235,11 @@ function getConnectedRegionTransition(
|
||||
viewportRatio,
|
||||
);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
const transitionRotation = lerpRotation3D(
|
||||
getRotation3D(currentRegion),
|
||||
getRotation3D(nextRegion),
|
||||
transitionProgress,
|
||||
);
|
||||
|
||||
return {
|
||||
region: {
|
||||
@@ -241,6 +248,7 @@ function getConnectedRegionTransition(
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
rotation3D: transitionRotation,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
@@ -254,34 +262,92 @@ function getConnectedRegionTransition(
|
||||
return null;
|
||||
}
|
||||
|
||||
type DominantRegionResult = {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
rotation3D: Rotation3D;
|
||||
transition: ConnectedPanTransition | null;
|
||||
};
|
||||
|
||||
// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
|
||||
// unchanged inputs (especially while paused). Reusing the previous result when
|
||||
// inputs match avoids the per-frame O(N) region scan + allocations.
|
||||
let dominantRegionCache: {
|
||||
regions: ZoomRegion[];
|
||||
timeMsKey: number;
|
||||
telemetry: CursorTelemetryPoint[] | undefined;
|
||||
connectZooms: boolean;
|
||||
viewportRatio: ViewportRatio | undefined;
|
||||
result: DominantRegionResult;
|
||||
} | null = null;
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
options: DominantRegionOptions = {},
|
||||
): {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
): DominantRegionResult {
|
||||
const connectZooms = !!options.connectZooms;
|
||||
const telemetry = options.cursorTelemetry;
|
||||
const vr = options.viewportRatio;
|
||||
const timeMsKey = Math.round(timeMs);
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
if (
|
||||
dominantRegionCache &&
|
||||
dominantRegionCache.regions === regions &&
|
||||
dominantRegionCache.timeMsKey === timeMsKey &&
|
||||
dominantRegionCache.telemetry === telemetry &&
|
||||
dominantRegionCache.connectZooms === connectZooms &&
|
||||
dominantRegionCache.viewportRatio === vr
|
||||
) {
|
||||
return dominantRegionCache.result;
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
|
||||
let result: DominantRegionResult;
|
||||
if (connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
result = connectedTransition;
|
||||
} else {
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
result = { ...connectedHold, transition: null };
|
||||
} else {
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
result = activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: {
|
||||
region: null,
|
||||
strength: 0,
|
||||
blendedScale: null,
|
||||
rotation3D: DEFAULT_ROTATION_3D,
|
||||
transition: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
result = activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: {
|
||||
region: null,
|
||||
strength: 0,
|
||||
blendedScale: null,
|
||||
rotation3D: DEFAULT_ROTATION_3D,
|
||||
transition: null,
|
||||
};
|
||||
}
|
||||
|
||||
dominantRegionCache = {
|
||||
regions,
|
||||
timeMsKey,
|
||||
telemetry,
|
||||
connectZooms,
|
||||
viewportRatio: vr,
|
||||
result,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user