Merge pull request #1080 from webadderallorg/codex/export-preferences-separation

Keep export preferences separate from project edits
This commit is contained in:
webadderall authored and GitHub committed 2026-10-03 21:32:35 +10:00
commit cfceee4643
14 files changed
+266 -254

No files matched your search

@@ -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,
@@ -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);
});
});
@@ -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<PersistedEditorControls>;
@@ -72,8 +61,6 @@ export interface EditorPresetSnapshot extends Omit<PersistedEditorControls, "web
cropRegion: PresetCropRegion;
webcam: PresetWebcamSettings;
autoCaptionSettings: PresetAutoCaptionSettings;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
}
export interface EditorPreset {
@@ -84,7 +71,7 @@ export interface EditorPreset {
snapshot: EditorPresetSnapshot;
}
export interface EditorPreferences extends PersistedEditorControls {
export interface EditorPreferences extends PersistedEditorControls, ExportPreferences {
borderRadiusUnit: "percent";
customAspectWidth: string;
customAspectHeight: string;
@@ -139,15 +126,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
padding: DEFAULT_EDITOR_CONTROLS.padding,
webcam: DEFAULT_EDITOR_CONTROLS.webcam,
aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio,
exportEncodingMode: DEFAULT_EDITOR_CONTROLS.exportEncodingMode,
exportBackendPreference: DEFAULT_EDITOR_CONTROLS.exportBackendPreference,
exportPipelineModel: DEFAULT_EDITOR_CONTROLS.exportPipelineModel,
exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality,
mp4FrameRate: DEFAULT_EDITOR_CONTROLS.mp4FrameRate,
exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat,
gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate,
gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop,
gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset,
...normalizeExportPreferences({}),
customAspectWidth: "16",
customAspectHeight: "9",
customWallpapers: [],
@@ -226,11 +205,6 @@ function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot
webcam,
cropRegion: normalizedCropRegion,
autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
whisperExecutablePath:
normalizeNullablePath(raw.whisperExecutablePath) ??
normalizedPreferences.whisperExecutablePath,
whisperModelPath:
normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath,
};
}
@@ -348,24 +322,6 @@ function normalizeEditorControls(
padding: sanitizedRaw.padding ?? fallback.padding,
webcam: sanitizedRaw.webcam ?? fallback.webcam,
aspectRatio: sanitizedRaw.aspectRatio ?? fallback.aspectRatio,
exportEncodingMode: sanitizedRaw.exportEncodingMode ?? fallback.exportEncodingMode,
exportBackendPreference:
sanitizedRaw.exportBackendPreference === undefined
? fallback.exportBackendPreference
: normalizeExportBackendPreference(sanitizedRaw.exportBackendPreference),
exportPipelineModel:
sanitizedRaw.exportPipelineModel === undefined
? fallback.exportPipelineModel
: normalizeExportPipelineModel(sanitizedRaw.exportPipelineModel),
exportQuality: sanitizedRaw.exportQuality ?? fallback.exportQuality,
mp4FrameRate:
sanitizedRaw.mp4FrameRate === undefined
? fallback.mp4FrameRate
: normalizeExportMp4FrameRate(sanitizedRaw.mp4FrameRate),
exportFormat: sanitizedRaw.exportFormat ?? fallback.exportFormat,
gifFrameRate: sanitizedRaw.gifFrameRate ?? fallback.gifFrameRate,
gifLoop: sanitizedRaw.gifLoop ?? fallback.gifLoop,
gifSizePreset: sanitizedRaw.gifSizePreset ?? fallback.gifSizePreset,
};
const normalized = normalizeProjectEditor(candidate);
@@ -409,15 +365,6 @@ function normalizeEditorControls(
padding: normalized.padding,
webcam: normalized.webcam,
aspectRatio: normalized.aspectRatio,
exportEncodingMode: normalized.exportEncodingMode,
exportBackendPreference: normalized.exportBackendPreference,
exportPipelineModel: normalized.exportPipelineModel,
exportQuality: normalized.exportQuality,
mp4FrameRate: normalized.mp4FrameRate,
exportFormat: normalized.exportFormat,
gifFrameRate: normalized.gifFrameRate,
gifLoop: normalized.gifLoop,
gifSizePreset: normalized.gifSizePreset,
};
}
@@ -440,6 +387,7 @@ export function normalizeEditorPreferences(
return {
...normalizeEditorControls(controls, fallback),
...normalizeExportPreferences({ ...fallback, ...raw }),
borderRadiusUnit: "percent",
customAspectWidth: normalizePositiveIntegerString(
raw.customAspectWidth,
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type {
ExportBackendPreference,
ExportEncodingMode,
@@ -10,7 +10,7 @@ import type {
GifSizePreset,
} from "@/lib/exporter";
import { projectCaptionCues } from "../captionTimeline";
import type { EditorPreferences } from "../editorPreferences";
import { saveEditorPreferences, type EditorPreferences } from "../editorPreferences";
import type { CaptionCue, ClipRegion } from "../types";
const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30;
@@ -20,7 +20,10 @@ export function useExportSettings(
autoCaptions: CaptionCue[],
clips: ClipRegion[],
) {
const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(false);
const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(
preferences.includeCaptionSidecar,
);
const [publishDestination, setPublishDestination] = useState(preferences.publishDestination);
const [exportQuality, setExportQuality] = useState<ExportQuality>(preferences.exportQuality);
const [exportEncodingMode, setExportEncodingMode] = useState<ExportEncodingMode>(
preferences.exportEncodingMode,
@@ -38,6 +41,34 @@ export function useExportSettings(
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(preferences.gifFrameRate);
const [gifLoop, setGifLoop] = useState(preferences.gifLoop);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(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,
@@ -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>): 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,
};
}
@@ -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<typeof useAppearanceState>;
exportSettings: ReturnType<typeof useExportSettings>;
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,
]);
@@ -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<typeof useEditorPresets>[0]["t"];
appearance: ReturnType<typeof useAppearanceState>;
timeline: ReturnType<typeof useTimelineState>;
exportSettings: ReturnType<typeof useExportSettings>;
aspectRatio: AspectRatio;
setAspectRatio: Dispatch<SetStateAction<AspectRatio>>;
whisperExecutablePath: string | null;
setWhisperExecutablePath: Dispatch<SetStateAction<string | null>>;
whisperModelPath: string | null;
setWhisperModelPath: Dispatch<SetStateAction<string | null>>;
};
export function useVideoEditorPresets({
t,
appearance,
timeline,
exportSettings,
aspectRatio,
setAspectRatio,
whisperExecutablePath,
setWhisperExecutablePath,
whisperModelPath,
setWhisperModelPath,
}: Input) {
const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets());
const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(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({
@@ -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<typeof useProjectState>;
appearance: ReturnType<typeof useAppearanceState>;
timeline: ReturnType<typeof useTimelineState>;
exportSettings: ReturnType<typeof useExportSettings>;
initialPreferences: ReturnType<typeof loadEditorPreferences>;
smokeConfig: ReturnType<typeof getSmokeExportConfig>;
devConfig: ReturnType<typeof getDevOpenRecordingConfig>;
@@ -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,
@@ -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<typeof useProjectState>;
appearance: ReturnType<typeof useAppearanceState>;
timeline: ReturnType<typeof useTimelineState>;
exportSettings: ReturnType<typeof useExportSettings>;
aspectRatio: AspectRatio;
setAspectRatio: Dispatch<SetStateAction<AspectRatio>>;
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);
@@ -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<typeof useProjectState>;
appearance: ReturnType<typeof useAppearanceState>;
timeline: ReturnType<typeof useTimelineState>;
exportSettings: ReturnType<typeof useExportSettings>;
aspectRatio: AspectRatio;
projectNameInputRef: RefObject<HTMLInputElement | null>;
projectSaveDialogInputRef: RefObject<HTMLInputElement | null>;
@@ -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,
@@ -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);
});
@@ -1,4 +1,4 @@
import type { EditorProjectData } from "./projectPersistence";
import { stripProjectUserPreferences, type EditorProjectData } from "./projectPersistence";
function isComparableObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -47,10 +47,10 @@ function omitTransientWebcamMediaFields(project: EditorProjectData | null) {
return project;
}
const editor = project.editor as Record<string, unknown>;
const editor = stripProjectUserPreferences(project.editor) as Record<string, unknown>;
const webcam = editor.webcam;
if (!isComparableObject(webcam)) {
return project;
return { ...project, editor };
}
const {
@@ -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");
});
@@ -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<string, SourceAudioTrackSettings>;
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<T extends PersistedDevMotion
return persistedEditor;
}
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;
}
function normalizeZoomTransitionEasing(
value: unknown,
fallback: ZoomTransitionEasing,
@@ -1094,32 +1048,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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<T extends object>(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<string, unknown>)[key];
}
return result;
}