Merge branch 'main' into feature/pause-button
This commit is contained in:
@@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import {
|
||||
MdCancel,
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
@@ -45,6 +46,7 @@ const ICON_CONFIG = {
|
||||
resume: { icon: BsPlayCircle, size: ICON_SIZE },
|
||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||
cancel: { icon: MdCancel, size: ICON_SIZE },
|
||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||
@@ -84,6 +86,7 @@ export function LaunchWindow() {
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
@@ -478,6 +481,18 @@ export function LaunchWindow() {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
|
||||
@@ -37,8 +37,10 @@ export function KeyboardShortcutsHelp() {
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
<div key={fixed.label} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{fixed.label}</span>
|
||||
<div key={fixed.i18nKey} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">
|
||||
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
|
||||
</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{isMac
|
||||
? fixed.display
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
@@ -500,6 +512,41 @@ export function SettingsPanel({
|
||||
{!zoomEnabled && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
|
||||
)}
|
||||
{zoomEnabled && hasCursorTelemetry && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm font-medium text-slate-200 mb-2 block">
|
||||
{t("zoom.focusMode.title")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(["manual", "auto"] as const).map((mode) => {
|
||||
const isActive = selectedZoomFocusMode === mode;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onZoomFocusModeChange?.(mode)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-2 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-xs font-semibold capitalize">
|
||||
{t(`zoom.focusMode.${mode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedZoomFocusMode === "auto" && (
|
||||
<p className="text-[10px] text-slate-500 mt-1.5">
|
||||
{t("zoom.focusMode.autoDescription")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
@@ -623,6 +670,87 @@ export function SettingsPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
|
||||
{t("layout.webcamShape")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ value: "rectangle", label: "Rect" },
|
||||
{ value: "circle", label: "Circle" },
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
] as Array<{ value: WebcamMaskShape; label: string }>
|
||||
).map((shape) => (
|
||||
<button
|
||||
key={shape.value}
|
||||
type="button"
|
||||
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
|
||||
webcamMaskShape === shape.value
|
||||
? "bg-[#34B27B] border-[#34B27B] text-white"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "circle" && (
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "square" && (
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "rounded" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<span className="text-[8px] leading-none">{shape.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
@@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("fixed")}
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||
<div
|
||||
key={label}
|
||||
key={i18nKey}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
|
||||
@@ -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<CursorTelemetryPoint[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -141,6 +149,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
cursorTelemetry = [],
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -160,6 +169,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -194,6 +204,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
@@ -272,6 +284,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -302,6 +315,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -379,6 +393,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (!regionId) return;
|
||||
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
||||
if (!region) return;
|
||||
if (region.focusMode === "auto") return;
|
||||
onSelectZoom(region.id);
|
||||
event.preventDefault();
|
||||
isDraggingFocusRef.current = true;
|
||||
@@ -462,6 +477,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
zoomRegionsRef.current = zoomRegions;
|
||||
}, [zoomRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
@@ -830,10 +849,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
const ticker = () => {
|
||||
const bm = baseMaskRef.current;
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{ connectZooms: true },
|
||||
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
@@ -854,6 +879,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
// Apply adaptive smoothing for auto-follow mode
|
||||
if (region.focusMode === "auto" && !transition) {
|
||||
const raw = targetFocus;
|
||||
const isZoomingIn =
|
||||
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
|
||||
if (targetProgress >= 0.999) {
|
||||
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
} else if (isZoomingIn) {
|
||||
// Zoom-in: track cursor directly so zoom always aims at current cursor
|
||||
// position; keep ref in sync to avoid snap when full-zoom begins
|
||||
smoothedAutoFocusRef.current = raw;
|
||||
} else {
|
||||
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
}
|
||||
} else if (region.focusMode !== "auto") {
|
||||
smoothedAutoFocusRef.current = null;
|
||||
}
|
||||
prevTargetProgressRef.current = targetProgress;
|
||||
|
||||
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
@@ -1154,31 +1220,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath && (
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
borderRadius: webcamLayout?.borderRadius ?? 0,
|
||||
boxShadow: webcamCssBoxShadow,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
normalizeProjectEditor,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
@@ -40,6 +41,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
@@ -55,4 +57,11 @@ describe("projectPersistence media compatibility", () => {
|
||||
});
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes webcam mask shape values safely", () => {
|
||||
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
|
||||
expect(
|
||||
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
|
||||
).toBe("rectangle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -44,6 +46,7 @@ export interface ProjectEditorState {
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -189,6 +192,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -352,6 +356,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamLayoutPreset === "picture-in-picture"
|
||||
? editor.webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape:
|
||||
editor.webcamMaskShape === "rectangle" ||
|
||||
editor.webcamMaskShape === "circle" ||
|
||||
editor.webcamMaskShape === "square" ||
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamPosition:
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
|
||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
export type ZoomFocusMode = "manual" | "auto";
|
||||
export type { WebcamLayoutPreset };
|
||||
|
||||
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||
|
||||
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
|
||||
|
||||
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
|
||||
|
||||
export interface WebcamPosition {
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
@@ -23,6 +28,7 @@ export interface ZoomRegion {
|
||||
endMs: number;
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
|
||||
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||
export const ZOOM_SCALE_DEADZONE = 0.002;
|
||||
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
|
||||
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
|
||||
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
|
||||
/**
|
||||
* Binary-search the sorted telemetry array and linearly interpolate
|
||||
* the cursor position at the given playback time.
|
||||
*/
|
||||
export function interpolateCursorAt(
|
||||
telemetry: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
): ZoomFocus | null {
|
||||
if (telemetry.length === 0) return null;
|
||||
|
||||
if (timeMs <= telemetry[0].timeMs) {
|
||||
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
|
||||
}
|
||||
|
||||
const last = telemetry[telemetry.length - 1];
|
||||
if (timeMs >= last.timeMs) {
|
||||
return { cx: last.cx, cy: last.cy };
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = telemetry.length - 1;
|
||||
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (telemetry[mid].timeMs <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
const before = telemetry[lo];
|
||||
const after = telemetry[hi];
|
||||
const span = after.timeMs - before.timeMs;
|
||||
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
|
||||
|
||||
return {
|
||||
cx: before.cx + (after.cx - before.cx) * t,
|
||||
cy: before.cy + (after.cy - before.cy) * t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential smoothing to reduce jitter from high-frequency cursor data.
|
||||
* Lower factor = smoother / more lag, higher = more responsive.
|
||||
*/
|
||||
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
|
||||
return {
|
||||
cx: prev.cx + (raw.cx - prev.cx) * factor,
|
||||
cy: prev.cy + (raw.cy - prev.cy) * factor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an adaptive smoothing factor that scales with distance:
|
||||
* far from target → faster (maxFactor), close → slower (minFactor).
|
||||
* This replaces the hard deadzone with a natural deceleration curve.
|
||||
*/
|
||||
export function adaptiveSmoothFactor(
|
||||
raw: ZoomFocus,
|
||||
prev: ZoomFocus,
|
||||
minFactor: number,
|
||||
maxFactor: number,
|
||||
rampDistance: number,
|
||||
): number {
|
||||
const dx = raw.cx - prev.cx;
|
||||
const dy = raw.cy - prev.cy;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const t = Math.min(1, distance / rampDistance);
|
||||
return minFactor + (maxFactor - minFactor) * t;
|
||||
}
|
||||
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
|
||||
return getFocusBoundsForScale(zoomScale);
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number) {
|
||||
const marginX = 1 / (2 * zoomScale);
|
||||
const marginY = 1 / (2 * zoomScale);
|
||||
interface ViewportRatio {
|
||||
widthRatio: number;
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||
const wr = viewportRatio?.widthRatio ?? 1;
|
||||
const hr = viewportRatio?.heightRatio ?? 1;
|
||||
const marginX = Math.min(0.5, wr / (2 * zoomScale));
|
||||
const marginY = Math.min(0.5, hr / (2 * zoomScale));
|
||||
|
||||
return {
|
||||
minX: marginX,
|
||||
@@ -65,12 +72,16 @@ export function clampFocusToStage(
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
export function clampFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
|
||||
};
|
||||
}
|
||||
|
||||
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
export function softenFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
const horizontalRange = bounds.maxX - bounds.minX;
|
||||
const verticalRange = bounds.maxY - bounds.minY;
|
||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import type { CropRegion } from "../types";
|
||||
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||
|
||||
interface LayoutParams {
|
||||
container: HTMLDivElement;
|
||||
@@ -21,6 +21,7 @@ interface LayoutParams {
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -47,6 +48,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
@@ -94,6 +96,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (!compositeLayout) {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
|
||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||
|
||||
if (!region) {
|
||||
if (!region || region.focusMode === "auto") {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = "none";
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ZoomFocus, ZoomRegion } from "../types";
|
||||
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
viewportRatio?: ViewportRatio;
|
||||
};
|
||||
|
||||
type ConnectedRegionPair = {
|
||||
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
interface ViewportRatio {
|
||||
widthRatio: number;
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getResolvedFocus(
|
||||
region: ZoomRegion,
|
||||
zoomScale: number,
|
||||
timeMs?: number,
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
let focus = region.focus;
|
||||
|
||||
if (
|
||||
region.focusMode === "auto" &&
|
||||
cursorTelemetry &&
|
||||
cursorTelemetry.length > 0 &&
|
||||
timeMs !== undefined
|
||||
) {
|
||||
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
|
||||
if (cursorFocus) {
|
||||
focus = cursorFocus;
|
||||
}
|
||||
}
|
||||
|
||||
return clampFocusToScale(focus, zoomScale, viewportRatio);
|
||||
}
|
||||
|
||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||
@@ -96,6 +124,8 @@ function getActiveRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
const activeRegions = regions
|
||||
.map((region) => {
|
||||
@@ -130,21 +160,32 @@ function getActiveRegion(
|
||||
return {
|
||||
region: {
|
||||
...activeRegion,
|
||||
focus: getResolvedFocus(activeRegion, activeScale),
|
||||
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
||||
function getConnectedRegionHold(
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
focus: getResolvedFocus(pair.nextRegion, nextScale),
|
||||
focus: getResolvedFocus(
|
||||
pair.nextRegion,
|
||||
nextScale,
|
||||
timeMs,
|
||||
cursorTelemetry,
|
||||
viewportRatio,
|
||||
),
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
function getConnectedRegionTransition(
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
timeMs: number,
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
||||
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||
const sharedCursorFocus =
|
||||
cursorTelemetry && cursorTelemetry.length > 0
|
||||
? interpolateCursorAt(cursorTelemetry, timeMs)
|
||||
: null;
|
||||
const currentFocus = clampFocusToScale(
|
||||
currentRegion.focusMode === "auto" && sharedCursorFocus
|
||||
? sharedCursorFocus
|
||||
: currentRegion.focus,
|
||||
currentScale,
|
||||
viewportRatio,
|
||||
);
|
||||
const nextFocus = clampFocusToScale(
|
||||
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
|
||||
nextScale,
|
||||
viewportRatio,
|
||||
);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
@@ -204,20 +265,22 @@ export function findDominantRegion(
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
const telemetry = options.cursorTelemetry;
|
||||
const vr = options.viewportRatio;
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
|
||||
Reference in New Issue
Block a user