diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index b3b41976..82b85145 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,11 +1,11 @@ import { - ChatCircle as MessageSquare, FilmSlate as Film, Gauge, - MagnifyingGlassPlus as ZoomIn, - MouseLeftClickIcon as PhMouseLeftClick, + ChatCircle as MessageSquare, MusicNotes as Music, + MouseLeftClickIcon as PhMouseLeftClick, Scissors, + MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; @@ -88,7 +88,12 @@ export default function Item({ ); const MIN_ITEM_PX = 6; - const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX, height: "100%" }; + const safeItemStyle = { + ...itemStyle, + minWidth: MIN_ITEM_PX, + height: "100%", + overflow: "hidden", + }; return (
onSelect?.()} className="group h-full" > -
+
= ({ onMouseDown={(e) => { e.stopPropagation(); setSelectedKeyframeId(kf.id); + if (e.button !== 0) { + return; + } setDraggingKeyframeId(kf.id); }} onContextMenu={(e) => { diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index 1e2a17f9..ab2e975f 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -258,12 +258,12 @@ export default function TimelineWrapper({ // Drag/resize tooltip (direct DOM updates, no re-renders) const tooltipRef = useRef(null); - const formatTooltipMs = (ms: number) => { + const formatTooltipMs = useCallback((ms: number) => { const s = ms / 1000; const min = Math.floor(s / 60); const sec = s % 60; return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`; - }; + }, []); const showTooltip = useCallback( (span: { start: number; end: number } | null, screenX?: number) => { @@ -284,7 +284,7 @@ export default function TimelineWrapper({ } } }, - [], + [formatTooltipMs], ); const onDragStart = useCallback( @@ -344,17 +344,7 @@ export default function TimelineWrapper({ const desired = updater(normalized); if (totalMs > 0) { - const clamped = clampRange(desired); - - if (clamped.end > totalMs) { - const span = Math.min(clamped.end - clamped.start, totalMs); - return { - start: Math.max(0, totalMs - span), - end: totalMs, - }; - } - - return clamped; + return clampRange(desired); } return desired; diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 338538c3..ab2c40f2 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -54,6 +54,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { ); }; + const skipPastTrimRegion = (trimRegion: TrimRegion) => { + const skipToTime = trimRegion.endMs / 1000; + const clampedSkipToTime = Math.min(skipToTime, video.duration); + + video.currentTime = clampedSkipToTime; + emitTime(clampedSkipToTime); + + if (clampedSkipToTime >= video.duration) { + video.pause(); + } + }; + function updateTime() { if (!video) return; @@ -62,15 +74,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { // If we're in a trim region during playback, skip to the end of it if (activeTrimRegion && !video.paused && !video.ended) { - const skipToTime = activeTrimRegion.endMs / 1000; - - // If the skip would take us past the video duration, pause instead - if (skipToTime >= video.duration) { - video.pause(); - } else { - video.currentTime = skipToTime; - emitTime(skipToTime); - } + skipPastTrimRegion(activeTrimRegion); } else { // Apply playback speed from active speed region const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs); @@ -115,14 +119,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { // If we seeked into a trim region while playing, skip to the end if (activeTrimRegion && isPlayingRef.current && !video.paused) { - const skipToTime = activeTrimRegion.endMs / 1000; - - if (skipToTime >= video.duration) { - video.pause(); - } else { - video.currentTime = skipToTime; - emitTime(skipToTime); - } + skipPastTrimRegion(activeTrimRegion); } else { emitTime(video.currentTime); } diff --git a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts index b65f12d5..bdaf352a 100644 --- a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts +++ b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts @@ -366,6 +366,30 @@ describe("findDominantRegion", () => { } }); + it("keeps the outgoing region active until the connected transition begins", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + const result = findDominantRegion(regions, 3100, { connectZooms: true }); + expect(result.transition).toBeNull(); + expect(result.region?.id).toBe("a"); + expect(result.strength).toBeGreaterThan(0); + }); + + it("keeps the incoming region at full strength after a connected handoff", () => { + const regions: ZoomRegion[] = [ + { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, + { id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } }, + ]; + + const result = findDominantRegion(regions, 4300, { connectZooms: true }); + expect(result.transition).toBeNull(); + expect(result.region?.id).toBe("b"); + expect(result.strength).toBe(1); + }); + it("does NOT connect zooms with a large gap", () => { const regions: ZoomRegion[] = [ { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index a3cc06d5..11a412ed 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -108,13 +108,23 @@ function getActiveRegion( const activeRegions = regions .map((region) => { const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id); - if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) { + if (outgoingPair && timeMs >= outgoingPair.transitionStart) { return { region, strength: 0 }; } const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id); - if (incomingPair && timeMs < incomingPair.transitionEnd) { - return { region, strength: 0 }; + if (incomingPair) { + if (timeMs < incomingPair.transitionStart) { + return { region, strength: 0 }; + } + + const nextRegionZoomOutStart = + incomingPair.nextRegion.endMs - + ZOOM_OUT_EARLY_START_MS + + ZOOM_ANIMATION_LEAD_MS; + if (timeMs < nextRegionZoomOutStart) { + return { region, strength: 1 }; + } } return { region, strength: computeRegionStrength(region, timeMs) }; diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 9bcd3b4d..3e6a00dd 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -65,6 +65,26 @@ interface ZoomTransformGeometry { focusY: number; } +function resetMotionEffects( + blurFilter: BlurFilter | null, + motionBlurFilter?: MotionBlurFilter | null, + motionBlurState?: MotionBlurState, +) { + if (motionBlurFilter) { + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + } + + if (blurFilter) { + blurFilter.blur = 0; + } + + if (motionBlurState) { + motionBlurState.initialized = false; + } +} + export function computeZoomTransform({ stageSize, baseMask, @@ -150,6 +170,9 @@ export function applyZoomTransform({ baseMask.width <= 0 || baseMask.height <= 0 ) { + cameraContainer.scale.set(1); + cameraContainer.position.set(0, 0); + resetMotionEffects(blurFilter, motionBlurFilter, motionBlurState); return { scale: 1, x: 0, y: 0 }; } @@ -225,17 +248,7 @@ export function applyZoomTransform({ } } } else { - if (motionBlurFilter) { - motionBlurFilter.velocity = { x: 0, y: 0 }; - motionBlurFilter.kernelSize = 5; - motionBlurFilter.offset = 0; - } - if (blurFilter) { - blurFilter.blur = 0; - } - if (motionBlurState) { - motionBlurState.initialized = false; - } + resetMotionEffects(blurFilter, motionBlurFilter, motionBlurState); } return { diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 398d71bb..597198c6 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -38,7 +38,11 @@ function stopRecording( const recorderState = recorder.state; if (recorderState === "recording" || recorderState === "paused") { if (recorderState === "paused") { - recorder.resume(); + try { + recorder.resume(); + } catch { + // Stopping a paused recorder is still valid; mirror the hook's fallback path. + } } if (webcamRecorder && webcamRecorder.state !== "inactive") { webcamRecorder.stop(); @@ -194,6 +198,19 @@ describe("useScreenRecorder state machine", () => { expect(callOrder).toEqual(["resume", "stop"]); }); + it("still stops when resume throws from paused state", () => { + recorder.pause(); + recorder.resume.mockImplementation(() => { + throw new Error("resume failed"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + it("does nothing when already inactive", () => { const inactiveRecorder = createMockMediaRecorder("inactive"); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 84ece28c..33f9cb39 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -261,7 +261,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return false; }, []); - const selectMimeType = () => { + const selectMimeType = useCallback(() => { const preferred = [ "video/webm;codecs=av1", "video/webm;codecs=h264", @@ -271,7 +271,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ]; return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm"; - }; + }, []); const computeBitrate = (width: number, height: number) => { const pixels = width * height; @@ -537,7 +537,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamStream.current = null; } } - }, [getRecordingDurationMs, webcamDeviceId, webcamEnabled]); + }, [getRecordingDurationMs, selectMimeType, webcamDeviceId, webcamEnabled]); /** Start the prepared webcam MediaRecorder. Call after main recording begins. */ const beginWebcamCapture = useCallback(() => { @@ -622,8 +622,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const recorderState = recorder?.state; if (recorder && (recorderState === "recording" || recorderState === "paused")) { if (recorderState === "paused") { - markRecordingResumed(Date.now()); - recorder.resume(); + try { + recorder.resume(); + markRecordingResumed(Date.now()); + } catch (error) { + console.warn("Failed to resume recorder before stopping:", error); + } } pendingWebcamPathPromise.current = stopWebcamRecorder(); cleanupCapturedMedia(); diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts index 3a1ebd81..6f7862ff 100644 --- a/src/hooks/useVideoDevices.ts +++ b/src/hooks/useVideoDevices.ts @@ -20,13 +20,17 @@ export function useVideoDevices(enabled: boolean = true) { } let mounted = true; + let activeLoadId = 0; const loadDevices = async () => { + const loadId = ++activeLoadId; let permissionStream: MediaStream | null = null; try { - setIsLoading(true); - setError(null); + if (mounted && loadId === activeLoadId) { + setIsLoading(true); + setError(null); + } let allDevices = await navigator.mediaDevices.enumerateDevices(); let videoInputs = allDevices @@ -41,7 +45,6 @@ export function useVideoDevices(enabled: boolean = true) { videoInputs.length > 0 && videoInputs.every((device) => !device.label.trim()); if (needsLabelPermission && !hasRequestedVideoLabels) { - hasRequestedVideoLabels = true; permissionStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false, @@ -54,9 +57,10 @@ export function useVideoDevices(enabled: boolean = true) { label: device.label || `Camera ${index + 1}`, groupId: device.groupId, })); + hasRequestedVideoLabels = true; } - if (mounted) { + if (mounted && loadId === activeLoadId) { setDevices(videoInputs); setSelectedDeviceId((currentDeviceId) => { if (currentDeviceId === "default" && videoInputs.length > 0) { @@ -72,20 +76,21 @@ export function useVideoDevices(enabled: boolean = true) { return videoInputs[0]?.deviceId ?? "default"; }); - setIsLoading(false); } } catch (error) { - if (mounted) { + if (mounted && loadId === activeLoadId) { const message = error instanceof Error ? error.message : "Failed to enumerate video devices"; setError(message); - setIsLoading(false); console.error("Error loading video devices:", error); } } finally { permissionStream?.getTracks().forEach((track) => track.stop()); + if (mounted && loadId === activeLoadId) { + setIsLoading(false); + } } };