-
{fixed.label}
+
+
+ {t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
+
{isMac
? fixed.display
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index f5afe35..7e556b8 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -51,7 +51,9 @@ import type {
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
+ WebcamMaskShape,
ZoomDepth,
+ ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -92,6 +94,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
+ selectedZoomFocusMode?: ZoomFocusMode | null;
+ onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
+ hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
@@ -143,6 +148,8 @@ interface SettingsPanelProps {
hasWebcam?: boolean;
webcamLayoutPreset?: WebcamLayoutPreset;
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
+ webcamMaskShape?: import("./types").WebcamMaskShape;
+ onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
}
export default SettingsPanel;
@@ -161,6 +168,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
+ selectedZoomFocusMode,
+ onZoomFocusModeChange,
+ hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -211,6 +221,8 @@ export function SettingsPanel({
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
onWebcamLayoutPresetChange,
+ webcamMaskShape = "rectangle",
+ onWebcamMaskShapeChange,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState([]);
@@ -500,6 +512,41 @@ export function SettingsPanel({
{!zoomEnabled && (
{t("zoom.selectRegion")}
)}
+ {zoomEnabled && hasCursorTelemetry && (
+
+
+ {t("zoom.focusMode.title")}
+
+
+ {(["manual", "auto"] as const).map((mode) => {
+ const isActive = selectedZoomFocusMode === mode;
+ return (
+
+ );
+ })}
+
+ {selectedZoomFocusMode === "auto" && (
+
+ {t("zoom.focusMode.autoDescription")}
+
+ )}
+
+ )}
{zoomEnabled && (
+ {webcamLayoutPreset === "picture-in-picture" && (
+
+
+ {t("layout.webcamShape")}
+
+
+ {(
+ [
+ { value: "rectangle", label: "Rect" },
+ { value: "circle", label: "Circle" },
+ { value: "square", label: "Square" },
+ { value: "rounded", label: "Rounded" },
+ ] as Array<{ value: WebcamMaskShape; label: string }>
+ ).map((shape) => (
+
+ ))}
+
+
+ )}
)}
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx
index 775cb8c..faa7513 100644
--- a/src/components/video-editor/ShortcutsConfigDialog.tsx
+++ b/src/components/video-editor/ShortcutsConfigDialog.tsx
@@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {
{t("fixed")}
- {FIXED_SHORTCUTS.map(({ label, display }) => (
+ {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
- {label}
+
+ {t(`fixedActions.${i18nKey}`, { defaultValue: label })}
+
{display}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 304d10f..549aa37 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -20,6 +20,7 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
+import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
@@ -56,6 +57,7 @@ import {
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
+ type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
@@ -84,6 +86,7 @@ export default function VideoEditor() {
padding,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
} = editorState;
@@ -98,6 +101,10 @@ export default function VideoEditor() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
+ const durationRef = useRef(duration);
+ durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
const [selectedTrimId, setSelectedTrimId] = useState(null);
@@ -195,6 +202,7 @@ export default function VideoEditor() {
annotationRegions: normalizedEditor.annotationRegions,
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
+ webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -264,6 +272,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -287,6 +296,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -380,6 +390,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -434,6 +445,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -688,6 +700,18 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
+ const handleZoomFocusModeChange = useCallback(
+ (focusMode: ZoomFocusMode) => {
+ if (!selectedZoomId) return;
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
+ region.id === selectedZoomId ? { ...region, focusMode } : region,
+ ),
+ }));
+ },
+ [selectedZoomId, pushState],
+ );
+
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
@@ -926,6 +950,40 @@ export default function VideoEditor() {
return;
}
+ // Frame-step navigation (arrow keys, no modifiers)
+ if (
+ (e.key === "ArrowLeft" || e.key === "ArrowRight") &&
+ !e.ctrlKey &&
+ !e.metaKey &&
+ !e.shiftKey &&
+ !e.altKey
+ ) {
+ const target = e.target;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const video = videoPlaybackRef.current?.video;
+ if (!video) {
+ return;
+ }
+ const direction = e.key === "ArrowLeft" ? "backward" : "forward";
+ const newTime = computeFrameStepTime(
+ video.currentTime,
+ Number.isFinite(video.duration) ? video.duration : durationRef.current,
+ direction,
+ );
+ video.currentTime = newTime;
+ return;
+ }
+
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
@@ -1090,9 +1148,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
+ cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1221,9 +1281,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
+ cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1289,9 +1351,11 @@ export default function VideoEditor() {
isPlaying,
aspectRatio,
webcamLayoutPreset,
+ webcamMaskShape,
webcamPosition,
exportQuality,
handleExportSaved,
+ cursorTelemetry,
],
);
@@ -1473,6 +1537,7 @@ export default function VideoEditor() {
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
+ webcamMaskShape={webcamMaskShape}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1502,6 +1567,7 @@ export default function VideoEditor() {
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
+ cursorTelemetry={cursorTelemetry}
/>
@@ -1584,6 +1650,13 @@ export default function VideoEditor() {
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
+ selectedZoomFocusMode={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
+ : null
+ }
+ onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
+ hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
@@ -1613,6 +1686,8 @@ export default function VideoEditor() {
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
})
}
+ webcamMaskShape={webcamMaskShape}
+ onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 71030bb..d659afe 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -25,6 +25,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
+import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -41,10 +42,14 @@ import {
type ZoomRegion,
} from "./types";
import {
+ AUTO_FOLLOW_RAMP_DISTANCE,
+ AUTO_FOLLOW_SMOOTHING_FACTOR,
+ AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
+import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -63,6 +68,7 @@ interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
+ webcamMaskShape?: import("./types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -93,6 +99,7 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
export interface VideoPlaybackRef {
@@ -111,6 +118,7 @@ const VideoPlayback = forwardRef