Merge pull request #373 from Moncef-Mhz/adjust-zoom-speed

feat: implement zoom speed
This commit is contained in:
Sid
2026-04-11 20:23:10 -07:00
committed by GitHub
9 changed files with 261 additions and 14 deletions
+47 -1
View File
@@ -225,6 +225,9 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
selectedZoomInDuration?: number;
selectedZoomOutDuration?: number;
onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
@@ -241,6 +244,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
const ZOOM_SPEED_OPTIONS = [
{ label: "Instant", zoomIn: 0, zoomOut: 0 },
{ label: "Fast", zoomIn: 500, zoomOut: 350 },
{ label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
{ label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
];
export function SettingsPanel({
selected,
onWallpaperChange,
@@ -306,6 +316,9 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
selectedZoomInDuration,
selectedZoomOutDuration,
onZoomDurationChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
@@ -648,6 +661,39 @@ export function SettingsPanel({
)}
</div>
)}
{zoomEnabled && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.speed.title") || "Zoom Speed"}
</span>
<div className="grid grid-cols-4 gap-1.5">
{ZOOM_SPEED_OPTIONS.map((opt) => {
const isActive =
selectedZoomInDuration !== undefined &&
selectedZoomOutDuration !== undefined &&
Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
return (
<Button
key={opt.label}
type="button"
onClick={() => onZoomDurationChange?.(opt.zoomIn, opt.zoomOut)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out cursor-pointer",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
)}
>
<span className="text-[10px] font-semibold">{opt.label}</span>
</Button>
);
})}
</div>
</div>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
@@ -1026,7 +1072,7 @@ export function SettingsPanel({
</AccordionTrigger>
<AccordionContent className="pb-3">
<Tabs defaultValue="image" className="w-full">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 rounded-lg">
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
@@ -74,6 +74,7 @@ import {
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants";
export default function VideoEditor() {
const {
@@ -955,6 +956,19 @@ export default function VideoEditor() {
[pushState],
);
const handleZoomDurationChange = useCallback(
(id: string, zoomIn: number, zoomOut: number) => {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut }
: region,
),
}));
},
[pushState],
);
const handleAnnotationSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
@@ -1852,6 +1866,7 @@ export default function VideoEditor() {
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDurationChange={handleZoomDurationChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
@@ -1993,6 +2008,21 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
selectedZoomInDuration={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ??
Math.round(ZOOM_IN_TRANSITION_WINDOW_MS))
: undefined
}
selectedZoomOutDuration={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ??
Math.round(TRANSITION_WINDOW_MS))
: undefined
}
onZoomDurationChange={(zoomIn, zoomOut) =>
selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut)
}
/>
</div>
</div>
+115 -1
View File
@@ -1,8 +1,13 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useItem, useTimelineContext } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ZOOM_IN_MS,
DEFAULT_ZOOM_OUT_MS,
getDurations,
} from "../videoPlayback/zoomRegionUtils";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -13,7 +18,10 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
speedValue?: number;
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
@@ -44,10 +52,14 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
zoomInDurationMs,
zoomOutDurationMs,
speedValue,
variant = "zoom",
children,
onZoomDurationChange,
}: ItemProps) {
const { pixelsToValue } = useTimelineContext();
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -79,6 +91,16 @@ export default function Item({
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
const { zoomIn, zoomOut } = useMemo(() => {
if (!isZoom) return { zoomIn: 0, zoomOut: 0 };
return getDurations({
startMs: span.start,
endMs: span.end,
zoomInDurationMs,
zoomOutDurationMs,
});
}, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]);
return (
<div
ref={setNodeRef}
@@ -101,6 +123,98 @@ export default function Item({
onSelect?.();
}}
>
{isZoom && (
<>
{/* Transition In Marker */}
<div
className="absolute top-0 bottom-0 left-0 bg-white/10 border-r border-white/20 pointer-events-none"
style={{
width: `${(zoomIn / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition In */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
left: `${(zoomIn / (span.end - span.start)) * 100}%`,
transform: "translateX(-50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const startX = e.clientX;
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = moveEvent.clientX - startX;
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut),
);
onZoomDurationChange?.(id, newDuration, initialZoomOut);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
{/* Transition Out Marker */}
<div
className="absolute top-0 bottom-0 right-0 bg-white/10 border-l border-white/20 pointer-events-none"
style={{
width: `${(zoomOut / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition Out */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
right: `${(zoomOut / (span.end - span.start)) * 100}%`,
transform: "translateX(50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const startX = e.clientX;
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn),
);
onZoomDurationChange?.(id, initialZoomIn, newDuration);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
</>
)}
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{
@@ -59,6 +59,7 @@ interface TimelineEditorProps {
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
@@ -103,6 +104,8 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
@@ -539,6 +542,7 @@ function Timeline({
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
onZoomDurationChange,
keyframes = [],
}: {
items: TimelineRenderItem[];
@@ -556,6 +560,7 @@ function Timeline({
selectedAnnotationId?: string | null;
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
@@ -682,6 +687,9 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomInDurationMs={item.zoomInDurationMs}
zoomOutDurationMs={item.zoomOutDurationMs}
onZoomDurationChange={onZoomDurationChange}
variant="zoom"
>
{item.label}
@@ -770,6 +778,7 @@ export default function TimelineEditor({
onZoomAdded,
onZoomSuggested,
onZoomSpanChange,
onZoomDurationChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
@@ -1338,6 +1347,8 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
zoomInDurationMs: region.zoomInDurationMs,
zoomOutDurationMs: region.zoomOutDurationMs,
variant: "zoom",
}));
@@ -1594,6 +1605,7 @@ export default function TimelineEditor({
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
onZoomDurationChange={onZoomDurationChange}
keyframes={keyframes}
/>
</TimelineWrapper>
+2
View File
@@ -33,6 +33,8 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
}
export interface CursorTelemetryPoint {
@@ -7,7 +7,6 @@ import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
@@ -38,26 +37,49 @@ function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
export const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS;
export const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
export function getDurations(region: {
startMs: number;
endMs: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
}) {
let zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
let zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
const duration = region.endMs - region.startMs;
if (zoomIn + zoomOut > duration) {
const scale = duration / (zoomIn + zoomOut);
zoomIn *= scale;
zoomOut *= scale;
}
return { zoomIn, zoomOut };
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const { zoomIn, zoomOut } = getDurations(region);
if (timeMs < region.startMs || timeMs > region.endMs) {
return 0;
}
if (timeMs < zoomInEnd) {
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
// Zooming in
if (timeMs < region.startMs + zoomIn) {
const progress = Math.max(0, Math.min(1, (timeMs - region.startMs) / zoomIn));
return easeOutScreenStudio(progress);
}
if (timeMs <= region.endMs) {
return 1;
// Zooming out
if (timeMs > region.endMs - zoomOut) {
const progress = Math.max(0, Math.min(1, (region.endMs - timeMs) / zoomOut));
return easeOutScreenStudio(progress);
}
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
return 1 - easeOutScreenStudio(progress);
// Full zoom
return 1;
}
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
+7
View File
@@ -8,6 +8,13 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
},
"speed": {
"title": "Zoom Speed",
"instant": "Instant",
"fast": "Fast",
"smooth": "Smooth",
"lazy": "Lazy"
}
},
"speed": {
+7
View File
@@ -8,6 +8,13 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
},
"speed": {
"title": "Velocidad de zoom",
"instant": "Instantáneo",
"fast": "Rápido",
"smooth": "Suave",
"lazy": "Lento"
}
},
"speed": {
+7
View File
@@ -8,6 +8,13 @@
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
},
"speed": {
"title": "缩放速度",
"instant": "即时",
"fast": "快速",
"smooth": "平滑",
"lazy": "缓慢"
}
},
"speed": {