diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 31ba6c11d..b49197da4 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -107,6 +107,7 @@ import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils"; import { type AnnotationRegion, type BlurData, + type CameraFullscreenRegion, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, @@ -205,6 +206,7 @@ export default function VideoEditor() { const { zoomRegions, + cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, trimRegions, @@ -243,6 +245,7 @@ export default function VideoEditor() { const durationRef = useRef(duration); durationRef.current = duration; const [selectedZoomId, setSelectedZoomId] = useState(null); + const [selectedCameraFullscreenId, setSelectedCameraFullscreenId] = useState(null); const [isPreviewingZoom, setIsPreviewingZoom] = useState(false); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); @@ -313,6 +316,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); + const nextCameraFullscreenIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); @@ -389,6 +393,7 @@ export default function VideoEditor() { const inferredDurationMs = Math.max( 0, ...normalizedEditor.zoomRegions.map((region) => region.endMs), + ...normalizedEditor.cameraFullscreenRegions.map((region) => region.endMs), ...normalizedEditor.trimRegions.map((region) => region.endMs), ...normalizedEditor.speedRegions.map((region) => region.endMs), ...normalizedEditor.annotationRegions.map((region) => region.endMs), @@ -425,6 +430,7 @@ export default function VideoEditor() { padding: normalizedEditor.padding, cropRegion: normalizedEditor.cropRegion, zoomRegions: normalizedEditor.zoomRegions, + cameraFullscreenRegions: normalizedEditor.cameraFullscreenRegions, autoZoomEnabled: normalizedEditor.autoZoomEnabled, autoFocusAll: normalizedEditor.autoFocusAll, trimRegions: normalizedEditor.trimRegions, @@ -446,6 +452,7 @@ export default function VideoEditor() { setCursorTheme(normalizedEditor.cursorTheme); setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); @@ -455,6 +462,10 @@ export default function VideoEditor() { "zoom", normalizedEditor.zoomRegions.map((region) => region.id), ); + nextCameraFullscreenIdRef.current = deriveNextId( + "camera-fullscreen", + normalizedEditor.cameraFullscreenRegions.map((region) => region.id), + ); nextTrimIdRef.current = deriveNextId( "trim", normalizedEditor.trimRegions.map((region) => region.id), @@ -502,6 +513,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, trimRegions, @@ -533,6 +545,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, trimRegions, @@ -661,6 +674,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, trimRegions, @@ -726,6 +740,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, trimRegions, @@ -863,6 +878,7 @@ export default function VideoEditor() { resetState(); // Reset non-undoable selection state. setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); @@ -880,6 +896,7 @@ export default function VideoEditor() { setCursorTheme(DEFAULT_CURSOR_SETTINGS.theme); // Reset region ID counters. nextZoomIdRef.current = 1; + nextCameraFullscreenIdRef.current = 1; nextTrimIdRef.current = 1; nextSpeedIdRef.current = 1; nextAnnotationIdRef.current = 1; @@ -990,6 +1007,18 @@ export default function VideoEditor() { const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { + setSelectedCameraFullscreenId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + } + }, []); + + const handleSelectCameraFullscreen = useCallback((id: string | null) => { + setSelectedCameraFullscreenId(id); + if (id) { + setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); @@ -1001,6 +1030,7 @@ export default function VideoEditor() { setSelectedTrimId(id); if (id) { setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); @@ -1011,6 +1041,7 @@ export default function VideoEditor() { setSelectedAnnotationId(id); if (id) { setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedBlurId(null); @@ -1021,6 +1052,7 @@ export default function VideoEditor() { setSelectedBlurId(id); if (id) { setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedSpeedId(null); @@ -1043,6 +1075,7 @@ export default function VideoEditor() { }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); @@ -1051,6 +1084,56 @@ export default function VideoEditor() { [pushState, autoFocusAll], ); + const handleCameraFullscreenAdded = useCallback( + (span: Span) => { + const id = `camera-fullscreen-${nextCameraFullscreenIdRef.current++}`; + const newRegion: CameraFullscreenRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + }; + pushState((prev) => ({ + cameraFullscreenRegions: [...prev.cameraFullscreenRegions, newRegion], + })); + setSelectedCameraFullscreenId(id); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + }, + [pushState], + ); + + const handleCameraFullscreenSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + cameraFullscreenRegions: prev.cameraFullscreenRegions.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + })); + }, + [pushState], + ); + + const handleCameraFullscreenDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + cameraFullscreenRegions: prev.cameraFullscreenRegions.filter((r) => r.id !== id), + })); + if (selectedCameraFullscreenId === id) { + setSelectedCameraFullscreenId(null); + } + }, + [selectedCameraFullscreenId, pushState], + ); + // Builds fresh "auto" zoom regions from cursor telemetry without overlapping // existing ones. Used by both the on-load auto-suggest pass and the wand toggle. const buildAutoZoomRegions = useCallback( @@ -1301,6 +1384,7 @@ export default function VideoEditor() { setSelectedSpeedId(id); if (id) { setSelectedZoomId(null); + setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); @@ -1969,6 +2053,15 @@ export default function VideoEditor() { } }, [selectedZoomId, zoomRegions]); + useEffect(() => { + if ( + selectedCameraFullscreenId && + !cameraFullscreenRegions.some((region) => region.id === selectedCameraFullscreenId) + ) { + setSelectedCameraFullscreenId(null); + } + }, [selectedCameraFullscreenId, cameraFullscreenRegions]); + useEffect(() => { if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { setSelectedTrimId(null); @@ -2138,6 +2231,7 @@ export default function VideoEditor() { sizePreset: settings.gifConfig.sizePreset, wallpaper, zoomRegions, + cameraFullscreenRegions, trimRegions, speedRegions, showShadow: shadowIntensity > 0, @@ -2233,6 +2327,7 @@ export default function VideoEditor() { codec: "avc1.640033", wallpaper, zoomRegions, + cameraFullscreenRegions, trimRegions, speedRegions, showShadow: shadowIntensity > 0, @@ -2341,6 +2436,7 @@ export default function VideoEditor() { webcamVideoPath, wallpaper, zoomRegions, + cameraFullscreenRegions, trimRegions, speedRegions, shadowIntensity, @@ -2871,6 +2967,7 @@ export default function VideoEditor() { onError={setError} wallpaper={wallpaper} zoomRegions={zoomRegions} + cameraFullscreenRegions={cameraFullscreenRegions} selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} onZoomFocusChange={handleZoomFocusChange} @@ -3127,6 +3224,12 @@ export default function VideoEditor() { onZoomDelete={handleZoomDelete} selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} + cameraFullscreenRegions={cameraFullscreenRegions} + onCameraFullscreenAdded={handleCameraFullscreenAdded} + onCameraFullscreenSpanChange={handleCameraFullscreenSpanChange} + onCameraFullscreenDelete={handleCameraFullscreenDelete} + selectedCameraFullscreenId={selectedCameraFullscreenId} + onSelectCameraFullscreen={handleSelectCameraFullscreen} trimRegions={trimRegions} onTrimAdded={handleTrimAdded} onTrimSpanChange={handleTrimSpanChange} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8ce32cfd2..11c9e4061 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -19,7 +19,9 @@ import { useState, } from "react"; import { + computeCameraFullscreenTargetRect, getWebcamLayoutCssBoxShadow, + lerpRect, reactiveWebcamScale, type Size, type StyledRenderRect, @@ -56,6 +58,7 @@ import { import { type AnnotationRegion, type BlurData, + type CameraFullscreenRegion, type CursorTelemetryPoint, computeRotation3DContainScale, DEFAULT_CROP_REGION, @@ -70,6 +73,7 @@ import { type ZoomFocus, type ZoomRegion, } from "./types"; +import { computeCameraFullscreenProgress } from "./videoPlayback/cameraFullscreenUtils"; import { AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS } from "./videoPlayback/constants"; import { advanceFollowFocus } from "./videoPlayback/cursorFollowUtils"; import { @@ -110,6 +114,7 @@ interface VideoPlaybackProps { onError: (error: string) => void; wallpaper?: string; zoomRegions: ZoomRegion[]; + cameraFullscreenRegions?: CameraFullscreenRegion[]; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; onZoomFocusChange: (id: string, focus: ZoomFocus) => void; @@ -264,6 +269,7 @@ const VideoPlayback = forwardRef( onError, wallpaper, zoomRegions, + cameraFullscreenRegions = [], selectedZoomId, onSelectZoom, onZoomFocusChange, @@ -330,9 +336,11 @@ const VideoPlayback = forwardRef( const composite3DRef = useRef(null); const outerWrapperRef = useRef(null); const [webcamLayout, setWebcamLayout] = useState(null); + const webcamLayoutRef = useRef(null); const [webcamDimensions, setWebcamDimensions] = useState(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); + const cameraFullscreenRegionsRef = useRef([]); const cursorTelemetryRef = useRef([]); const cursorClickTimestampsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -607,6 +615,7 @@ const VideoPlayback = forwardRef( baseMaskRef.current = result.maskRect; borderRadiusRef.current = result.maskBorderRadius; cropBoundsRef.current = result.cropBounds; + webcamLayoutRef.current = result.webcamRect; setWebcamLayout(result.webcamRect); // Reset camera container to identity @@ -811,6 +820,10 @@ const VideoPlayback = forwardRef( zoomRegionsRef.current = zoomRegions; }, [zoomRegions]); + useEffect(() => { + cameraFullscreenRegionsRef.current = cameraFullscreenRegions; + }, [cameraFullscreenRegions]); + useEffect(() => { cursorTelemetryRef.current = cursorTelemetry; }, [cursorTelemetry]); @@ -1411,17 +1424,51 @@ const VideoPlayback = forwardRef( // corner (bottom-right by default) so it stays flush instead of drifting to center. const webcamWrapper = webcamWrapperRef.current; if (webcamWrapper) { - const reactive = - webcamReactiveZoomRef.current && webcamLayoutPresetRef.current === "picture-in-picture"; - const factor = reactive ? reactiveWebcamScale(state.appliedScale) : 1; - if (factor < 1) { - const pos = webcamPositionRef.current; - const originX = (pos ? pos.cx >= 0.5 : true) ? "100%" : "0%"; - const originY = (pos ? pos.cy >= 0.5 : true) ? "100%" : "0%"; - webcamWrapper.style.transformOrigin = `${originX} ${originY}`; - webcamWrapper.style.transform = `scale(${factor})`; + const cameraFullProgress = computeCameraFullscreenProgress( + cameraFullscreenRegionsRef.current, + currentTimeRef.current, + ); + const baseRect = webcamLayoutRef.current; + + if (cameraFullProgress > 0 && baseRect && baseRect.width > 0 && baseRect.height > 0) { + // Full Camera takes over the webcam's size/position entirely: it grows to + // cover the whole stage. Like the reactive-zoom branch below, this only ever + // touches `transform` (never left/top/width/height, which stay driven by the + // `webcamLayout` React state in the JSX style prop) so it can't race React's + // re-render on every currentTime update. The base rect is lerped toward a + // full-stage rect and expressed as a translate+scale from the base rect's own + // box, matching the CSS transform box (border-box, top-left origin). + const stage = stageSizeRef.current; + const targetRect = computeCameraFullscreenTargetRect(stage, baseRect); + const fullRect: StyledRenderRect = { + x: targetRect.x, + y: targetRect.y, + width: targetRect.width, + height: targetRect.height, + borderRadius: 0, + maskShape: baseRect.maskShape, + }; + const drawRect = lerpRect(baseRect, fullRect, cameraFullProgress); + const scaleX = drawRect.width / baseRect.width; + const scaleY = drawRect.height / baseRect.height; + const translateX = drawRect.x - baseRect.x; + const translateY = drawRect.y - baseRect.y; + webcamWrapper.style.transformOrigin = "0% 0%"; + webcamWrapper.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`; } else { - webcamWrapper.style.transform = ""; + const reactive = + webcamReactiveZoomRef.current && + webcamLayoutPresetRef.current === "picture-in-picture"; + const factor = reactive ? reactiveWebcamScale(state.appliedScale) : 1; + if (factor < 1) { + const pos = webcamPositionRef.current; + const originX = (pos ? pos.cx >= 0.5 : true) ? "100%" : "0%"; + const originY = (pos ? pos.cy >= 0.5 : true) ? "100%" : "0%"; + webcamWrapper.style.transformOrigin = `${originX} ${originY}`; + webcamWrapper.style.transform = `scale(${factor})`; + } else { + webcamWrapper.style.transform = ""; + } } } }; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 258efdf6a..4727b685e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -15,6 +15,7 @@ import { } from "./editorDefaults"; import { type AnnotationRegion, + type CameraFullscreenRegion, type CropRegion, clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, @@ -74,6 +75,7 @@ export interface ProjectEditorState { padding: number; cropRegion: CropRegion; zoomRegions: ZoomRegion[]; + cameraFullscreenRegions: CameraFullscreenRegion[]; autoZoomEnabled: boolean; autoFocusAll: boolean; trimRegions: TrimRegion[]; @@ -271,6 +273,26 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; + const normalizedCameraFullscreenRegions: CameraFullscreenRegion[] = Array.isArray( + editor.cameraFullscreenRegions, + ) + ? editor.cameraFullscreenRegions + .filter((region): region is CameraFullscreenRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + return { + id: region.id, + startMs, + endMs, + }; + }) + : []; + const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions) ? editor.trimRegions .filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string")) @@ -484,6 +506,7 @@ export function normalizeProjectEditor(editor: Partial): Pro height: cropHeight, }, zoomRegions: normalizedZoomRegions, + cameraFullscreenRegions: normalizedCameraFullscreenRegions, // Default on for legacy projects so re-opens match the new default. The // on-load auto-suggest pass is gated separately, so this won't add zooms. autoZoomEnabled: typeof editor.autoZoomEnabled === "boolean" ? editor.autoZoomEnabled : true, diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 254c4f94b..ec4ca084d 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,6 +1,6 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; -import { Gauge, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react"; +import { Gauge, Maximize, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react"; import { useMemo } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; @@ -17,7 +17,7 @@ interface ItemProps { zoomCustomScale?: number; speedValue?: number; isAutoFocus?: boolean; - variant?: "zoom" | "trim" | "annotation" | "speed" | "blur"; + variant?: "zoom" | "camera-fullscreen" | "trim" | "annotation" | "speed" | "blur"; } // Map zoom depth to multiplier labels @@ -61,18 +61,29 @@ export default function Item({ }); const isZoom = variant === "zoom"; + const isCameraFullscreen = variant === "camera-fullscreen"; const isTrim = variant === "trim"; const isSpeed = variant === "speed"; const glassClass = isZoom ? glassStyles.glassGreen - : isTrim - ? glassStyles.glassRed - : isSpeed - ? glassStyles.glassAmber - : glassStyles.glassYellow; + : isCameraFullscreen + ? glassStyles.glassBlue + : isTrim + ? glassStyles.glassRed + : isSpeed + ? glassStyles.glassAmber + : glassStyles.glassYellow; - const endCapColor = isZoom ? "#21916A" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046"; + const endCapColor = isZoom + ? "#21916A" + : isCameraFullscreen + ? "#0ea5e9" + : isTrim + ? "#ef4444" + : isSpeed + ? "#d97706" + : "#B4A046"; const timeLabel = useMemo( () => `${formatMs(span.start)} – ${formatMs(span.end)}`, @@ -146,6 +157,13 @@ export default function Item({ /> )} + ) : isCameraFullscreen ? ( + <> + + + {t("labels.cameraFullscreen")} + + ) : isTrim ? ( <> diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 9fdbacdb6..94fcbadee 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -110,6 +110,34 @@ z-index: 10; } +.glassBlue { + position: relative; + border-radius: 10px; + -corner-smoothing: antialiased; + background: linear-gradient(180deg, rgba(56, 189, 248, 0.28), rgba(14, 116, 144, 0.2)); + border: 1px solid rgba(56, 189, 248, 0.36); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.1) inset, + 0 8px 22px rgba(0, 0, 0, 0.22); + margin: 3px 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glassBlue:hover { + background: linear-gradient(180deg, rgba(56, 189, 248, 0.36), rgba(14, 116, 144, 0.25)); + border-color: rgba(56, 189, 248, 0.62); +} + +.glassBlue.selected { + background: linear-gradient(180deg, rgba(56, 189, 248, 0.48), rgba(14, 116, 144, 0.32)); + border-color: #38bdf8; + box-shadow: + 0 0 0 1px rgba(56, 189, 248, 0.95), + 0 0 0 4px rgba(56, 189, 248, 0.14), + 0 12px 26px rgba(0, 0, 0, 0.28); + z-index: 10; +} + .zoomEndCap { position: absolute; top: 0; @@ -130,7 +158,9 @@ .glassYellow:hover .zoomEndCap, .glassYellow.selected .zoomEndCap, .glassAmber:hover .zoomEndCap, -.glassAmber.selected .zoomEndCap { +.glassAmber.selected .zoomEndCap, +.glassBlue:hover .zoomEndCap, +.glassBlue.selected .zoomEndCap { opacity: 1; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 96965a012..b6e323a31 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -5,6 +5,7 @@ import { Check, ChevronDown, Gauge, + Maximize, MessageSquare, Plus, ScanEye, @@ -31,7 +32,13 @@ import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/as import { formatShortcut } from "@/utils/platformUtils"; import { BLUR_REGIONS_ENABLED } from "../featureFlags"; import { findFreeGapAt } from "../regionPlacement"; -import type { AnnotationRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../types"; +import type { + AnnotationRegion, + CameraFullscreenRegion, + SpeedRegion, + TrimRegion, + ZoomRegion, +} from "../types"; import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; @@ -39,6 +46,7 @@ import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; const ZOOM_ROW_ID = "row-zoom"; +const CAMERA_ROW_ID = "row-camera-fullscreen"; const TRIM_ROW_ID = "row-trim"; const ANNOTATION_ROW_ID = "row-annotation"; const BLUR_ROW_ID = "row-blur"; @@ -63,6 +71,12 @@ interface TimelineEditorProps { onZoomDelete: (id: string) => void; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; + cameraFullscreenRegions?: CameraFullscreenRegion[]; + onCameraFullscreenAdded?: (span: Span) => void; + onCameraFullscreenSpanChange?: (id: string, span: Span) => void; + onCameraFullscreenDelete?: (id: string) => void; + selectedCameraFullscreenId?: string | null; + onSelectCameraFullscreen?: (id: string | null) => void; trimRegions?: TrimRegion[]; onTrimAdded?: (span: Span) => void; onTrimSpanChange?: (id: string, span: Span) => void; @@ -113,7 +127,7 @@ interface TimelineRenderItem { zoomCustomScale?: number; speedValue?: number; isAutoFocus?: boolean; - variant: "zoom" | "trim" | "annotation" | "speed" | "blur"; + variant: "zoom" | "camera-fullscreen" | "trim" | "annotation" | "speed" | "blur"; } const SCALE_CANDIDATES = [ @@ -560,11 +574,13 @@ function Timeline({ onSeek, onRangeChange, onSelectZoom, + onSelectCameraFullscreen, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed, selectedZoomId, + selectedCameraFullscreenId, selectedTrimId, selectedAnnotationId, selectedBlurId, @@ -579,11 +595,13 @@ function Timeline({ onSeek?: (time: number) => void; onRangeChange?: (updater: (previous: Range) => Range) => void; onSelectZoom?: (id: string | null) => void; + onSelectCameraFullscreen?: (id: string | null) => void; onSelectTrim?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectBlur?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; selectedZoomId: string | null; + selectedCameraFullscreenId?: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; selectedBlurId?: string | null; @@ -627,11 +645,19 @@ function Timeline({ const clearTimelineSelection = useCallback(() => { onSelectZoom?.(null); + onSelectCameraFullscreen?.(null); onSelectTrim?.(null); onSelectAnnotation?.(null); onSelectBlur?.(null); onSelectSpeed?.(null); - }, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]); + }, [ + onSelectZoom, + onSelectCameraFullscreen, + onSelectTrim, + onSelectAnnotation, + onSelectBlur, + onSelectSpeed, + ]); const handleTimelineClick = useCallback( (e: React.MouseEvent) => { @@ -746,6 +772,7 @@ function Timeline({ ); const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); + const cameraFullscreenItems = items.filter((item) => item.rowId === CAMERA_ROW_ID); const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID); const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID); @@ -795,6 +822,26 @@ function Timeline({ ))} + + {cameraFullscreenItems.map((item) => ( + onSelectCameraFullscreen?.(item.id)} + variant="camera-fullscreen" + > + {item.label} + + ))} + + { + if (!selectedCameraFullscreenId || !onCameraFullscreenDelete || !onSelectCameraFullscreen) { + return; + } + onCameraFullscreenDelete(selectedCameraFullscreenId); + onSelectCameraFullscreen(null); + }, [selectedCameraFullscreenId, onCameraFullscreenDelete, onSelectCameraFullscreen]); + const deleteSelectedTrim = useCallback(() => { if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; onTrimDelete(selectedTrimId); @@ -1019,11 +1080,17 @@ export default function TimelineEditor({ // Normalize regions only when timeline bounds change. Reading via refs avoids a // dependency loop that would re-fire on every drag and race dnd-timeline's state. const zoomRegionsRef = useRef(zoomRegions); + const cameraFullscreenRegionsRef = useRef(cameraFullscreenRegions); const trimRegionsRef = useRef(trimRegions); const speedRegionsRef = useRef(speedRegions); - zoomRegionsRef.current = zoomRegions; - trimRegionsRef.current = trimRegions; - speedRegionsRef.current = speedRegions; + // Sync refs in an effect (not during render) so render stays pure; defined before + // the normalization effect below so that effect reads the freshest region values. + useEffect(() => { + zoomRegionsRef.current = zoomRegions; + cameraFullscreenRegionsRef.current = cameraFullscreenRegions; + trimRegionsRef.current = trimRegions; + speedRegionsRef.current = speedRegions; + }, [zoomRegions, cameraFullscreenRegions, trimRegions, speedRegions]); useEffect(() => { if (totalMs === 0 || safeMinDurationMs <= 0) { @@ -1042,6 +1109,18 @@ export default function TimelineEditor({ } }); + cameraFullscreenRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onCameraFullscreenSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + trimRegionsRef.current.forEach((region) => { const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); const minEnd = clampedStart + safeMinDurationMs; @@ -1065,11 +1144,19 @@ export default function TimelineEditor({ onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); } }); - }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]); + }, [ + totalMs, + safeMinDurationMs, + onZoomSpanChange, + onCameraFullscreenSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + ]); const hasOverlap = useCallback( (newSpan: Span, excludeId?: string): boolean => { const isZoomItem = zoomRegions.some((r) => r.id === excludeId); + const isCameraFullscreenItem = cameraFullscreenRegions.some((r) => r.id === excludeId); const isTrimItem = trimRegions.some((r) => r.id === excludeId); const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); const isBlurItem = blurRegions.some((r) => r.id === excludeId); @@ -1079,7 +1166,9 @@ export default function TimelineEditor({ return false; } - const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => { + const checkOverlap = ( + regions: (ZoomRegion | CameraFullscreenRegion | TrimRegion | SpeedRegion)[], + ) => { return regions.some((region) => { if (region.id === excludeId) return false; // True intersection, adjacency is allowed @@ -1091,6 +1180,10 @@ export default function TimelineEditor({ return checkOverlap(zoomRegions); } + if (isCameraFullscreenItem) { + return checkOverlap(cameraFullscreenRegions); + } + if (isTrimItem) { return checkOverlap(trimRegions); } @@ -1101,7 +1194,14 @@ export default function TimelineEditor({ return false; }, - [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions], + [ + zoomRegions, + cameraFullscreenRegions, + trimRegions, + annotationRegions, + blurRegions, + speedRegions, + ], ); // 5% of the timeline or 1000ms, whichever is larger, so it's wide enough to grab. @@ -1133,6 +1233,37 @@ export default function TimelineEditor({ onZoomAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]); + const handleAddCameraFullscreen = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onCameraFullscreenAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const { ok, gapMs } = findFreeGapAt(cameraFullscreenRegions, startPos, totalMs); + if (!ok) { + toast.error(t("errors.cannotPlaceCameraFullscreen"), { + description: t("errors.cameraFullscreenExistsAtLocation"), + }); + return; + } + + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); + onCameraFullscreenAdded({ start: startPos, end: startPos + actualDuration }); + }, [ + videoDuration, + totalMs, + currentTimeMs, + cameraFullscreenRegions, + onCameraFullscreenAdded, + defaultRegionDurationMs, + t, + ]); + const handleAddTrim = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { return; @@ -1243,6 +1374,9 @@ export default function TimelineEditor({ if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { handleAddSpeed(); } + if (matchesShortcut(e, keyShortcuts.addCameraFullscreen, isMac)) { + handleAddCameraFullscreen(); + } // Tab cycles through overlapping annotations at the current time if (e.key === "Tab" && annotationRegions.length > 0) { @@ -1275,6 +1409,8 @@ export default function TimelineEditor({ deleteSelectedKeyframe(); } else if (selectedZoomId) { deleteSelectedZoom(); + } else if (selectedCameraFullscreenId) { + deleteSelectedCameraFullscreen(); } else if (selectedTrimId) { deleteSelectedTrim(); } else if (selectedAnnotationId) { @@ -1295,14 +1431,17 @@ export default function TimelineEditor({ handleAddAnnotation, handleAddBlur, handleAddSpeed, + handleAddCameraFullscreen, deleteSelectedKeyframe, deleteSelectedZoom, + deleteSelectedCameraFullscreen, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedBlur, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, + selectedCameraFullscreenId, selectedTrimId, selectedAnnotationId, selectedBlurId, @@ -1337,6 +1476,16 @@ export default function TimelineEditor({ variant: "zoom", })); + const cameraFullscreens: TimelineRenderItem[] = cameraFullscreenRegions.map( + (region, index) => ({ + id: region.id, + rowId: CAMERA_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: t("labels.cameraFullscreenItem", { index: String(index + 1) }), + variant: "camera-fullscreen", + }), + ); + const trims: TimelineRenderItem[] = trimRegions.map((region, index) => ({ id: region.id, rowId: TRIM_ROW_ID, @@ -1383,17 +1532,30 @@ export default function TimelineEditor({ variant: "speed", })); - return [...zooms, ...trims, ...annotations, ...blurs, ...speeds]; - }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]); + return [...zooms, ...cameraFullscreens, ...trims, ...annotations, ...blurs, ...speeds]; + }, [ + zoomRegions, + cameraFullscreenRegions, + trimRegions, + annotationRegions, + blurRegions, + speedRegions, + t, + ]); // Spans that participate in overlap resolution (clampToNeighbours). Annotation // and blur are excluded since they may overlap and shouldn't constrain a drag. const allRegionSpans = useMemo(() => { const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); + const cameraFullscreens = cameraFullscreenRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + })); const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); - return [...zooms, ...trims, ...speeds]; - }, [zoomRegions, trimRegions, speedRegions]); + return [...zooms, ...cameraFullscreens, ...trims, ...speeds]; + }, [zoomRegions, cameraFullscreenRegions, trimRegions, speedRegions]); // Snap targets whose edges pull during a snap but don't push anyone away. const softSnapSpans = useMemo(() => { @@ -1412,6 +1574,8 @@ export default function TimelineEditor({ (id: string, span: Span) => { if (zoomRegions.some((r) => r.id === id)) { onZoomSpanChange(id, span); + } else if (cameraFullscreenRegions.some((r) => r.id === id)) { + onCameraFullscreenSpanChange?.(id, span); } else if (trimRegions.some((r) => r.id === id)) { onTrimSpanChange?.(id, span); } else if (speedRegions.some((r) => r.id === id)) { @@ -1424,11 +1588,13 @@ export default function TimelineEditor({ }, [ zoomRegions, + cameraFullscreenRegions, trimRegions, speedRegions, annotationRegions, blurRegions, onZoomSpanChange, + onCameraFullscreenSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange, @@ -1469,6 +1635,15 @@ export default function TimelineEditor({ > +