mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
Merge pull request #1080 from webadderallorg/codex/export-preferences-separation
Keep export preferences separate from project edits
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user