diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0c12d289..86080a29 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -87,10 +87,7 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExtensionIcon } from "./ExtensionIcon"; -import { - calculateMp4ExportDimensions, - calculateMp4SourceDimensions, -} from "./exportDimensions"; +import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -111,11 +108,21 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) ); +import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { extensionHost } from "@/lib/extensions"; +import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; +import { + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "./editorHistory"; import { type EditorPreset, type EditorPresetSnapshot, @@ -125,15 +132,8 @@ import { saveEditorPresets, serializeEditorPresetSnapshot, } from "./editorPreferences"; -import { - createEditorHistoryStack, - type EditorHistorySnapshot, - recordEditorHistorySnapshot, - redoEditorHistoryStack, - resetEditorHistoryStack, - undoEditorHistoryStack, -} from "./editorHistory"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; +import { hasUnsavedProjectChanges } from "./projectDirtyState"; import { createProjectData, deriveNextId, @@ -147,7 +147,6 @@ import { } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; -import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -157,7 +156,6 @@ import { } from "./TutorialHelp"; import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; -import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { type AnnotationRegion, type AudioRegion, @@ -334,48 +332,6 @@ function cloneStructured(value: T): T { return globalThis.structuredClone(value); } -function isComparableObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function areDeepEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) { - return true; - } - - if (Array.isArray(left) || Array.isArray(right)) { - if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { - return false; - } - - for (let index = 0; index < left.length; index += 1) { - if (!areDeepEqual(left[index], right[index])) { - return false; - } - } - - return true; - } - - if (!isComparableObject(left) || !isComparableObject(right)) { - return false; - } - - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) { - return false; - } - - for (const key of leftKeys) { - if (!(key in right) || !areDeepEqual(left[key], right[key])) { - return false; - } - } - - return true; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -535,9 +491,8 @@ export default function VideoEditor() { const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState< Record >({}); - const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState< - SourceAudioTrackSettings - >({}); + const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = + useState({}); const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false); const [autoCaptions, setAutoCaptions] = useState([]); const [autoCaptionSettings, setAutoCaptionSettings] = useState( @@ -1827,43 +1782,39 @@ export default function VideoEditor() { selectedAudioId, ]); - const applyHistorySnapshot = useCallback( - (snapshot: EditorHistorySnapshot) => { - applyingHistoryRef.current = true; - const cloned = cloneStructured(snapshot); - setZoomRegions(cloned.zoomRegions); - setClipRegions(cloned.clipRegions); - setSpeedRegions(cloned.speedRegions); - setAnnotationRegions(cloned.annotationRegions); - setAudioRegions(cloned.audioRegions); - setAutoCaptions(cloned.autoCaptions); - setSelectedZoomId(cloned.selectedZoomId); - setSelectedClipId(cloned.selectedClipId); - setSelectedAnnotationId(cloned.selectedAnnotationId); - setSelectedAudioId(cloned.selectedAudioId); - - nextZoomIdRef.current = deriveNextId( - "zoom", - cloned.zoomRegions.map((region) => region.id), - ); - nextClipIdRef.current = deriveNextId( - "clip", - cloned.clipRegions.map((region) => region.id), - ); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - cloned.annotationRegions.map((region) => region.id), - ); - nextAudioIdRef.current = deriveNextId( - "audio", - cloned.audioRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + - 1; - }, - [], - ); + const applyHistorySnapshot = useCallback((snapshot: EditorHistorySnapshot) => { + applyingHistoryRef.current = true; + const cloned = cloneStructured(snapshot); + setZoomRegions(cloned.zoomRegions); + setClipRegions(cloned.clipRegions); + setSpeedRegions(cloned.speedRegions); + setAnnotationRegions(cloned.annotationRegions); + setAudioRegions(cloned.audioRegions); + setAutoCaptions(cloned.autoCaptions); + setSelectedZoomId(cloned.selectedZoomId); + setSelectedClipId(cloned.selectedClipId); + setSelectedAnnotationId(cloned.selectedAnnotationId); + setSelectedAudioId(cloned.selectedAudioId); + + nextZoomIdRef.current = deriveNextId( + "zoom", + cloned.zoomRegions.map((region) => region.id), + ); + nextClipIdRef.current = deriveNextId( + "clip", + cloned.clipRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + cloned.annotationRegions.map((region) => region.id), + ); + nextAudioIdRef.current = deriveNextId( + "audio", + cloned.audioRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + }, []); const handleUndo = useCallback(() => { const previous = undoEditorHistoryStack(editorHistoryRef.current, buildHistorySnapshot()); @@ -1975,7 +1926,9 @@ export default function VideoEditor() { setSpeedRegions(normalizedEditor.speedRegions); setAnnotationRegions(normalizedEditor.annotationRegions); setAudioRegions(normalizedEditor.audioRegions); - setSourceAudioTrackSettingsByClip(normalizedEditor.sourceAudioTrackSettingsByClip ?? {}); + setSourceAudioTrackSettingsByClip( + normalizedEditor.sourceAudioTrackSettingsByClip ?? {}, + ); setDefaultSourceAudioTrackSettings( normalizedEditor.defaultSourceAudioTrackSettings ?? {}, ); @@ -2147,12 +2100,7 @@ export default function VideoEditor() { }, [buildHistorySnapshot, syncHistoryButtons]); const hasUnsavedChanges = useMemo( - () => - Boolean( - currentProjectSnapshot && - (!lastSavedSnapshot || - !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)), - ), + () => hasUnsavedProjectChanges(currentProjectSnapshot, lastSavedSnapshot), [currentProjectSnapshot, lastSavedSnapshot], ); @@ -2346,7 +2294,7 @@ export default function VideoEditor() { sessionVideoPath: session?.videoPath, videoSourcePath: videoSourcePath, match: session?.videoPath === videoSourcePath, - webcamPath: session?.webcamPath + webcamPath: session?.webcamPath, }); if (!session || session.videoPath !== videoSourcePath) { @@ -3241,21 +3189,27 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { - const playback = videoPlaybackRef.current; - const video = playback?.video; - if (!video) return; + const handleSeek = useCallback( + (time: number, options: { pause?: boolean } = {}) => { + const playback = videoPlaybackRef.current; + const video = playback?.video; + if (!video) return; - if (options.pause && !video.paused) { - playback?.pause(); - } + if (options.pause && !video.paused) { + playback?.pause(); + } - video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - }, [mapTimelineTimeToSourceTime]); + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; + }, + [mapTimelineTimeToSourceTime], + ); - const handleTimelineSeek = useCallback((time: number) => { - handleSeek(time, { pause: true }); - }, [handleSeek]); + const handleTimelineSeek = useCallback( + (time: number) => { + handleSeek(time, { pause: true }); + }, + [handleSeek], + ); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); @@ -3652,17 +3606,17 @@ export default function VideoEditor() { } }, []); - const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { - const id = `audio-${nextAudioIdRef.current++}`; - const newRegion: AudioRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - audioPath, - volume: 1, - normalize: false, - trackIndex, - }; + const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { + const id = `audio-${nextAudioIdRef.current++}`; + const newRegion: AudioRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + audioPath, + volume: 1, + normalize: false, + trackIndex, + }; setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); setSelectedZoomId(null); @@ -3712,29 +3666,29 @@ export default function VideoEditor() { [selectedAudioId], ); - const handleAudioDelete = useCallback( - (id: string) => { + const handleAudioDelete = useCallback( + (id: string) => { setAudioRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAudioId === id) { setSelectedAudioId(null); } }, - [selectedAudioId], - ); + [selectedAudioId], + ); - const handleAudioNormalizeChange = useCallback( - (normalize: boolean) => { - if (!selectedAudioId) { - return; - } - setAudioRegions((prev) => - prev.map((region) => - region.id === selectedAudioId ? { ...region, normalize } : region, - ), - ); - }, - [selectedAudioId], - ); + const handleAudioNormalizeChange = useCallback( + (normalize: boolean) => { + if (!selectedAudioId) { + return; + } + setAudioRegions((prev) => + prev.map((region) => + region.id === selectedAudioId ? { ...region, normalize } : region, + ), + ); + }, + [selectedAudioId], + ); const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -5632,19 +5586,20 @@ export default function VideoEditor() { selectedClipId={selectedClipId} selectedClipSpeed={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.speed ?? 1 + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.speed ?? 1) : null } selectedClipMuted={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.muted ?? - false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.muted ?? false) : null } selectedClipShowSourceAudio={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId) - ?.showSourceAudio ?? false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.showSourceAudio ?? false) : null } onClipSpeedChange={handleClipSpeedChange} @@ -5653,7 +5608,9 @@ export default function VideoEditor() { onClipDelete={handleClipDelete} hasClipSourceAudio={hasClipSourceAudio} sourceAudioTrackMeta={audio.sourceAudioTrackMeta} - sourceAudioTrackSettings={audio.selectedClipSourceAudioTrackSettings} + sourceAudioTrackSettings={ + audio.selectedClipSourceAudioTrackSettings + } onSourceAudioTrackVolumeChange={ audio.onSelectedClipSourceAudioTrackVolumeChange } @@ -5661,21 +5618,21 @@ export default function VideoEditor() { audio.onSelectedClipSourceAudioTrackNormalizeChange } selectedAudioId={selectedAudioId} - selectedAudioVolume={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.volume ?? null) - : null - } - selectedAudioNormalize={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.normalize ?? false) - : null - } - onAudioVolumeChange={handleAudioVolumeChange} - onAudioNormalizeChange={handleAudioNormalizeChange} - onAudioDelete={handleAudioDelete} + selectedAudioVolume={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.volume ?? null) + : null + } + selectedAudioNormalize={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.normalize ?? false) + : null + } + onAudioVolumeChange={handleAudioVolumeChange} + onAudioNormalizeChange={handleAudioNormalizeChange} + onAudioDelete={handleAudioDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} backgroundBlur={backgroundBlur} @@ -5972,13 +5929,15 @@ export default function VideoEditor() { } cursorSway={cursorSway} volume={ - audio.shouldMutePreviewVideo || audio.isCurrentClipMuted + audio.shouldMutePreviewVideo || + audio.isCurrentClipMuted ? 0 : Math.max( 0, Math.min( 1, - previewVolume * audio.embeddedSourcePreviewGain, + previewVolume * + audio.embeddedSourcePreviewGain, ), ) } diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts new file mode 100644 index 00000000..4cfb5257 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { hasUnsavedProjectChanges } from "./projectDirtyState"; +import type { EditorProjectData } from "./projectPersistence"; + +function createProjectData(overrides: Partial = {}): EditorProjectData { + return { + version: 1, + projectId: "project-1", + videoPath: "file:///recording.mp4", + editor: { + wallpaper: "#ffffff", + zoomRegions: [], + trimRegions: [], + clipRegions: [], + speedRegions: [], + annotationRegions: [], + audioRegions: [], + autoCaptions: [], + }, + ...overrides, + }; +} + +describe("hasUnsavedProjectChanges", () => { + it("does not report changes before a project snapshot exists", () => { + expect(hasUnsavedProjectChanges(null, createProjectData())).toBe(false); + }); + + it("reports changes when there is no saved baseline", () => { + expect(hasUnsavedProjectChanges(createProjectData(), null)).toBe(true); + }); + + it("treats deeply equal snapshots as unchanged", () => { + expect(hasUnsavedProjectChanges(createProjectData(), createProjectData())).toBe(false); + }); + + it("detects nested editor changes", () => { + const current = createProjectData({ + editor: { + ...createProjectData().editor, + zoomRegions: [ + { + id: "zoom-1", + startMs: 100, + endMs: 500, + focus: { cx: 0.4, cy: 0.6 }, + depth: 2, + }, + ], + }, + }); + + expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true); + }); + + it("detects array length changes", () => { + const current = createProjectData({ + editor: { + ...createProjectData().editor, + autoCaptions: [{ id: "caption-1", startMs: 0, endMs: 1_000, text: "hello" }], + }, + }); + + expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true); + }); +}); diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts new file mode 100644 index 00000000..78fcee84 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.ts @@ -0,0 +1,53 @@ +import type { EditorProjectData } from "./projectPersistence"; + +function isComparableObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function areDeepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (!areDeepEqual(left[index], right[index])) { + return false; + } + } + + return true; + } + + if (!isComparableObject(left) || !isComparableObject(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (!(key in right) || !areDeepEqual(left[key], right[key])) { + return false; + } + } + + return true; +} + +export function hasUnsavedProjectChanges( + currentProjectSnapshot: EditorProjectData | null, + lastSavedSnapshot: EditorProjectData | null, +): boolean { + return Boolean( + currentProjectSnapshot && + (!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)), + ); +}