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:
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface ZoomRegion {
|
||||
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 {
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Zoom Level",
|
||||
"customScale": "Custom Zoom",
|
||||
"selectRegion": "Select a zoom region to adjust",
|
||||
"deleteZoom": "Delete Zoom",
|
||||
"focusMode": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user