diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 549aa37..c70cc5b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -32,6 +32,8 @@ import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, + createProjectSnapshot, + hasProjectUnsavedChanges, deriveNextId, fromFileUrl, normalizeProjectEditor, @@ -239,13 +241,11 @@ export default function VideoEditor() { ) + 1; setLastSavedSnapshot( - JSON.stringify( - createProjectData( - webcamSourcePath - ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } - : { screenVideoPath: sourcePath }, - normalizedEditor, - ), + createProjectSnapshot( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + normalizedEditor, ), ); return true; @@ -257,8 +257,7 @@ export default function VideoEditor() { if (!currentProjectMedia) { return null; } - return JSON.stringify( - createProjectData(currentProjectMedia, { + return createProjectSnapshot(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -279,8 +278,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, - }), - ); + }); }, [ currentProjectMedia, wallpaper, @@ -305,11 +303,9 @@ export default function VideoEditor() { gifSizePreset, ]); - const hasUnsavedChanges = Boolean( - currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - currentProjectSnapshot !== lastSavedSnapshot, + const hasUnsavedChanges = hasProjectUnsavedChanges( + currentProjectSnapshot, + lastSavedSnapshot, ); useEffect(() => { @@ -338,7 +334,14 @@ export default function VideoEditor() { setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(null); - setLastSavedSnapshot(null); + setLastSavedSnapshot( + createProjectSnapshot( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + INITIAL_EDITOR_STATE, + ), + ); return; } @@ -350,7 +353,9 @@ export default function VideoEditor() { setWebcamVideoSourcePath(null); setWebcamVideoPath(null); setCurrentProjectPath(null); - setLastSavedSnapshot(null); + setLastSavedSnapshot( + createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE), + ); } else { setError("No video to load. Please record or select a video."); } diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 3243aca..82e084f 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { createProjectData, + createProjectSnapshot, + hasProjectUnsavedChanges, normalizeProjectEditor, PROJECT_VERSION, resolveProjectMedia, @@ -65,3 +67,40 @@ describe("projectPersistence media compatibility", () => { ).toBe("rectangle"); }); }); + + +it("creates stable snapshots for identical project state", () => { + const media = { + screenVideoPath: "/tmp/screen.webm", + webcamVideoPath: "/tmp/webcam.webm", + }; + const editor = normalizeProjectEditor({ + wallpaper: "/wallpapers/wallpaper1.jpg", + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, + padding: 50, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + zoomRegions: [], + trimRegions: [], + speedRegions: [], + annotationRegions: [], + aspectRatio: "16:9", + webcamLayoutPreset: "picture-in-picture", + webcamMaskShape: "circle", + exportQuality: "good", + exportFormat: "mp4", + gifFrameRate: 15, + gifLoop: true, + gifSizePreset: "medium", + }); + + expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor)); +}); + +it("detects unsaved changes from differing snapshots", () => { + expect(hasProjectUnsavedChanges(null, null)).toBe(false); + expect(hasProjectUnsavedChanges("same", "same")).toBe(false); + expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index d7111b1..246de62 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -405,3 +405,21 @@ export function createProjectData( editor, }; } + +export function createProjectSnapshot( + media: ProjectMedia, + editor: ProjectEditorState, +): string { + return JSON.stringify(createProjectData(media, editor)); +} + +export function hasProjectUnsavedChanges( + currentSnapshot: string | null, + baselineSnapshot: string | null, +): boolean { + return Boolean( + currentSnapshot !== null && + baselineSnapshot !== null && + currentSnapshot !== baselineSnapshot, + ); +}