Files
Recordly/src/components/video-editor/editorPreferences.ts
T

487 lines
18 KiB
TypeScript

import {
normalizeExportBackendPreference,
normalizeExportMp4FrameRate,
normalizeExportPipelineModel,
normalizeProjectEditor,
stripPersistedDevMotionBlurSettings,
type ProjectEditorState,
} from "./projectPersistence";
import { loadAppSetting, saveAppSetting } from "../../lib/appSettings";
type PersistedEditorControls = Pick<
ProjectEditorState,
| "wallpaper"
| "shadowIntensity"
| "backgroundBlur"
| "zoomMotionBlur"
| "zoomMotionBlurTuning"
| "zoomTemporalMotionBlur"
| "zoomMotionBlurSampleCount"
| "zoomMotionBlurShutterFraction"
| "connectZooms"
| "zoomInDurationMs"
| "zoomInOverlapMs"
| "zoomOutDurationMs"
| "connectedZoomGapMs"
| "connectedZoomDurationMs"
| "zoomInEasing"
| "zoomOutEasing"
| "connectedZoomEasing"
| "showCursor"
| "loopCursor"
| "cursorStyle"
| "cursorSize"
| "cursorSmoothing"
| "cursorSpringStiffnessMultiplier"
| "cursorSpringDampingMultiplier"
| "cursorSpringMassMultiplier"
| "cameraSpringStiffnessMultiplier"
| "cameraSpringDampingMultiplier"
| "cameraSpringMassMultiplier"
| "cursorMotionBlur"
| "cursorClickBounce"
| "cursorClickBounceDuration"
| "cursorSway"
| "borderRadius"
| "padding"
| "frame"
| "webcam"
| "aspectRatio"
| "exportEncodingMode"
| "exportBackendPreference"
| "exportPipelineModel"
| "exportQuality"
| "mp4FrameRate"
| "exportFormat"
| "gifFrameRate"
| "gifLoop"
| "gifSizePreset"
>;
type PartialEditorControls = Partial<PersistedEditorControls>;
type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"];
export interface EditorPresetSnapshot extends PersistedEditorControls {
autoCaptionSettings: PresetAutoCaptionSettings;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
}
export interface EditorPreset {
id: string;
name: string;
createdAt: string;
updatedAt: string;
snapshot: EditorPresetSnapshot;
}
export interface EditorPreferences extends PersistedEditorControls {
customAspectWidth: string;
customAspectHeight: string;
customWallpapers: string[];
autoApplyFreshRecordingAutoZooms: boolean;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
}
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets";
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper,
shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning,
zoomTemporalMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurShutterFraction,
connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms,
zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs,
zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs,
zoomOutDurationMs: DEFAULT_EDITOR_CONTROLS.zoomOutDurationMs,
connectedZoomGapMs: DEFAULT_EDITOR_CONTROLS.connectedZoomGapMs,
connectedZoomDurationMs: DEFAULT_EDITOR_CONTROLS.connectedZoomDurationMs,
zoomInEasing: DEFAULT_EDITOR_CONTROLS.zoomInEasing,
zoomOutEasing: DEFAULT_EDITOR_CONTROLS.zoomOutEasing,
connectedZoomEasing: DEFAULT_EDITOR_CONTROLS.connectedZoomEasing,
showCursor: DEFAULT_EDITOR_CONTROLS.showCursor,
loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor,
cursorStyle: DEFAULT_EDITOR_CONTROLS.cursorStyle,
cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing,
cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cameraSpringMassMultiplier,
cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration,
cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway,
borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius,
padding: DEFAULT_EDITOR_CONTROLS.padding,
frame: DEFAULT_EDITOR_CONTROLS.frame,
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,
customAspectWidth: "16",
customAspectHeight: "9",
customWallpapers: [],
autoApplyFreshRecordingAutoZooms: true,
whisperExecutablePath: null,
whisperModelPath: null,
};
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function normalizePositiveIntegerString(value: unknown, fallback: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return String(parsed);
}
function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return fallback;
}
return Array.from(
new Set(
value.filter((item): item is string => typeof item === "string" && item.length > 0),
),
);
}
function normalizeNullablePath(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings {
return normalizeProjectEditor({
autoCaptionSettings:
value && typeof value === "object" ? (value as PresetAutoCaptionSettings) : undefined,
}).autoCaptionSettings;
}
function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot {
const normalizedPreferences = normalizeEditorPreferences(candidate);
const raw =
candidate && typeof candidate === "object"
? (candidate as Partial<EditorPresetSnapshot>)
: {};
return {
...normalizeEditorControls(normalizedPreferences, normalizedPreferences),
autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
whisperExecutablePath:
normalizeNullablePath(raw.whisperExecutablePath) ??
normalizedPreferences.whisperExecutablePath,
whisperModelPath:
normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath,
};
}
function normalizePresetName(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim().replace(/\s+/g, " ");
return trimmed.length > 0 ? trimmed : null;
}
function normalizePresetTimestamp(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
}
function normalizeEditorPreset(candidate: unknown): EditorPreset | null {
if (!candidate || typeof candidate !== "object") {
return null;
}
const raw = candidate as Partial<EditorPreset>;
const name = normalizePresetName(raw.name);
if (!name) {
return null;
}
const timestamp = new Date().toISOString();
const id =
typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID();
return {
id,
name,
createdAt: normalizePresetTimestamp(raw.createdAt, timestamp),
updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp),
snapshot: normalizeEditorPresetSnapshot(raw.snapshot),
};
}
function normalizeEditorPresets(candidates: unknown): EditorPreset[] {
if (!Array.isArray(candidates)) {
return [];
}
return candidates
.map((item) => normalizeEditorPreset(item))
.filter((preset): preset is EditorPreset => preset !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string {
return JSON.stringify(normalizeEditorPresetSnapshot(snapshot));
}
function normalizeEditorControls(
raw: Partial<EditorPreferences>,
fallback: EditorPreferences,
): PersistedEditorControls {
const sanitizedRaw = stripPersistedDevMotionBlurSettings(raw);
const candidate: PartialEditorControls = {
wallpaper: sanitizedRaw.wallpaper ?? fallback.wallpaper,
shadowIntensity: sanitizedRaw.shadowIntensity ?? fallback.shadowIntensity,
backgroundBlur: sanitizedRaw.backgroundBlur ?? fallback.backgroundBlur,
zoomMotionBlur: sanitizedRaw.zoomMotionBlur ?? fallback.zoomMotionBlur,
connectZooms: sanitizedRaw.connectZooms ?? fallback.connectZooms,
zoomInDurationMs: sanitizedRaw.zoomInDurationMs ?? fallback.zoomInDurationMs,
zoomInOverlapMs: sanitizedRaw.zoomInOverlapMs ?? fallback.zoomInOverlapMs,
zoomOutDurationMs: sanitizedRaw.zoomOutDurationMs ?? fallback.zoomOutDurationMs,
connectedZoomGapMs:
sanitizedRaw.connectedZoomGapMs ?? fallback.connectedZoomGapMs,
connectedZoomDurationMs:
sanitizedRaw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs,
zoomInEasing: sanitizedRaw.zoomInEasing ?? fallback.zoomInEasing,
zoomOutEasing: sanitizedRaw.zoomOutEasing ?? fallback.zoomOutEasing,
connectedZoomEasing:
sanitizedRaw.connectedZoomEasing ?? fallback.connectedZoomEasing,
showCursor: sanitizedRaw.showCursor ?? fallback.showCursor,
loopCursor: sanitizedRaw.loopCursor ?? fallback.loopCursor,
cursorStyle: sanitizedRaw.cursorStyle ?? fallback.cursorStyle,
cursorSize: sanitizedRaw.cursorSize ?? fallback.cursorSize,
cursorSmoothing: sanitizedRaw.cursorSmoothing ?? fallback.cursorSmoothing,
cursorSpringStiffnessMultiplier:
sanitizedRaw.cursorSpringStiffnessMultiplier ??
fallback.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier:
sanitizedRaw.cursorSpringDampingMultiplier ??
fallback.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier:
sanitizedRaw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier:
sanitizedRaw.cameraSpringStiffnessMultiplier ??
fallback.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier:
sanitizedRaw.cameraSpringDampingMultiplier ??
fallback.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier:
sanitizedRaw.cameraSpringMassMultiplier ?? fallback.cameraSpringMassMultiplier,
cursorMotionBlur: sanitizedRaw.cursorMotionBlur ?? fallback.cursorMotionBlur,
cursorClickBounce: sanitizedRaw.cursorClickBounce ?? fallback.cursorClickBounce,
cursorClickBounceDuration:
sanitizedRaw.cursorClickBounceDuration ?? fallback.cursorClickBounceDuration,
cursorSway: sanitizedRaw.cursorSway ?? fallback.cursorSway,
borderRadius: sanitizedRaw.borderRadius ?? fallback.borderRadius,
padding: sanitizedRaw.padding ?? fallback.padding,
frame: sanitizedRaw.frame !== undefined ? sanitizedRaw.frame : fallback.frame,
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);
return {
wallpaper: normalized.wallpaper,
shadowIntensity: normalized.shadowIntensity,
backgroundBlur: normalized.backgroundBlur,
zoomMotionBlur: normalized.zoomMotionBlur,
zoomMotionBlurTuning: normalized.zoomMotionBlurTuning,
zoomTemporalMotionBlur: normalized.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalized.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalized.zoomMotionBlurShutterFraction,
connectZooms: normalized.connectZooms,
zoomInDurationMs: normalized.zoomInDurationMs,
zoomInOverlapMs: normalized.zoomInOverlapMs,
zoomOutDurationMs: normalized.zoomOutDurationMs,
connectedZoomGapMs: normalized.connectedZoomGapMs,
connectedZoomDurationMs: normalized.connectedZoomDurationMs,
zoomInEasing: normalized.zoomInEasing,
zoomOutEasing: normalized.zoomOutEasing,
connectedZoomEasing: normalized.connectedZoomEasing,
showCursor: normalized.showCursor,
loopCursor: normalized.loopCursor,
cursorStyle: normalized.cursorStyle,
cursorSize: normalized.cursorSize,
cursorSmoothing: normalized.cursorSmoothing,
cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: normalized.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: normalized.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: normalized.cameraSpringMassMultiplier,
cursorMotionBlur: normalized.cursorMotionBlur,
cursorClickBounce: normalized.cursorClickBounce,
cursorClickBounceDuration: normalized.cursorClickBounceDuration,
cursorSway: normalized.cursorSway,
borderRadius: normalized.borderRadius,
padding: normalized.padding,
frame: normalized.frame,
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,
};
}
export function normalizeEditorPreferences(
candidate: unknown,
fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES,
): EditorPreferences {
const raw =
candidate && typeof candidate === "object" ? (candidate as Partial<EditorPreferences>) : {};
return {
...normalizeEditorControls(raw, fallback),
customAspectWidth: normalizePositiveIntegerString(
raw.customAspectWidth,
fallback.customAspectWidth,
),
customAspectHeight: normalizePositiveIntegerString(
raw.customAspectHeight,
fallback.customAspectHeight,
),
customWallpapers: normalizeCustomWallpapers(
raw.customWallpapers,
fallback.customWallpapers,
),
autoApplyFreshRecordingAutoZooms: normalizeBoolean(
raw.autoApplyFreshRecordingAutoZooms,
fallback.autoApplyFreshRecordingAutoZooms,
),
whisperExecutablePath:
normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath,
whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath,
};
}
export function loadEditorPreferences(): EditorPreferences {
const persisted = loadAppSetting<unknown>(EDITOR_PREFERENCES_STORAGE_KEY);
if (persisted !== null) {
return normalizeEditorPreferences(persisted);
}
try {
const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY);
if (!stored) {
return DEFAULT_EDITOR_PREFERENCES;
}
return normalizeEditorPreferences(JSON.parse(stored));
} catch {
return DEFAULT_EDITOR_PREFERENCES;
}
}
export function saveEditorPreferences(preferences: Partial<EditorPreferences>): void {
try {
const current = loadEditorPreferences();
const merged = normalizeEditorPreferences({ ...current, ...preferences }, current);
const persisted = stripPersistedDevMotionBlurSettings(merged);
saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted);
globalThis.localStorage?.setItem(
EDITOR_PREFERENCES_STORAGE_KEY,
JSON.stringify(persisted),
);
} catch {
// Ignore storage failures so editor controls still work.
}
}
export function loadEditorPresets(): EditorPreset[] {
const persisted = loadAppSetting<unknown>(EDITOR_PRESETS_STORAGE_KEY);
if (persisted !== null) {
return normalizeEditorPresets(persisted);
}
try {
const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY);
if (!stored) {
return [];
}
return normalizeEditorPresets(JSON.parse(stored));
} catch {
return [];
}
}
export function saveEditorPresets(presets: EditorPreset[]): boolean {
try {
const normalized = normalizeEditorPresets(presets);
const persisted = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized);
globalThis.localStorage?.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
return persisted || typeof globalThis.localStorage !== "undefined";
} catch {
// Ignore storage failures so editor controls still work.
return false;
}
}