diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d37190a..c625f87 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -61,6 +61,7 @@ import type { WebcamMaskShape, WebcamSizePreset, ZoomDepth, + ZoomFocus, ZoomFocusMode, } from "./types"; import { @@ -72,6 +73,7 @@ import { SPEED_OPTIONS, ZOOM_DEPTH_SCALES, } from "./types"; +import { getFocusBoundsForScale } from "./videoPlayback/focusUtils"; function CustomSpeedInput({ value, @@ -136,6 +138,58 @@ function CustomSpeedInput({ ); } +function ZoomFocusCoordInput({ + percent, + onChange, + onCommit, + disabled, + ariaLabel, +}: { + percent: number; + onChange: (nextPercent: number) => void; + onCommit?: () => void; + disabled?: boolean; + ariaLabel: string; +}) { + // While the input is focused (user is editing), show their draft text + // so partial entries like "5" or "" don't get overwritten by re-renders. + // When not focused, mirror the live prop value so external changes + // (dragging the overlay on the preview) update the displayed number in real time. + const [draft, setDraft] = useState(null); + const display = percent.toFixed(1); + + return ( + setDraft(display)} + onChange={(e) => { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next !== "" && Number.isFinite(parsed)) { + const clamped = Math.min(100, Math.max(0, parsed)); + onChange(clamped); + } + }} + onBlur={() => { + setDraft(null); + onCommit?.(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" + /> + ); +} + const GRADIENTS = [ "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", @@ -179,6 +233,9 @@ interface SettingsPanelProps { onZoomCustomScaleCommit?: () => void; selectedZoomFocusMode?: ZoomFocusMode | null; onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; + selectedZoomFocus?: ZoomFocus | null; + onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void; + onZoomFocusCoordinateCommit?: () => void; hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; @@ -275,6 +332,9 @@ export function SettingsPanel({ onZoomCustomScaleCommit, selectedZoomFocusMode, onZoomFocusModeChange, + selectedZoomFocus, + onZoomFocusCoordinateChange, + onZoomFocusCoordinateCommit, hasCursorTelemetry = false, selectedZoomId, onZoomDelete, @@ -734,6 +794,70 @@ export function SettingsPanel({ )} )} + {zoomEnabled && + selectedZoomFocusMode !== "auto" && + selectedZoomFocus && + onZoomFocusCoordinateChange && + (() => { + const effectiveZoomScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE); + const bounds = getFocusBoundsForScale(effectiveZoomScale); + const xRange = bounds.maxX - bounds.minX; + const yRange = bounds.maxY - bounds.minY; + const focusToPercentX = (cx: number) => + xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); + const focusToPercentY = (cy: number) => + yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); + const percentToFocusX = (p: number) => + xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; + const percentToFocusY = (p: number) => + yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; + return ( +
+ + {t("zoom.position.title")} + +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + onZoomFocusCoordinateChange({ + cx: selectedZoomFocus.cx, + cy: percentToFocusY(p), + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+ + {t("zoom.position.hint")} + +
+
+ ); + })()} {zoomEnabled && (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 2e04a83..12832ad 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2102,6 +2102,15 @@ export default function VideoEditor() { : null } onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)} + selectedZoomFocus={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) + : null + } + onZoomFocusCoordinateChange={(focus) => + selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) + } + onZoomFocusCoordinateCommit={commitState} hasCursorTelemetry={cursorTelemetry.length > 0} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index f893935..a0973ec 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -44,7 +44,7 @@ interface ViewportRatio { heightRatio: number; } -function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) { +export 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)); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 6620b75..3ec0819 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -17,6 +17,12 @@ "left": "Left", "right": "Right" } + }, + "position": { + "title": "Focus Position", + "x": "X (%)", + "y": "Y (%)", + "hint": "0 = leftmost / topmost, 100 = rightmost / bottommost" } }, "speed": {