Skip to content

Commit a6ae0e6

Browse files
Merge pull request #373 from Moncef-Mhz/adjust-zoom-speed
feat: implement zoom speed
2 parents db10f92 + e8d6fe3 commit a6ae0e6

9 files changed

Lines changed: 260 additions & 13 deletions

File tree

src/components/video-editor/SettingsPanel.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ interface SettingsPanelProps {
225225
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
226226
webcamMaskShape?: import("./types").WebcamMaskShape;
227227
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
228+
selectedZoomInDuration?: number;
229+
selectedZoomOutDuration?: number;
230+
onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
228231
webcamSizePreset?: WebcamSizePreset;
229232
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
230233
onWebcamSizePresetCommit?: () => void;
@@ -241,6 +244,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
241244
{ depth: 6, label: "5×" },
242245
];
243246

247+
const ZOOM_SPEED_OPTIONS = [
248+
{ label: "Instant", zoomIn: 0, zoomOut: 0 },
249+
{ label: "Fast", zoomIn: 500, zoomOut: 350 },
250+
{ label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
251+
{ label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
252+
];
253+
244254
export function SettingsPanel({
245255
selected,
246256
onWallpaperChange,
@@ -306,6 +316,9 @@ export function SettingsPanel({
306316
onWebcamLayoutPresetChange,
307317
webcamMaskShape = "rectangle",
308318
onWebcamMaskShapeChange,
319+
selectedZoomInDuration,
320+
selectedZoomOutDuration,
321+
onZoomDurationChange,
309322
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
310323
onWebcamSizePresetChange,
311324
onWebcamSizePresetCommit,
@@ -648,6 +661,39 @@ export function SettingsPanel({
648661
)}
649662
</div>
650663
)}
664+
665+
{zoomEnabled && (
666+
<div className="mt-3">
667+
<span className="text-sm font-medium text-slate-200 mb-2 block">
668+
{t("zoom.speed.title") || "Zoom Speed"}
669+
</span>
670+
<div className="grid grid-cols-4 gap-1.5">
671+
{ZOOM_SPEED_OPTIONS.map((opt) => {
672+
const isActive =
673+
selectedZoomInDuration !== undefined &&
674+
selectedZoomOutDuration !== undefined &&
675+
Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
676+
Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
677+
return (
678+
<Button
679+
key={opt.label}
680+
type="button"
681+
onClick={() => onZoomDurationChange?.(opt.zoomIn, opt.zoomOut)}
682+
className={cn(
683+
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
684+
"duration-200 ease-out cursor-pointer",
685+
isActive
686+
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
687+
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
688+
)}
689+
>
690+
<span className="text-[10px] font-semibold">{opt.label}</span>
691+
</Button>
692+
);
693+
})}
694+
</div>
695+
</div>
696+
)}
651697
{zoomEnabled && (
652698
<Button
653699
onClick={handleDeleteClick}
@@ -1026,7 +1072,7 @@ export function SettingsPanel({
10261072
</AccordionTrigger>
10271073
<AccordionContent className="pb-3">
10281074
<Tabs defaultValue="image" className="w-full">
1029-
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
1075+
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 rounded-lg">
10301076
<TabsTrigger
10311077
value="image"
10321078
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"

src/components/video-editor/VideoEditor.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
type ZoomRegion,
7575
} from "./types";
7676
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
77+
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants";
7778

7879
export default function VideoEditor() {
7980
const {
@@ -955,6 +956,19 @@ export default function VideoEditor() {
955956
[pushState],
956957
);
957958

959+
const handleZoomDurationChange = useCallback(
960+
(id: string, zoomIn: number, zoomOut: number) => {
961+
pushState((prev) => ({
962+
zoomRegions: prev.zoomRegions.map((region) =>
963+
region.id === id
964+
? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut }
965+
: region,
966+
),
967+
}));
968+
},
969+
[pushState],
970+
);
971+
958972
const handleAnnotationSpanChange = useCallback(
959973
(id: string, span: Span) => {
960974
pushState((prev) => ({
@@ -1852,6 +1866,7 @@ export default function VideoEditor() {
18521866
onZoomAdded={handleZoomAdded}
18531867
onZoomSuggested={handleZoomSuggested}
18541868
onZoomSpanChange={handleZoomSpanChange}
1869+
onZoomDurationChange={handleZoomDurationChange}
18551870
onZoomDelete={handleZoomDelete}
18561871
selectedZoomId={selectedZoomId}
18571872
onSelectZoom={handleSelectZoom}
@@ -1993,6 +2008,21 @@ export default function VideoEditor() {
19932008
onSpeedDelete={handleSpeedDelete}
19942009
unsavedExport={unsavedExport}
19952010
onSaveUnsavedExport={handleSaveUnsavedExport}
2011+
selectedZoomInDuration={
2012+
selectedZoomId
2013+
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ??
2014+
Math.round(ZOOM_IN_TRANSITION_WINDOW_MS))
2015+
: undefined
2016+
}
2017+
selectedZoomOutDuration={
2018+
selectedZoomId
2019+
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ??
2020+
Math.round(TRANSITION_WINDOW_MS))
2021+
: undefined
2022+
}
2023+
onZoomDurationChange={(zoomIn, zoomOut) =>
2024+
selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut)
2025+
}
19962026
/>
19972027
</div>
19982028
</div>

src/components/video-editor/timeline/Item.tsx

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import type { Span } from "dnd-timeline";
2-
import { useItem } from "dnd-timeline";
2+
import { useItem, useTimelineContext } from "dnd-timeline";
33
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
44
import { useMemo } from "react";
55
import { cn } from "@/lib/utils";
6+
import {
7+
DEFAULT_ZOOM_IN_MS,
8+
DEFAULT_ZOOM_OUT_MS,
9+
getDurations,
10+
} from "../videoPlayback/zoomRegionUtils";
611
import glassStyles from "./ItemGlass.module.css";
712

813
interface ItemProps {
@@ -13,7 +18,10 @@ interface ItemProps {
1318
isSelected?: boolean;
1419
onSelect?: () => void;
1520
zoomDepth?: number;
21+
zoomInDurationMs?: number;
22+
zoomOutDurationMs?: number;
1623
speedValue?: number;
24+
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
1725
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
1826
}
1927

@@ -44,10 +52,14 @@ export default function Item({
4452
isSelected = false,
4553
onSelect,
4654
zoomDepth = 1,
55+
zoomInDurationMs,
56+
zoomOutDurationMs,
4757
speedValue,
4858
variant = "zoom",
4959
children,
60+
onZoomDurationChange,
5061
}: ItemProps) {
62+
const { pixelsToValue } = useTimelineContext();
5163
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
5264
id,
5365
span,
@@ -79,6 +91,16 @@ export default function Item({
7991
const MIN_ITEM_PX = 6;
8092
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
8193

94+
const { zoomIn, zoomOut } = useMemo(() => {
95+
if (!isZoom) return { zoomIn: 0, zoomOut: 0 };
96+
return getDurations({
97+
startMs: span.start,
98+
endMs: span.end,
99+
zoomInDurationMs,
100+
zoomOutDurationMs,
101+
});
102+
}, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]);
103+
82104
return (
83105
<div
84106
ref={setNodeRef}
@@ -101,6 +123,98 @@ export default function Item({
101123
onSelect?.();
102124
}}
103125
>
126+
{isZoom && (
127+
<>
128+
{/* Transition In Marker */}
129+
<div
130+
className="absolute top-0 bottom-0 left-0 bg-white/10 border-r border-white/20 pointer-events-none"
131+
style={{
132+
width: `${(zoomIn / (span.end - span.start)) * 100}%`,
133+
}}
134+
/>
135+
{/* Draggable handle for Transition In */}
136+
<div
137+
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
138+
style={{
139+
left: `${(zoomIn / (span.end - span.start)) * 100}%`,
140+
transform: "translateX(-50%)",
141+
}}
142+
onPointerDown={(e) => {
143+
e.stopPropagation();
144+
e.preventDefault();
145+
const target = e.currentTarget;
146+
target.setPointerCapture(e.pointerId);
147+
148+
const startX = e.clientX;
149+
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
150+
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
151+
152+
const onPointerMove = (moveEvent: PointerEvent) => {
153+
const deltaPx = moveEvent.clientX - startX;
154+
const deltaMs = pixelsToValue(deltaPx);
155+
const newDuration = Math.max(
156+
0,
157+
Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut),
158+
);
159+
onZoomDurationChange?.(id, newDuration, initialZoomOut);
160+
};
161+
162+
const onPointerUp = () => {
163+
target.releasePointerCapture(e.pointerId);
164+
window.removeEventListener("pointermove", onPointerMove);
165+
window.removeEventListener("pointerup", onPointerUp);
166+
};
167+
168+
window.addEventListener("pointermove", onPointerMove);
169+
window.addEventListener("pointerup", onPointerUp);
170+
}}
171+
/>
172+
{/* Transition Out Marker */}
173+
<div
174+
className="absolute top-0 bottom-0 right-0 bg-white/10 border-l border-white/20 pointer-events-none"
175+
style={{
176+
width: `${(zoomOut / (span.end - span.start)) * 100}%`,
177+
}}
178+
/>
179+
{/* Draggable handle for Transition Out */}
180+
<div
181+
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
182+
style={{
183+
right: `${(zoomOut / (span.end - span.start)) * 100}%`,
184+
transform: "translateX(50%)",
185+
}}
186+
onPointerDown={(e) => {
187+
e.stopPropagation();
188+
e.preventDefault();
189+
const target = e.currentTarget;
190+
target.setPointerCapture(e.pointerId);
191+
192+
const startX = e.clientX;
193+
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
194+
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
195+
196+
const onPointerMove = (moveEvent: PointerEvent) => {
197+
const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored
198+
const deltaMs = pixelsToValue(deltaPx);
199+
const newDuration = Math.max(
200+
0,
201+
Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn),
202+
);
203+
onZoomDurationChange?.(id, initialZoomIn, newDuration);
204+
};
205+
206+
const onPointerUp = () => {
207+
target.releasePointerCapture(e.pointerId);
208+
window.removeEventListener("pointermove", onPointerMove);
209+
window.removeEventListener("pointerup", onPointerUp);
210+
};
211+
212+
window.addEventListener("pointermove", onPointerMove);
213+
window.addEventListener("pointerup", onPointerUp);
214+
}}
215+
/>
216+
</>
217+
)}
104218
<div
105219
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
106220
style={{

src/components/video-editor/timeline/TimelineEditor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface TimelineEditorProps {
5959
onZoomAdded: (span: Span) => void;
6060
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
6161
onZoomSpanChange: (id: string, span: Span) => void;
62+
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
6263
onZoomDelete: (id: string) => void;
6364
selectedZoomId: string | null;
6465
onSelectZoom: (id: string | null) => void;
@@ -103,6 +104,8 @@ interface TimelineRenderItem {
103104
label: string;
104105
zoomDepth?: number;
105106
speedValue?: number;
107+
zoomInDurationMs?: number;
108+
zoomOutDurationMs?: number;
106109
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
107110
}
108111

@@ -539,6 +542,7 @@ function Timeline({
539542
selectedAnnotationId,
540543
selectedBlurId,
541544
selectedSpeedId,
545+
onZoomDurationChange,
542546
keyframes = [],
543547
}: {
544548
items: TimelineRenderItem[];
@@ -556,6 +560,7 @@ function Timeline({
556560
selectedAnnotationId?: string | null;
557561
selectedBlurId?: string | null;
558562
selectedSpeedId?: string | null;
563+
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
559564
keyframes?: { id: string; time: number }[];
560565
}) {
561566
const t = useScopedT("timeline");
@@ -682,6 +687,9 @@ function Timeline({
682687
isSelected={item.id === selectedZoomId}
683688
onSelect={() => onSelectZoom?.(item.id)}
684689
zoomDepth={item.zoomDepth}
690+
zoomInDurationMs={item.zoomInDurationMs}
691+
zoomOutDurationMs={item.zoomOutDurationMs}
692+
onZoomDurationChange={onZoomDurationChange}
685693
variant="zoom"
686694
>
687695
{item.label}
@@ -770,6 +778,7 @@ export default function TimelineEditor({
770778
onZoomAdded,
771779
onZoomSuggested,
772780
onZoomSpanChange,
781+
onZoomDurationChange,
773782
onZoomDelete,
774783
selectedZoomId,
775784
onSelectZoom,
@@ -1338,6 +1347,8 @@ export default function TimelineEditor({
13381347
span: { start: region.startMs, end: region.endMs },
13391348
label: t("labels.zoomItem", { index: String(index + 1) }),
13401349
zoomDepth: region.depth,
1350+
zoomInDurationMs: region.zoomInDurationMs,
1351+
zoomOutDurationMs: region.zoomOutDurationMs,
13411352
variant: "zoom",
13421353
}));
13431354

@@ -1594,6 +1605,7 @@ export default function TimelineEditor({
15941605
selectedAnnotationId={selectedAnnotationId}
15951606
selectedBlurId={selectedBlurId}
15961607
selectedSpeedId={selectedSpeedId}
1608+
onZoomDurationChange={onZoomDurationChange}
15971609
keyframes={keyframes}
15981610
/>
15991611
</TimelineWrapper>

src/components/video-editor/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface ZoomRegion {
3333
depth: ZoomDepth;
3434
focus: ZoomFocus;
3535
focusMode?: ZoomFocusMode;
36+
zoomInDurationMs?: number;
37+
zoomOutDurationMs?: number;
3638
}
3739

3840
export interface CursorTelemetryPoint {

0 commit comments

Comments
 (0)