Merge pull request #545 from webadderallorg/refactor/editor-project-dirty-state-local

refactor(editor): extract project dirty state
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-20 23:58:04 +07:00
committed by GitHub
3 changed files with 245 additions and 166 deletions
+125 -166
View File
@@ -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" }) => (
<Cursor weight="fill" className={props.className} />
@@ -111,11 +108,21 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" })
<Gear weight={props.weight ?? "regular"} className={props.className} />
);
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<T>(value: T): T {
return globalThis.structuredClone(value);
}
function isComparableObject(value: unknown): value is Record<string, unknown> {
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<string, SourceAudioTrackSettings>
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState<
SourceAudioTrackSettings
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] =
useState<SourceAudioTrackSettings>({});
const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false);
const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]);
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(
@@ -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);
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;
},
[],
);
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,
),
)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { hasUnsavedProjectChanges } from "./projectDirtyState";
import type { EditorProjectData } from "./projectPersistence";
function createProjectData(overrides: Partial<EditorProjectData> = {}): 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);
});
});
@@ -0,0 +1,53 @@
import type { EditorProjectData } from "./projectPersistence";
function isComparableObject(value: unknown): value is Record<string, unknown> {
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)),
);
}