ui revamp

This commit is contained in:
Siddharth
2026-05-09 19:18:16 -07:00
parent 7bbb855e8e
commit e3d4a330df
22 changed files with 1878 additions and 1629 deletions
+1
View File
@@ -157,6 +157,7 @@ interface Window {
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
+3
View File
@@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
+7
View File
@@ -24,6 +24,12 @@ ipcMain.on("hud-overlay-hide", () => {
}
});
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
}
});
/**
* Creates the always-on-top HUD overlay window centred at the bottom of the
* primary display. The window is frameless, transparent, and follows the user
@@ -63,6 +69,7 @@ export function createHudOverlayWindow(): BrowserWindow {
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true, { forward: true });
// Follow the user across macOS Spaces (virtual desktops).
// Without this the HUD stays pinned to the Space it was first opened on.
+12 -12
View File
@@ -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;
}
+34 -15
View File
@@ -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);
@@ -320,6 +328,12 @@ export function LaunchWindow() {
// 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
@@ -360,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)}
@@ -409,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)}
@@ -485,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}`}>
@@ -494,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 */}
@@ -548,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}
@@ -624,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>
+24 -22
View File
@@ -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 */
+15 -15
View File
@@ -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
+277 -275
View File
@@ -1869,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
@@ -1877,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
@@ -1896,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")}
@@ -1904,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")}
@@ -1912,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")}
@@ -1920,289 +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}
cursorHighlight={effectiveCursorHighlight}
cursorClickTimestamps={cursorClickTimestamps}
/>
<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
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}
onZoomDelete={handleZoomDelete}
selectedZoomRotationPreset={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
: null
}
onZoomRotationPresetChange={handleZoomRotationPresetChange}
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}
onSaveDiagnostic={handleSaveDiagnostic}
/>
</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
+34 -4
View File
@@ -80,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;
@@ -256,6 +263,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
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) => {
@@ -1448,15 +1461,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 null;
if (!app?.renderer?.extract) return cached.canvas;
try {
return app.renderer.extract.canvas(app.stage);
const canvas = app.renderer.extract.canvas(app.stage);
blurPreviewSnapshotRef.current = {
bucket: previewSnapshotBucket,
canvas,
height: overlaySize.height,
width: overlaySize.width,
};
return canvas;
} catch {
return null;
return cached.canvas;
}
})()
: null;
@@ -1528,7 +1558,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
previewFrameVersion={previewSnapshotBucket}
/>
));
})()}
@@ -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);
});
@@ -101,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?.();
@@ -130,12 +130,12 @@ 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">
<span className="text-[11px] font-semibold whitespace-nowrap">
{zoomCustomScale != null
? `${zoomCustomScale.toFixed(2)}×`
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
@@ -150,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;
}
+4 -14
View File
@@ -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,
@@ -378,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`,
}}
@@ -389,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">
@@ -475,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`,
}}
@@ -486,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` }}
/>
);
@@ -508,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",
@@ -659,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}
@@ -1448,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" />
@@ -1464,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" />
@@ -1473,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" />
@@ -1482,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" />
@@ -1491,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
@@ -1510,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" />
@@ -1541,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}
@@ -1562,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
+1 -1
View File
@@ -294,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,
+164 -4
View File
@@ -66,11 +66,154 @@
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
overflow: hidden;
text-rendering: geometricPrecision;
}
}
/* Smooth timeline cursor animations */
@layer utilities {
.editor-workspace {
display: flex;
flex-direction: column;
padding: 14px;
background:
radial-gradient(circle at 18% 0%, rgba(52, 178, 123, 0.08), transparent 30%),
linear-gradient(180deg, #08090b 0%, #050606 100%);
}
.editor-main-deck {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(286px, 20vw, 352px);
gap: 14px;
min-height: 0;
}
.editor-preview-zone,
.editor-settings-rail {
min-width: 0;
min-height: 0;
}
.editor-preview-panel,
.editor-timeline-panel,
.editor-inspector-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#090a0c;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 18px;
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.045);
}
.editor-timeline-panel {
border-radius: 16px;
background:
linear-gradient(180deg, rgba(18, 20, 24, 0.96), rgba(8, 9, 11, 0.98)),
#090a0c;
}
.editor-resize-handle {
height: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
transition:
background-color 160ms ease,
opacity 160ms ease;
opacity: 0.8;
}
.editor-resize-handle:hover {
background: rgba(255, 255, 255, 0.035);
opacity: 1;
}
.editor-panel-section {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.editor-control-surface {
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 10px;
background: rgba(255, 255, 255, 0.032);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.editor-inspector-shell {
color: #d9e2ee;
}
.editor-inspector-shell button,
.editor-inspector-shell [role="button"] {
letter-spacing: 0;
}
.editor-inspector-shell button:focus-visible,
.editor-inspector-shell [role="button"]:focus-visible {
outline: 1px solid rgba(52, 178, 123, 0.68);
outline-offset: 2px;
}
.editor-inspector-shell [data-radix-collection-item],
.editor-inspector-shell input,
.editor-inspector-shell textarea {
letter-spacing: 0;
}
.editor-inspector-shell [role="slider"] {
box-shadow: 0 0 0 4px rgba(52, 178, 123, 0.12);
}
.editor-inspector-shell .editor-panel-section > h3 {
display: none;
}
.editor-inspector-shell .editor-panel-section > div {
overflow: visible;
}
@media (max-width: 1240px) {
.editor-workspace {
gap: 10px;
padding: 10px;
}
.editor-main-deck {
grid-template-columns: minmax(0, 1fr) 286px;
gap: 10px;
}
}
@media (max-width: 1020px) {
.editor-main-deck {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) 180px;
}
.editor-settings-rail {
min-height: 180px;
}
}
@media (min-width: 1900px) {
.editor-workspace {
padding: 18px;
gap: 18px;
}
.editor-main-deck {
grid-template-columns: minmax(0, 1fr) 372px;
gap: 18px;
}
}
.timeline-cursor-smooth {
will-change: transform;
transition:
@@ -78,14 +221,31 @@
right 33ms linear;
}
/* Hidden scrollbar - still scrollable but invisible */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
width: 10px;
height: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.55);
border: 3px solid transparent;
background-clip: content-box;
}
.squircle {
+2 -2
View File
@@ -69,12 +69,12 @@ describe("blur color helpers", () => {
it("returns a dark overlay when black blur color is selected", () => {
expect(
getBlurOverlayColor({
type: "blur",
type: "mosaic",
shape: "rectangle",
color: "black",
intensity: 12,
blockSize: 12,
}),
).toBe("rgba(0, 0, 0, 0.56)");
).toBe("rgba(0, 0, 0, 0.72)");
});
});
+3 -7
View File
@@ -16,7 +16,8 @@ function clamp(value: number, min: number, max: number) {
}
export function normalizeBlurType(value: unknown): BlurType {
return value === "mosaic" ? "mosaic" : "blur";
void value;
return "mosaic";
}
export function normalizeBlurColor(value: unknown): BlurColor {
@@ -42,13 +43,8 @@ export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFa
export function getBlurOverlayColor(blurData?: BlurData | null): string {
const blurColor = normalizeBlurColor(blurData?.color);
const blurType = normalizeBlurType(blurData?.type);
if (blurColor === "black") {
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
}
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
return blurColor === "black" ? "rgba(0, 0, 0, 0.72)" : "rgba(255, 255, 255, 0.06)";
}
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {