Merge pull request #518 from makaradam/feature/custom-zoom-slider-clean

feat: add custom zoom slider with continuous scale control (#513)
This commit is contained in:
Sid
2026-05-09 09:14:37 -07:00
committed by GitHub
10 changed files with 145 additions and 37 deletions
+76 -2
View File
@@ -1,3 +1,4 @@
import * as SliderPrimitive from "@radix-ui/react-slider";
import {
Bug,
ChevronDown,
@@ -65,8 +66,11 @@ import type {
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER,
SPEED_OPTIONS,
ZOOM_DEPTH_SCALES,
} from "./types";
function CustomSpeedInput({
@@ -170,6 +174,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomCustomScale?: number | null;
onZoomCustomScaleChange?: (scale: number) => void;
onZoomCustomScaleCommit?: () => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
@@ -263,6 +270,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomCustomScale,
onZoomCustomScaleChange,
onZoomCustomScaleCommit,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
@@ -593,7 +603,9 @@ export function SettingsPanel({
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
{ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
{selectedZoomCustomScale != null
? `${selectedZoomCustomScale.toFixed(2)}×`
: ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
</span>
)}
<KeyboardShortcutsHelp />
@@ -601,7 +613,10 @@ export function SettingsPanel({
</div>
<div className="grid grid-cols-6 gap-1.5">
{ZOOM_DEPTH_OPTIONS.map((option) => {
const isActive = selectedZoomDepth === option.depth;
const effectiveScale =
selectedZoomCustomScale ??
(selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null);
const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth];
return (
<Button
key={option.depth}
@@ -622,6 +637,65 @@ export function SettingsPanel({
);
})}
</div>
{zoomEnabled && (
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400">{t("zoom.customScale")}</span>
<span
className={cn(
"text-xs font-mono font-semibold tabular-nums",
selectedZoomCustomScale != null ? "text-[#34B27B]" : "text-slate-400",
)}
>
{(
selectedZoomCustomScale ??
(selectedZoomDepth != null
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
: MIN_ZOOM_SCALE)
).toFixed(2)}
×
</span>
</div>
<SliderPrimitive.Root
min={MIN_ZOOM_SCALE}
max={MAX_ZOOM_SCALE}
step={0.01}
value={[
selectedZoomCustomScale ??
(selectedZoomDepth != null
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
: MIN_ZOOM_SCALE),
]}
onValueChange={(values) => onZoomCustomScaleChange?.(values[0])}
onValueCommit={() => onZoomCustomScaleCommit?.()}
disabled={!zoomEnabled}
className="relative flex w-full touch-none select-none items-center py-1"
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full border border-white/10 bg-white/5">
<SliderPrimitive.Range
className={cn(
"absolute h-full transition-colors duration-150",
selectedZoomCustomScale != null ? "bg-[#34B27B]" : "bg-white/20",
)}
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className={cn(
"block h-3.5 w-3.5 rounded-full border-2 shadow transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50",
"disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing",
selectedZoomCustomScale != null
? "border-[#34B27B] bg-[#34B27B] shadow-[0_0_6px_rgba(52,178,123,0.4)]"
: "border-white/20 bg-[#2a2a30] hover:border-white/40",
)}
/>
</SliderPrimitive.Root>
<div className="flex justify-between text-[10px] text-slate-600 mt-0.5">
<span>{MIN_ZOOM_SCALE.toFixed(1)}×</span>
<span>{MAX_ZOOM_SCALE.toFixed(1)}×</span>
</div>
</div>
)}
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
@@ -75,6 +75,7 @@ import {
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
@@ -732,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] }));
@@ -751,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] }));
@@ -834,6 +837,7 @@ export default function VideoEditor() {
? {
...region,
depth,
customScale: ZOOM_DEPTH_SCALES[depth],
focus: clampFocusToDepth(region.focus, depth),
}
: region,
@@ -843,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;
@@ -2060,6 +2082,13 @@ export default function VideoEditor() {
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")
@@ -38,13 +38,12 @@ import {
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";
@@ -67,7 +66,7 @@ import {
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -258,10 +257,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
}, []);
const updateOverlayForRegion = useCallback(
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
const overlayEl = overlayRef.current;
@@ -442,7 +437,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);
@@ -951,7 +946,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;
@@ -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",
@@ -134,7 +136,9 @@ export default function Item({
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
{zoomCustomScale != null
? `${zoomCustomScale.toFixed(2)}×`
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
@@ -102,6 +102,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -683,6 +684,7 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomCustomScale={item.zoomCustomScale}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
@@ -1339,6 +1341,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",
}));
+14
View File
@@ -65,6 +65,8 @@ export interface ZoomRegion {
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
/** Custom scale overriding the preset depth (1.05.0, two decimal precision). */
customScale?: number;
}
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
@@ -356,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),
@@ -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,5 +1,5 @@
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } 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: {
@@ -176,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,
@@ -214,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 =
+1
View File
@@ -1,6 +1,7 @@
{
"zoom": {
"level": "Zoom Level",
"customScale": "Custom Zoom",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
+4 -13
View File
@@ -15,14 +15,13 @@ import type {
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import {
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
ZOOM_DEPTH_SCALES,
} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
@@ -42,7 +41,7 @@ import {
clickEmphasisAlpha,
drawCursorHighlightCanvas,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -645,14 +644,6 @@ export class FrameRenderer {
};
}
private clampFocusToStage(
focus: { cx: number; cy: number },
depth: ZoomDepth,
): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
}
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
@@ -673,8 +664,8 @@ export class FrameRenderer {
: { ...DEFAULT_ROTATION_3D };
if (region && strength > 0) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = clampFocusToScale(region.focus, zoomScale);
targetScaleFactor = zoomScale;
targetFocus = regionFocus;