mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Add editor presets menu
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BookmarkSimple,
|
||||
Check,
|
||||
CaretDown as ChevronDown,
|
||||
CaretUp as ChevronUp,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
@@ -77,6 +80,7 @@ import {
|
||||
getAspectRatioLabel,
|
||||
getAspectRatioValue,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExtensionIcon } from "./ExtensionIcon";
|
||||
|
||||
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
|
||||
@@ -103,7 +107,15 @@ import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { ExportSettingsMenu } from "./ExportSettingsMenu";
|
||||
import ExtensionManager from "./ExtensionManager";
|
||||
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
|
||||
import {
|
||||
loadEditorPreferences,
|
||||
loadEditorPresets,
|
||||
saveEditorPreferences,
|
||||
saveEditorPresets,
|
||||
serializeEditorPresetSnapshot,
|
||||
type EditorPreset,
|
||||
type EditorPresetSnapshot,
|
||||
} from "./editorPreferences";
|
||||
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
|
||||
import {
|
||||
createProjectData,
|
||||
@@ -670,6 +682,9 @@ export default function VideoEditor() {
|
||||
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
|
||||
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
|
||||
const [editorPresets, setEditorPresets] = useState<EditorPreset[]>(() => loadEditorPresets());
|
||||
const [presetPopoverOpen, setPresetPopoverOpen] = useState(false);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState("");
|
||||
const [showCropModal, setShowCropModal] = useState(false);
|
||||
const [previewVersion, setPreviewVersion] = useState(0);
|
||||
const [isPreviewReady, setIsPreviewReady] = useState(false);
|
||||
@@ -749,6 +764,228 @@ export default function VideoEditor() {
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, []);
|
||||
|
||||
const captureEditorPresetSnapshot = useCallback(
|
||||
(): EditorPresetSnapshot => ({
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
backgroundBlur,
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
zoomInDurationMs,
|
||||
zoomInOverlapMs,
|
||||
zoomOutDurationMs,
|
||||
connectedZoomGapMs,
|
||||
connectedZoomDurationMs,
|
||||
zoomInEasing,
|
||||
zoomOutEasing,
|
||||
connectedZoomEasing,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorStyle,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
borderRadius,
|
||||
padding: { ...padding },
|
||||
frame,
|
||||
webcam: { ...webcam },
|
||||
aspectRatio,
|
||||
exportEncodingMode,
|
||||
exportBackendPreference,
|
||||
exportPipelineModel,
|
||||
exportQuality,
|
||||
mp4FrameRate,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
autoCaptionSettings: { ...autoCaptionSettings },
|
||||
whisperExecutablePath,
|
||||
whisperModelPath,
|
||||
}),
|
||||
[
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
backgroundBlur,
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
zoomInDurationMs,
|
||||
zoomInOverlapMs,
|
||||
zoomOutDurationMs,
|
||||
connectedZoomGapMs,
|
||||
connectedZoomDurationMs,
|
||||
zoomInEasing,
|
||||
zoomOutEasing,
|
||||
connectedZoomEasing,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorStyle,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
borderRadius,
|
||||
padding,
|
||||
frame,
|
||||
webcam,
|
||||
aspectRatio,
|
||||
exportEncodingMode,
|
||||
exportBackendPreference,
|
||||
exportPipelineModel,
|
||||
exportQuality,
|
||||
mp4FrameRate,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
autoCaptionSettings,
|
||||
whisperExecutablePath,
|
||||
whisperModelPath,
|
||||
],
|
||||
);
|
||||
|
||||
const currentPresetSnapshot = useMemo(
|
||||
() => captureEditorPresetSnapshot(),
|
||||
[captureEditorPresetSnapshot],
|
||||
);
|
||||
const currentPresetSignature = useMemo(
|
||||
() => serializeEditorPresetSnapshot(currentPresetSnapshot),
|
||||
[currentPresetSnapshot],
|
||||
);
|
||||
const currentEditorPreset = useMemo(
|
||||
() =>
|
||||
editorPresets.find(
|
||||
(preset) =>
|
||||
serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature,
|
||||
) ?? null,
|
||||
[editorPresets, currentPresetSignature],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presetPopoverOpen) {
|
||||
setPresetNameDraft("");
|
||||
}
|
||||
}, [presetPopoverOpen]);
|
||||
|
||||
const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => {
|
||||
setWallpaper(snapshot.wallpaper);
|
||||
setShadowIntensity(snapshot.shadowIntensity);
|
||||
setBackgroundBlur(snapshot.backgroundBlur);
|
||||
setZoomMotionBlur(snapshot.zoomMotionBlur);
|
||||
setConnectZooms(snapshot.connectZooms);
|
||||
setZoomInDurationMs(snapshot.zoomInDurationMs);
|
||||
setZoomInOverlapMs(snapshot.zoomInOverlapMs);
|
||||
setZoomOutDurationMs(snapshot.zoomOutDurationMs);
|
||||
setConnectedZoomGapMs(snapshot.connectedZoomGapMs);
|
||||
setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs);
|
||||
setZoomInEasing(snapshot.zoomInEasing);
|
||||
setZoomOutEasing(snapshot.zoomOutEasing);
|
||||
setConnectedZoomEasing(snapshot.connectedZoomEasing);
|
||||
setShowCursor(snapshot.showCursor);
|
||||
setLoopCursor(snapshot.loopCursor);
|
||||
setCursorStyle(snapshot.cursorStyle);
|
||||
setCursorSize(snapshot.cursorSize);
|
||||
setCursorSmoothing(snapshot.cursorSmoothing);
|
||||
setCursorMotionBlur(snapshot.cursorMotionBlur);
|
||||
setCursorClickBounce(snapshot.cursorClickBounce);
|
||||
setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
|
||||
setCursorSway(snapshot.cursorSway);
|
||||
setBorderRadius(snapshot.borderRadius);
|
||||
setPadding({ ...snapshot.padding });
|
||||
setFrame(snapshot.frame);
|
||||
setWebcam({ ...snapshot.webcam });
|
||||
setAspectRatio(snapshot.aspectRatio);
|
||||
setExportEncodingMode(snapshot.exportEncodingMode);
|
||||
setExportBackendPreference(snapshot.exportBackendPreference);
|
||||
setExportPipelineModel(snapshot.exportPipelineModel);
|
||||
setExportQuality(snapshot.exportQuality);
|
||||
setMp4FrameRate(snapshot.mp4FrameRate);
|
||||
setExportFormat(snapshot.exportFormat);
|
||||
setGifFrameRate(snapshot.gifFrameRate);
|
||||
setGifLoop(snapshot.gifLoop);
|
||||
setGifSizePreset(snapshot.gifSizePreset);
|
||||
setAutoCaptionSettings({ ...snapshot.autoCaptionSettings });
|
||||
setWhisperExecutablePath(snapshot.whisperExecutablePath);
|
||||
setWhisperModelPath(snapshot.whisperModelPath);
|
||||
}, []);
|
||||
|
||||
const handleApplyEditorPreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const preset = editorPresets.find((item) => item.id === presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyEditorPresetSnapshot(preset.snapshot);
|
||||
toast.success(`Applied preset \"${preset.name}\"`);
|
||||
},
|
||||
[applyEditorPresetSnapshot, editorPresets],
|
||||
);
|
||||
|
||||
const handleSaveEditorPreset = useCallback(
|
||||
(name: string) => {
|
||||
const normalizedName = name.trim().replace(/\s+/g, " ");
|
||||
if (normalizedName.length === 0) {
|
||||
toast.error("Enter a preset name.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasDuplicateName = editorPresets.some(
|
||||
(preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(),
|
||||
);
|
||||
if (hasDuplicateName) {
|
||||
toast.error("A preset with that name already exists.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = captureEditorPresetSnapshot();
|
||||
const timestamp = new Date().toISOString();
|
||||
const nextPresets = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: normalizedName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
snapshot,
|
||||
},
|
||||
...editorPresets,
|
||||
];
|
||||
|
||||
setEditorPresets(nextPresets);
|
||||
saveEditorPresets(nextPresets);
|
||||
toast.success(`Saved preset \"${normalizedName}\"`);
|
||||
return true;
|
||||
},
|
||||
[captureEditorPresetSnapshot, editorPresets],
|
||||
);
|
||||
|
||||
const handleDeleteEditorPreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const preset = editorPresets.find((item) => item.id === presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPresets = editorPresets.filter((item) => item.id !== presetId);
|
||||
setEditorPresets(nextPresets);
|
||||
saveEditorPresets(nextPresets);
|
||||
toast.success(`Deleted preset \"${preset.name}\"`);
|
||||
},
|
||||
[editorPresets],
|
||||
);
|
||||
|
||||
const handleSavePresetSubmit = useCallback(() => {
|
||||
const didSave = handleSaveEditorPreset(presetNameDraft);
|
||||
if (didSave) {
|
||||
setPresetNameDraft("");
|
||||
}
|
||||
}, [handleSaveEditorPreset, presetNameDraft]);
|
||||
|
||||
const clearPendingExportSave = useCallback(() => {
|
||||
const pending = pendingExportSaveRef.current;
|
||||
pendingExportSaveRef.current = null;
|
||||
@@ -4957,6 +5194,98 @@ export default function VideoEditor() {
|
||||
className="flex items-center gap-2 justify-self-end pr-3"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<BookmarkSimple weight="fill" className="h-4 w-4" />
|
||||
<span>{currentEditorPreset?.name ?? "Presets"}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSavePresetSubmit();
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<p className="text-[11px] font-medium text-foreground">
|
||||
Save current preset as
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={presetNameDraft}
|
||||
onChange={(event) => setPresetNameDraft(event.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium text-foreground">Saved presets</p>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{editorPresets.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
No presets yet.
|
||||
</div>
|
||||
) : (
|
||||
editorPresets.map((preset) => {
|
||||
const isActive = preset.id === currentEditorPreset?.id;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground"
|
||||
: "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplyEditorPreset(preset.id)}
|
||||
className="flex min-w-0 flex-1 items-center justify-between text-left"
|
||||
>
|
||||
<span className="truncate pr-3">{preset.name}</span>
|
||||
{isActive && <Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteEditorPreset(preset.id)}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
|
||||
aria-label={`Delete preset ${preset.name}`}
|
||||
title={`Delete preset ${preset.name}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu
|
||||
open={showExportDropdown}
|
||||
onOpenChange={setShowExportDropdown}
|
||||
@@ -4966,7 +5295,7 @@ export default function VideoEditor() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenExportDropdown}
|
||||
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
|
||||
className="ml-3 inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
|
||||
@@ -48,6 +48,22 @@ type PersistedEditorControls = Pick<
|
||||
|
||||
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;
|
||||
@@ -58,6 +74,7 @@ export interface EditorPreferences extends PersistedEditorControls {
|
||||
}
|
||||
|
||||
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
|
||||
export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets";
|
||||
|
||||
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
|
||||
|
||||
@@ -144,6 +161,77 @@ function normalizeNullablePath(value: unknown): string | null {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string {
|
||||
return JSON.stringify(normalizeEditorPresetSnapshot(snapshot));
|
||||
}
|
||||
|
||||
function normalizeEditorControls(
|
||||
raw: Partial<EditorPreferences>,
|
||||
fallback: EditorPreferences,
|
||||
@@ -300,3 +388,40 @@ export function saveEditorPreferences(preferences: Partial<EditorPreferences>):
|
||||
// Ignore storage failures so editor controls still work.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEditorPresets(): EditorPreset[] {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((item) => normalizeEditorPreset(item))
|
||||
.filter((preset): preset is EditorPreset => preset !== null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveEditorPresets(presets: EditorPreset[]): void {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(presets));
|
||||
} catch {
|
||||
// Ignore storage failures so editor controls still work.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user