From 74ad7b31072607c18131ce182b5f5d5c9cb1b28d Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:09:24 +1000 Subject: [PATCH] Keep export and machine preferences out of project data --- src/components/video-editor/VideoEditor.tsx | 6 -- .../video-editor/editorPreferences.test.ts | 76 ++++++++++++++- .../video-editor/editorPreferences.ts | 60 +----------- .../video-editor/export/useExportSettings.ts | 39 +++++++- .../video-editor/exportPreferences.ts | 84 ++++++++++++++++ .../useEditorPreferencesPersistence.ts | 21 ---- .../presets/useVideoEditorPresets.ts | 51 +--------- .../project/useEditorProjectController.ts | 5 - .../project/useProjectLifecycle.ts | 13 +-- .../project/useProjectSnapshotModel.ts | 21 ---- .../video-editor/projectDirtyState.test.ts | 16 +++ .../video-editor/projectDirtyState.ts | 6 +- .../video-editor/projectPersistence.test.ts | 25 +++++ .../video-editor/projectPersistence.ts | 97 +++++-------------- 14 files changed, 266 insertions(+), 254 deletions(-) create mode 100644 src/components/video-editor/exportPreferences.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 20762b0b..b332ec20 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -162,13 +162,8 @@ export default function VideoEditor() { t, appearance, timeline, - exportSettings, aspectRatio, setAspectRatio, - whisperExecutablePath, - setWhisperExecutablePath, - whisperModelPath, - setWhisperModelPath, }); const { refreshProjectLibrary, captureProjectThumbnail } = useProjectLibraryController({ project, @@ -223,7 +218,6 @@ export default function VideoEditor() { project, appearance, timeline, - exportSettings, initialPreferences: initialEditorPreferences, smokeConfig: smokeExportConfig, devConfig: devOpenRecordingConfig, diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 1a88a793..bcb3584b 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -502,15 +502,81 @@ describe("editorPreferences", () => { }); }); - describe("source-specific webcam metadata", () => { it("does not copy imported source ranges into a reusable style preset", () => { vi.stubGlobal("localStorage", createStorageMock()); - saveEditorPresets([{ id: "webcam", name: "Webcam", createdAt: "2026-09-20T00:00:00Z", updatedAt: "2026-09-20T00:00:00Z", snapshot: { - ...DEFAULT_EDITOR_PREFERENCES, cropRegion: DEFAULT_CROP_REGION, autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, - webcam: { ...DEFAULT_EDITOR_PREFERENCES.webcam, sourcePath: "/imported-webcam.mp4", visibleRanges: [{ startMs: 1000, endMs: 2000 }] }, - } as never }]); + saveEditorPresets([ + { + id: "webcam", + name: "Webcam", + createdAt: "2026-09-20T00:00:00Z", + updatedAt: "2026-09-20T00:00:00Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + webcam: { + ...DEFAULT_EDITOR_PREFERENCES.webcam, + sourcePath: "/imported-webcam.mp4", + visibleRanges: [{ startMs: 1000, endMs: 2000 }], + }, + } as never, + }, + ]); expect(loadEditorPresets()[0].snapshot.webcam).not.toHaveProperty("visibleRanges"); expect(loadEditorPresets()[0].snapshot.webcam).not.toHaveProperty("sourcePath"); }); }); + +describe("user preferences stay independent from projects and presets", () => { + afterEach(() => vi.unstubAllGlobals()); + it("defaults Publish to Link and persists Local and export settings across reloads", () => { + vi.stubGlobal("localStorage", createStorageMock()); + expect(loadEditorPreferences().publishDestination).toBe("link"); + saveEditorPreferences({ + publishDestination: "local", + exportQuality: "high", + mp4FrameRate: 60, + includeCaptionSidecar: true, + }); + expect(loadEditorPreferences()).toMatchObject({ + publishDestination: "local", + exportQuality: "high", + mp4FrameRate: 60, + includeCaptionSidecar: true, + }); + saveEditorPreferences({ wallpaper: "#123456" }); + expect(loadEditorPreferences().publishDestination).toBe("local"); + expect(normalizeEditorPreferences({ publishDestination: null }).publishDestination).toBe( + "link", + ); + }); + it("drops export settings and machine paths from legacy presets", () => { + vi.stubGlobal("localStorage", createStorageMock()); + const snapshot = { + ...DEFAULT_EDITOR_PREFERENCES, + cropRegion: DEFAULT_CROP_REGION, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + whisperModelPath: "/machine/model.bin", + }; + saveEditorPresets([ + { + id: "legacy", + name: "Legacy", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + snapshot, + }, + ]); + const loaded = loadEditorPresets()[0].snapshot; + expect(loaded.wallpaper).toBe(snapshot.wallpaper); + for (const key of [ + "exportQuality", + "mp4FrameRate", + "publishDestination", + "whisperExecutablePath", + "whisperModelPath", + ]) + expect(loaded).not.toHaveProperty(key); + }); +}); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index f696f0a9..61ade506 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -1,10 +1,8 @@ +import { normalizeExportPreferences, type ExportPreferences } from "./exportPreferences"; import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; import { getDefaultBorderRadiusPercent, legacyBorderRadiusPixelsToPercent, - normalizeExportBackendPreference, - normalizeExportMp4FrameRate, - normalizeExportPipelineModel, normalizeProjectEditor, type ProjectEditorState, stripPersistedDevMotionBlurSettings, @@ -50,15 +48,6 @@ type PersistedEditorControls = Pick< | "padding" | "webcam" | "aspectRatio" - | "exportEncodingMode" - | "exportBackendPreference" - | "exportPipelineModel" - | "exportQuality" - | "mp4FrameRate" - | "exportFormat" - | "gifFrameRate" - | "gifLoop" - | "gifSizePreset" >; type PartialEditorControls = Partial; @@ -72,8 +61,6 @@ export interface EditorPresetSnapshot extends Omit(preferences.exportQuality); const [exportEncodingMode, setExportEncodingMode] = useState( preferences.exportEncodingMode, @@ -38,6 +41,34 @@ export function useExportSettings( const [gifFrameRate, setGifFrameRate] = useState(preferences.gifFrameRate); const [gifLoop, setGifLoop] = useState(preferences.gifLoop); const [gifSizePreset, setGifSizePreset] = useState(preferences.gifSizePreset); + useEffect(() => { + saveEditorPreferences({ + publishDestination, + includeCaptionSidecar, + exportQuality, + exportEncodingMode, + exportBackendPreference, + exportPipelineModel, + mp4FrameRate, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }); + }, [ + publishDestination, + includeCaptionSidecar, + exportQuality, + exportEncodingMode, + exportBackendPreference, + exportPipelineModel, + mp4FrameRate, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + ]); + const captionSidecarCues = useMemo( () => projectCaptionCues(autoCaptions, clips) @@ -54,6 +85,8 @@ export function useExportSettings( ); return { + publishDestination, + setPublishDestination, includeCaptionSidecar, setIncludeCaptionSidecar, exportQuality, diff --git a/src/components/video-editor/exportPreferences.ts b/src/components/video-editor/exportPreferences.ts new file mode 100644 index 00000000..54a65c7f --- /dev/null +++ b/src/components/video-editor/exportPreferences.ts @@ -0,0 +1,84 @@ +import type { + ExportBackendPreference, + ExportEncodingMode, + ExportFormat, + ExportMp4FrameRate, + ExportPipelineModel, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; +import { isValidMp4FrameRate } from "@/lib/exporter/types"; + +export interface ExportPreferences { + exportEncodingMode: ExportEncodingMode; + exportBackendPreference: ExportBackendPreference; + exportPipelineModel: ExportPipelineModel; + exportQuality: ExportQuality; + mp4FrameRate: ExportMp4FrameRate; + exportFormat: ExportFormat; + gifFrameRate: GifFrameRate; + gifLoop: boolean; + gifSizePreset: GifSizePreset; + publishDestination: "link" | "local"; + includeCaptionSidecar: boolean; +} + +export function normalizeExportEncodingMode(value: unknown): ExportEncodingMode { + if (value === "fast" || value === "balanced" || value === "quality") { + return value; + } + + return "balanced"; +} + +export function normalizeExportBackendPreference(value: unknown): ExportBackendPreference { + if (value === "auto" || value === "webcodecs" || value === "breeze") { + return value; + } + + return "auto"; +} + +export function normalizeExportPipelineModel(_value: unknown): ExportPipelineModel { + // Legacy remains available to internal smoke/export routing, but persisted + // user selections migrate to the only pipeline exposed by the editor UI. + return "modern"; +} + +export function normalizeExportMp4FrameRate(value: unknown): ExportMp4FrameRate { + return typeof value === "number" && isValidMp4FrameRate(value) ? value : 30; +} + +export function normalizeExportPreferences(raw: Partial): ExportPreferences { + return { + exportEncodingMode: normalizeExportEncodingMode(raw.exportEncodingMode), + exportBackendPreference: normalizeExportBackendPreference(raw.exportBackendPreference), + exportPipelineModel: normalizeExportPipelineModel(raw.exportPipelineModel), + exportQuality: + raw.exportQuality === "medium" || + raw.exportQuality === "good" || + raw.exportQuality === "high" || + raw.exportQuality === "source" + ? raw.exportQuality + : "source", + mp4FrameRate: normalizeExportMp4FrameRate(raw.mp4FrameRate), + exportFormat: raw.exportFormat === "gif" ? "gif" : "mp4", + gifFrameRate: + raw.gifFrameRate === 15 || + raw.gifFrameRate === 20 || + raw.gifFrameRate === 25 || + raw.gifFrameRate === 30 + ? raw.gifFrameRate + : 15, + gifLoop: typeof raw.gifLoop === "boolean" ? raw.gifLoop : true, + gifSizePreset: + raw.gifSizePreset === "medium" || + raw.gifSizePreset === "large" || + raw.gifSizePreset === "original" + ? raw.gifSizePreset + : "medium", + publishDestination: raw.publishDestination === "local" ? "local" : "link", + includeCaptionSidecar: raw.includeCaptionSidecar === true, + }; +} diff --git a/src/components/video-editor/presets/useEditorPreferencesPersistence.ts b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts index 7691af80..937ac816 100644 --- a/src/components/video-editor/presets/useEditorPreferencesPersistence.ts +++ b/src/components/video-editor/presets/useEditorPreferencesPersistence.ts @@ -1,12 +1,10 @@ import { useEffect } from "react"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { saveEditorPreferences } from "../editorPreferences"; -import type { useExportSettings } from "../export/useExportSettings"; import type { useAppearanceState } from "../state/useAppearanceState"; type Input = { appearance: ReturnType; - exportSettings: ReturnType; aspectRatio: AspectRatio; whisperExecutablePath: string | null; whisperModelPath: string | null; @@ -14,7 +12,6 @@ type Input = { export function useEditorPreferencesPersistence({ appearance, - exportSettings, aspectRatio, whisperExecutablePath, whisperModelPath, @@ -60,15 +57,6 @@ export function useEditorPreferencesPersistence({ padding: appearance.padding, webcam: appearance.webcam, aspectRatio, - exportEncodingMode: exportSettings.exportEncodingMode, - exportBackendPreference: exportSettings.exportBackendPreference, - exportPipelineModel: exportSettings.exportPipelineModel, - exportQuality: exportSettings.exportQuality, - mp4FrameRate: exportSettings.mp4FrameRate, - exportFormat: exportSettings.exportFormat, - gifFrameRate: exportSettings.gifFrameRate, - gifLoop: exportSettings.gifLoop, - gifSizePreset: exportSettings.gifSizePreset, whisperExecutablePath, whisperModelPath, }); @@ -112,15 +100,6 @@ export function useEditorPreferencesPersistence({ appearance.padding, appearance.webcam, aspectRatio, - exportSettings.exportEncodingMode, - exportSettings.exportBackendPreference, - exportSettings.exportPipelineModel, - exportSettings.exportQuality, - exportSettings.mp4FrameRate, - exportSettings.exportFormat, - exportSettings.gifFrameRate, - exportSettings.gifLoop, - exportSettings.gifSizePreset, whisperExecutablePath, whisperModelPath, ]); diff --git a/src/components/video-editor/presets/useVideoEditorPresets.ts b/src/components/video-editor/presets/useVideoEditorPresets.ts index 5a33377a..8bdce57b 100644 --- a/src/components/video-editor/presets/useVideoEditorPresets.ts +++ b/src/components/video-editor/presets/useVideoEditorPresets.ts @@ -1,7 +1,6 @@ import { type Dispatch, type SetStateAction, useCallback, useMemo, useState } from "react"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { type EditorPresetSnapshot, loadEditorPresets } from "../editorPreferences"; -import type { useExportSettings } from "../export/useExportSettings"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; import { useEditorPresets } from "./useEditorPresets"; @@ -10,26 +9,16 @@ type Input = { t: Parameters[0]["t"]; appearance: ReturnType; timeline: ReturnType; - exportSettings: ReturnType; aspectRatio: AspectRatio; setAspectRatio: Dispatch>; - whisperExecutablePath: string | null; - setWhisperExecutablePath: Dispatch>; - whisperModelPath: string | null; - setWhisperModelPath: Dispatch>; }; export function useVideoEditorPresets({ t, appearance, timeline, - exportSettings, aspectRatio, setAspectRatio, - whisperExecutablePath, - setWhisperExecutablePath, - whisperModelPath, - setWhisperModelPath, }: Input) { const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets()); const [activeEditorPresetId, setActiveEditorPresetId] = useState(null); @@ -79,27 +68,9 @@ export function useVideoEditorPresets({ webcam: (({ sourcePath: _sourcePath, visibleRanges: _visibleRanges, ...settings }) => settings)(appearance.webcam), aspectRatio, - exportEncodingMode: exportSettings.exportEncodingMode, - exportBackendPreference: exportSettings.exportBackendPreference, - exportPipelineModel: exportSettings.exportPipelineModel, - exportQuality: exportSettings.exportQuality, - mp4FrameRate: exportSettings.mp4FrameRate, - exportFormat: exportSettings.exportFormat, - gifFrameRate: exportSettings.gifFrameRate, - gifLoop: exportSettings.gifLoop, - gifSizePreset: exportSettings.gifSizePreset, autoCaptionSettings: { ...timeline.autoCaptionSettings }, - whisperExecutablePath, - whisperModelPath, }), - [ - appearance, - timeline.autoCaptionSettings, - exportSettings, - aspectRatio, - whisperExecutablePath, - whisperModelPath, - ], + [appearance, timeline.autoCaptionSettings, aspectRatio], ); const applySnapshot = useCallback( @@ -147,27 +118,9 @@ export function useVideoEditorPresets({ visibleRanges: current.visibleRanges, })); setAspectRatio(snapshot.aspectRatio); - exportSettings.setExportEncodingMode(snapshot.exportEncodingMode); - exportSettings.setExportBackendPreference(snapshot.exportBackendPreference); - exportSettings.setExportPipelineModel(snapshot.exportPipelineModel); - exportSettings.setExportQuality(snapshot.exportQuality); - exportSettings.setMp4FrameRate(snapshot.mp4FrameRate); - exportSettings.setExportFormat(snapshot.exportFormat); - exportSettings.setGifFrameRate(snapshot.gifFrameRate); - exportSettings.setGifLoop(snapshot.gifLoop); - exportSettings.setGifSizePreset(snapshot.gifSizePreset); timeline.setAutoCaptionSettings({ ...snapshot.autoCaptionSettings }); - setWhisperExecutablePath(snapshot.whisperExecutablePath); - setWhisperModelPath(snapshot.whisperModelPath); }, - [ - appearance, - exportSettings, - timeline, - setAspectRatio, - setWhisperExecutablePath, - setWhisperModelPath, - ], + [appearance, timeline, setAspectRatio], ); const actions = useEditorPresets({ diff --git a/src/components/video-editor/project/useEditorProjectController.ts b/src/components/video-editor/project/useEditorProjectController.ts index a57610f4..879cfc54 100644 --- a/src/components/video-editor/project/useEditorProjectController.ts +++ b/src/components/video-editor/project/useEditorProjectController.ts @@ -4,7 +4,6 @@ import type { useI18n } from "@/contexts/I18nContext"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { useAutoCaptionController } from "../captions/useAutoCaptionController"; import { loadEditorPreferences } from "../editorPreferences"; -import type { useExportSettings } from "../export/useExportSettings"; import { useEditorHistory } from "../hooks/useEditorHistory"; import { useEditorPreferencesPersistence } from "../presets/useEditorPreferencesPersistence"; import { hasUnsavedProjectChanges } from "../projectDirtyState"; @@ -25,7 +24,6 @@ type Input = { project: ReturnType; appearance: ReturnType; timeline: ReturnType; - exportSettings: ReturnType; initialPreferences: ReturnType; smokeConfig: ReturnType; devConfig: ReturnType; @@ -85,7 +83,6 @@ export function useEditorProjectController(input: Input) { project: input.project, appearance: input.appearance, timeline: input.timeline, - exportSettings: input.exportSettings, aspectRatio: input.aspectRatio, projectNameInputRef: input.projectNameInputRef, projectSaveDialogInputRef: input.projectSaveDialogInputRef, @@ -103,7 +100,6 @@ export function useEditorProjectController(input: Input) { project: input.project, appearance: input.appearance, timeline: input.timeline, - exportSettings: input.exportSettings, aspectRatio: input.aspectRatio, setAspectRatio: input.setAspectRatio, currentSourcePath: snapshot.currentSourcePath, @@ -154,7 +150,6 @@ export function useEditorProjectController(input: Input) { }); useEditorPreferencesPersistence({ appearance: input.appearance, - exportSettings: input.exportSettings, aspectRatio: input.aspectRatio, whisperExecutablePath: input.whisperExecutablePath, whisperModelPath: input.whisperModelPath, diff --git a/src/components/video-editor/project/useProjectLifecycle.ts b/src/components/video-editor/project/useProjectLifecycle.ts index 8b221595..8cb648f2 100644 --- a/src/components/video-editor/project/useProjectLifecycle.ts +++ b/src/components/video-editor/project/useProjectLifecycle.ts @@ -10,7 +10,6 @@ import { } from "react"; import { toast } from "@/components/ui/toast"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; -import type { useExportSettings } from "../export/useExportSettings"; import type { UnsavedChangesDecision } from "../layout/EditorDialogs"; import { createProjectData, @@ -35,7 +34,6 @@ type Input = { project: ReturnType; appearance: ReturnType; timeline: ReturnType; - exportSettings: ReturnType; aspectRatio: AspectRatio; setAspectRatio: Dispatch>; currentSourcePath: string | null; @@ -81,7 +79,7 @@ export function useProjectLifecycle(input: Input) { const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { const current = inputRef.current; - const { project, appearance, timeline, exportSettings, refs } = current; + const { project, appearance, timeline, refs } = current; if (!validateProjectData(candidate)) return false; const loadedProject = candidate; const sourcePath = fromFileUrl(loadedProject.videoPath); @@ -185,15 +183,6 @@ export function useProjectLifecycle(input: Input) { timeline.setAutoCaptions(editor.autoCaptions); timeline.setAutoCaptionSettings(editor.autoCaptionSettings); current.setAspectRatio(editor.aspectRatio); - exportSettings.setExportEncodingMode(editor.exportEncodingMode); - exportSettings.setExportBackendPreference(editor.exportBackendPreference); - exportSettings.setExportPipelineModel(editor.exportPipelineModel); - exportSettings.setExportQuality(editor.exportQuality); - exportSettings.setMp4FrameRate(editor.mp4FrameRate); - exportSettings.setExportFormat(editor.exportFormat); - exportSettings.setGifFrameRate(editor.gifFrameRate); - exportSettings.setGifLoop(editor.gifLoop); - exportSettings.setGifSizePreset(editor.gifSizePreset); timeline.setSelectedZoomId(null); timeline.setSelectedClipId(null); timeline.setSelectedAnnotationId(null); diff --git a/src/components/video-editor/project/useProjectSnapshotModel.ts b/src/components/video-editor/project/useProjectSnapshotModel.ts index c01ef86e..1d498a47 100644 --- a/src/components/video-editor/project/useProjectSnapshotModel.ts +++ b/src/components/video-editor/project/useProjectSnapshotModel.ts @@ -1,6 +1,5 @@ import { type RefObject, useCallback, useEffect, useMemo } from "react"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; -import type { useExportSettings } from "../export/useExportSettings"; import { fromFileUrl, type ProjectEditorState, @@ -15,7 +14,6 @@ type Input = { project: ReturnType; appearance: ReturnType; timeline: ReturnType; - exportSettings: ReturnType; aspectRatio: AspectRatio; projectNameInputRef: RefObject; projectSaveDialogInputRef: RefObject; @@ -26,7 +24,6 @@ export function useProjectSnapshotModel({ project, appearance, timeline, - exportSettings, aspectRatio, projectNameInputRef, projectSaveDialogInputRef, @@ -120,15 +117,6 @@ export function useProjectSnapshotModel({ autoCaptions: timeline.autoCaptions, autoCaptionSettings: timeline.autoCaptionSettings, aspectRatio, - exportEncodingMode: exportSettings.exportEncodingMode, - exportBackendPreference: exportSettings.exportBackendPreference, - exportPipelineModel: exportSettings.exportPipelineModel, - exportQuality: exportSettings.exportQuality, - mp4FrameRate: exportSettings.mp4FrameRate, - exportFormat: exportSettings.exportFormat, - gifFrameRate: exportSettings.gifFrameRate, - gifLoop: exportSettings.gifLoop, - gifSizePreset: exportSettings.gifSizePreset, sourceAudioTrackSettingsByClip: timeline.sourceAudioTrackSettingsByClip, defaultSourceAudioTrackSettings: timeline.defaultSourceAudioTrackSettings, }), @@ -182,15 +170,6 @@ export function useProjectSnapshotModel({ timeline.autoCaptions, timeline.autoCaptionSettings, aspectRatio, - exportSettings.exportEncodingMode, - exportSettings.exportBackendPreference, - exportSettings.exportPipelineModel, - exportSettings.exportQuality, - exportSettings.mp4FrameRate, - exportSettings.exportFormat, - exportSettings.gifFrameRate, - exportSettings.gifLoop, - exportSettings.gifSizePreset, timeline.sourceAudioTrackSettingsByClip, timeline.defaultSourceAudioTrackSettings, buildPersistedEditorState, diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts index 68c5650f..27889673 100644 --- a/src/components/video-editor/projectDirtyState.test.ts +++ b/src/components/video-editor/projectDirtyState.test.ts @@ -119,3 +119,19 @@ describe("hasUnsavedProjectChanges", () => { expect(hasUnsavedProjectChanges(current, saved)).toBe(true); }); }); + +it("does not mark legacy export preferences as project edits", () => { + const original = createProjectData(); + const legacy = { + ...original, + editor: { ...original.editor, exportQuality: "high", mp4FrameRate: 60 }, + }; + expect(hasUnsavedProjectChanges(original, legacy)).toBe(false); + expect(hasUnsavedProjectChanges(legacy, original)).toBe(false); + expect( + hasUnsavedProjectChanges( + { ...legacy, editor: { ...legacy.editor, wallpaper: "#000000" } }, + original, + ), + ).toBe(true); +}); diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts index 16858f12..a2ba9dbf 100644 --- a/src/components/video-editor/projectDirtyState.ts +++ b/src/components/video-editor/projectDirtyState.ts @@ -1,4 +1,4 @@ -import type { EditorProjectData } from "./projectPersistence"; +import { stripProjectUserPreferences, type EditorProjectData } from "./projectPersistence"; function isComparableObject(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -47,10 +47,10 @@ function omitTransientWebcamMediaFields(project: EditorProjectData | null) { return project; } - const editor = project.editor as Record; + const editor = stripProjectUserPreferences(project.editor) as Record; const webcam = editor.webcam; if (!isComparableObject(webcam)) { - return project; + return { ...project, editor }; } const { diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index ac963884..ea645446 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -267,3 +267,28 @@ it("discards inverted saved source bounds while preserving the in-point", () => expect(editor.clipRegions[0].sourceMinMs).toBeUndefined(); expect(editor.clipRegions[0].sourceMaxMs).toBeUndefined(); }); + +it("ignores legacy export preferences when opening and resaving a project", async () => { + const { createProjectData } = await import("./projectPersistence"); + const legacyEditor = { + wallpaper: "#112233", + exportQuality: "high", + exportFormat: "gif", + mp4FrameRate: 60, + publishDestination: "local", + whisperModelPath: "/machine/model.bin", + }; + const loaded = normalizeProjectEditor(legacyEditor); + expect(loaded.wallpaper).toBe("#112233"); + for (const key of [ + "exportQuality", + "exportFormat", + "mp4FrameRate", + "publishDestination", + "whisperModelPath", + ]) { + expect(loaded).not.toHaveProperty(key); + expect(createProjectData("/clip.mp4", legacyEditor).editor).not.toHaveProperty(key); + } + expect(legacyEditor.exportQuality).toBe("high"); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 0b569074..ebe6a8d6 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,16 +1,5 @@ import { getLocalMediaServerPath } from "@/lib/localMediaUrl"; import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; -import type { - ExportBackendPreference, - ExportEncodingMode, - ExportFormat, - ExportMp4FrameRate, - ExportPipelineModel, - ExportQuality, - GifFrameRate, - GifSizePreset, -} from "@/lib/exporter"; -import { isValidMp4FrameRate } from "@/lib/exporter/types"; import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { closeClipGaps, rippleRegionAnchors, rippleRegions } from "./clipSequence"; @@ -146,15 +135,6 @@ export interface ProjectEditorState { aspectRatio: AspectRatio; sourceAudioTrackSettingsByClip?: Record; defaultSourceAudioTrackSettings?: SourceAudioTrackSettings; - exportEncodingMode: ExportEncodingMode; - exportBackendPreference: ExportBackendPreference; - exportPipelineModel: ExportPipelineModel; - exportQuality: ExportQuality; - mp4FrameRate: ExportMp4FrameRate; - exportFormat: ExportFormat; - gifFrameRate: GifFrameRate; - gifLoop: boolean; - gifSizePreset: GifSizePreset; } export interface EditorProjectData { @@ -184,32 +164,6 @@ export function stripPersistedDevMotionBlurSettings): Pro isCustomAspectRatio(editor.aspectRatio)) ? (editor.aspectRatio as AspectRatio) : "16:9", - exportEncodingMode: normalizeExportEncodingMode(editor.exportEncodingMode), - exportBackendPreference: normalizeExportBackendPreference(editor.exportBackendPreference), - exportPipelineModel: normalizeExportPipelineModel(editor.exportPipelineModel), - exportQuality: - editor.exportQuality === "medium" || - editor.exportQuality === "good" || - editor.exportQuality === "high" || - editor.exportQuality === "source" - ? editor.exportQuality - : "source", - mp4FrameRate: normalizeExportMp4FrameRate(editor.mp4FrameRate), - exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", - gifFrameRate: - editor.gifFrameRate === 15 || - editor.gifFrameRate === 20 || - editor.gifFrameRate === 25 || - editor.gifFrameRate === 30 - ? editor.gifFrameRate - : 15, - gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, - gifSizePreset: - editor.gifSizePreset === "medium" || - editor.gifSizePreset === "large" || - editor.gifSizePreset === "original" - ? editor.gifSizePreset - : "medium", }; } @@ -1132,6 +1060,29 @@ export function createProjectData( version: PROJECT_VERSION, ...(typeof projectId === "string" && projectId.trim().length > 0 ? { projectId } : {}), videoPath, - editor, + editor: stripProjectUserPreferences(editor), }; } + +// Older project files may contain app preferences. Never write or compare them as edits. +export function stripProjectUserPreferences(editor: T): T { + const result = { ...editor }; + for (const key of [ + "exportEncodingMode", + "exportBackendPreference", + "exportPipelineModel", + "exportQuality", + "mp4FrameRate", + "exportFormat", + "gifFrameRate", + "gifLoop", + "gifSizePreset", + "publishDestination", + "includeCaptionSidecar", + "whisperExecutablePath", + "whisperModelPath", + ]) { + delete (result as Record)[key]; + } + return result; +}