diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5c5bfb47..bda33cdf 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -398,12 +398,15 @@ interface Window { message?: string; error?: string; }>; - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentVideoPath: ( + path: string, + options?: { preserveProjectPath?: boolean }, + ) => Promise<{ success: boolean; webcamPath: string | null }>; setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; - }) => Promise<{ success: boolean }>; + }, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>; getCurrentRecordingSession: () => Promise<{ success: boolean; session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 0236b55b..51fca45d 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -79,12 +79,13 @@ describe("local media path policy", () => { const videoPath = path.join(downloadsPath, "external-video.mp4"); await fs.mkdir(downloadsPath, { recursive: true }); await fs.writeFile(videoPath, "test-video"); + const resolvedVideoPath = await fs.realpath(videoPath); const { resolveApprovedLocalMediaPath } = await import("./manager"); const { isAllowedMediaPath } = await import("../../mediaServer"); expect(isAllowedMediaPath(videoPath)).toBe(false); - await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath); + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath); expect(isAllowedMediaPath(videoPath)).toBe(true); }); @@ -100,4 +101,18 @@ describe("local media path policy", () => { await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull(); expect(isAllowedMediaPath(textPath)).toBe(false); }); + + it("preserves an existing project thumbnail when no replacement is provided", async () => { + const projectPath = path.join(tempRoot, "Projects", "demo.recordly"); + const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`; + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + + const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager"); + const thumbnailPath = getProjectThumbnailPath(projectPath); + + await saveProjectThumbnail(projectPath, thumbnailDataUrl); + await saveProjectThumbnail(projectPath, undefined); + + await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail"); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 473f3a63..9e61605a 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -68,20 +68,37 @@ export async function isAllowedLocalMediaPath(candidatePath: string) { return isAllowedLocalReadPath(normalizedCandidatePath); } -export async function rememberApprovedLocalReadPath(filePath?: string | null) { +async function collectApprovedLocalReadPaths(filePath?: string | null): Promise { const normalizedPath = normalizeVideoSourcePath(filePath); if (!normalizedPath) { - return; + return []; } - const resolvedPath = normalizePath(normalizedPath); - approvedLocalReadPaths.add(resolvedPath); + const approvedPaths = [normalizePath(normalizedPath)]; try { - approvedLocalReadPaths.add(await fs.realpath(resolvedPath)); + const realPath = await fs.realpath(approvedPaths[0]); + const normalizedRealPath = normalizePath(realPath); + if (!approvedPaths.includes(normalizedRealPath)) { + approvedPaths.push(normalizedRealPath); + } } catch { // Ignore missing files; the eventual read will surface the real error. } + + return approvedPaths; +} + +export async function rememberApprovedLocalReadPath(filePath?: string | null) { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return; + } + + const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath); + for (const approvedPath of approvedPaths) { + approvedLocalReadPaths.add(approvedPath); + } } export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise { @@ -101,13 +118,26 @@ export async function resolveApprovedLocalMediaPath(candidatePath: string): Prom return null; } - await rememberApprovedLocalReadPath(realPath); + await rememberApprovedLocalReadPath(candidatePath); return realPath; } export async function replaceApprovedSessionLocalReadPaths(filePaths: Array) { + const nextApprovedPaths = new Set(); + const approvedPathLists = await Promise.all( + filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)), + ); + + for (const approvedPathList of approvedPathLists) { + for (const approvedPath of approvedPathList) { + nextApprovedPaths.add(approvedPath); + } + } + approvedLocalReadPaths.clear(); - await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath))); + for (const approvedPath of nextApprovedPaths) { + approvedLocalReadPaths.add(approvedPath); + } } export async function resolveProjectMediaSources(project: unknown): Promise< @@ -196,6 +226,10 @@ export function getProjectThumbnailPath(projectPath: string) { export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) { const thumbnailPath = getProjectThumbnailPath(projectPath); + if (thumbnailDataUrl === undefined) { + return existsSync(thumbnailPath) ? thumbnailPath : null; + } + if (!thumbnailDataUrl) { await fs.rm(thumbnailPath, { force: true }).catch(() => undefined); return null; @@ -383,4 +417,3 @@ export function isTrustedProjectPath(filePath?: string | null): boolean { if (!filePath || !currentProjectPath) return false; return normalizePath(filePath) === normalizePath(currentProjectPath); } - diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 94c25b56..570d7b58 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -527,7 +527,7 @@ export function registerProjectHandlers() { return { success: false, error: String(error), message: 'Failed to open projects folder.' } } }) - ipcMain.handle('set-current-video-path', async (_, path: string) => { + ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => { setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) approveUserPath(currentVideoPath) const resolvedSession = await resolveRecordingSession(currentVideoPath) @@ -547,11 +547,13 @@ export function registerProjectHandlers() { await persistRecordingSessionManifest(resolvedSession) } - setCurrentProjectPath(null) + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null) + } return { success: true, webcamPath: resolvedSession.webcamPath ?? null } }) - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath setCurrentVideoPath(normalizedVideoPath) setCurrentRecordingSession({ @@ -563,7 +565,9 @@ export function registerProjectHandlers() { currentRecordingSession!.videoPath, currentRecordingSession!.webcamPath, ]) - setCurrentProjectPath(null) + if (!options?.preserveProjectPath) { + setCurrentProjectPath(null) + } await persistRecordingSessionManifest(currentRecordingSession!) return { success: true } }) diff --git a/electron/preload.ts b/electron/preload.ts index e6537ec4..e41acc26 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -430,15 +430,15 @@ contextBridge.exposeInMainWorld("electronAPI", { }) => { return ipcRenderer.invoke("generate-auto-captions", options); }, - setCurrentVideoPath: (path: string) => { - return ipcRenderer.invoke("set-current-video-path", path); + setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => { + return ipcRenderer.invoke("set-current-video-path", path, options); }, setCurrentRecordingSession: (session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; - }) => { - return ipcRenderer.invoke("set-current-recording-session", session); + }, options?: { preserveProjectPath?: boolean }) => { + return ipcRenderer.invoke("set-current-recording-session", session, options); }, getCurrentRecordingSession: () => { return ipcRenderer.invoke("get-current-recording-session"); diff --git a/public/wallpapers/energy-17.jpg b/public/wallpapers/energy-17.jpg new file mode 100644 index 00000000..713695fb Binary files /dev/null and b/public/wallpapers/energy-17.jpg differ diff --git a/public/wallpapers/energy-19.jpg b/public/wallpapers/energy-19.jpg new file mode 100644 index 00000000..dfabecec Binary files /dev/null and b/public/wallpapers/energy-19.jpg differ diff --git a/public/wallpapers/glassmorphism-3.jpg b/public/wallpapers/glassmorphism-3.jpg new file mode 100644 index 00000000..986c69a3 Binary files /dev/null and b/public/wallpapers/glassmorphism-3.jpg differ diff --git a/public/wallpapers/glassmorphism-4.jpg b/public/wallpapers/glassmorphism-4.jpg new file mode 100644 index 00000000..1f9aaffd Binary files /dev/null and b/public/wallpapers/glassmorphism-4.jpg differ diff --git a/public/wallpapers/ipad-17-dark.jpg b/public/wallpapers/ipad-17-dark.jpg new file mode 100644 index 00000000..f1782e17 Binary files /dev/null and b/public/wallpapers/ipad-17-dark.jpg differ diff --git a/public/wallpapers/ipad-17-light.jpg b/public/wallpapers/ipad-17-light.jpg new file mode 100644 index 00000000..738ecf99 Binary files /dev/null and b/public/wallpapers/ipad-17-light.jpg differ diff --git a/public/wallpapers/iridescent-9.jpg b/public/wallpapers/iridescent-9.jpg new file mode 100644 index 00000000..b33c7f83 Binary files /dev/null and b/public/wallpapers/iridescent-9.jpg differ diff --git a/public/wallpapers/midnight-8.jpg b/public/wallpapers/midnight-8.jpg new file mode 100644 index 00000000..21932e70 Binary files /dev/null and b/public/wallpapers/midnight-8.jpg differ diff --git a/public/wallpapers/sequoia-blue-orange.jpg b/public/wallpapers/sequoia-blue-orange.jpg new file mode 100644 index 00000000..904fab1b Binary files /dev/null and b/public/wallpapers/sequoia-blue-orange.jpg differ diff --git a/public/wallpapers/sequoia-blue.jpg b/public/wallpapers/sequoia-blue.jpg new file mode 100644 index 00000000..a0dfb46d Binary files /dev/null and b/public/wallpapers/sequoia-blue.jpg differ diff --git a/public/wallpapers/sonoma-clouds.jpg b/public/wallpapers/sonoma-clouds.jpg new file mode 100644 index 00000000..e60330f1 Binary files /dev/null and b/public/wallpapers/sonoma-clouds.jpg differ diff --git a/public/wallpapers/sonoma-dark.jpg b/public/wallpapers/sonoma-dark.jpg new file mode 100644 index 00000000..e11d78a2 Binary files /dev/null and b/public/wallpapers/sonoma-dark.jpg differ diff --git a/public/wallpapers/sonoma-evening.jpg b/public/wallpapers/sonoma-evening.jpg new file mode 100644 index 00000000..d61e60d6 Binary files /dev/null and b/public/wallpapers/sonoma-evening.jpg differ diff --git a/public/wallpapers/sonoma-horizon.jpg b/public/wallpapers/sonoma-horizon.jpg new file mode 100644 index 00000000..1e237181 Binary files /dev/null and b/public/wallpapers/sonoma-horizon.jpg differ diff --git a/public/wallpapers/sonoma-light.jpg b/public/wallpapers/sonoma-light.jpg new file mode 100644 index 00000000..44ef9efd Binary files /dev/null and b/public/wallpapers/sonoma-light.jpg differ diff --git a/public/wallpapers/tahoe-dark.jpg b/public/wallpapers/tahoe-dark.jpg new file mode 100644 index 00000000..e83fce96 Binary files /dev/null and b/public/wallpapers/tahoe-dark.jpg differ diff --git a/public/wallpapers/tahoe-light.jpg b/public/wallpapers/tahoe-light.jpg new file mode 100644 index 00000000..93001c56 Binary files /dev/null and b/public/wallpapers/tahoe-light.jpg differ diff --git a/public/wallpapers/ventura-dark.jpg b/public/wallpapers/ventura-dark.jpg new file mode 100644 index 00000000..09267bcc Binary files /dev/null and b/public/wallpapers/ventura-dark.jpg differ diff --git a/public/wallpapers/ventura.jpg b/public/wallpapers/ventura.jpg new file mode 100644 index 00000000..77e3c5c9 Binary files /dev/null and b/public/wallpapers/ventura.jpg differ diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 51b1a0c7..57cdb2d2 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1776,14 +1776,18 @@ export function SettingsPanel({
{ setGradient(g); onWallpaperChange(g); }} role="button" - /> + > +
+
))}
)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 580627ac..7dc6d812 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,13 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; +type SaveProjectOptions = { + silent?: boolean; + remountPreviewAfterSave?: boolean; + refreshLibraryAfterSave?: boolean; + captureThumbnail?: boolean; +}; + async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -251,6 +258,7 @@ async function writeSmokeExportReport( const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +const PROJECT_AUTOSAVE_DELAY_MS = 1000; function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number { switch (encodingMode) { @@ -695,6 +703,8 @@ export default function VideoEditor() { const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); + const projectAutosaveTimeoutRef = useRef(null); + const projectSaveQueueRef = useRef>(Promise.resolve()); const [historyVersion, setHistoryVersion] = useState(0); const timelineRef = useRef(null); @@ -982,6 +992,19 @@ export default function VideoEditor() { setPreviewVersion((version) => version + 1); }, []); + const clearPendingProjectAutosave = useCallback(() => { + if (projectAutosaveTimeoutRef.current !== null) { + window.clearTimeout(projectAutosaveTimeoutRef.current); + projectAutosaveTimeoutRef.current = null; + } + }, []); + + const queueProjectSave = useCallback((task: () => Promise) => { + const run = projectSaveQueueRef.current.catch(() => undefined).then(task); + projectSaveQueueRef.current = run.catch(() => undefined); + return run; + }, []); + useEffect(() => { return () => { exporterRef.current?.cancel(); @@ -999,6 +1022,10 @@ export default function VideoEditor() { window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); pendingFreshRecordingAutoSuggestTimeoutRef.current = null; } + if (projectAutosaveTimeoutRef.current !== null) { + window.clearTimeout(projectAutosaveTimeoutRef.current); + projectAutosaveTimeoutRef.current = null; + } }; }, []); @@ -1561,20 +1588,24 @@ export default function VideoEditor() { setCurrentTime(0); setDuration(0); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(await resolveVideoUrl(sourcePath)); - setCurrentProjectPath(path ?? null); - pendingFreshRecordingAutoZoomPathRef.current = null; - if (normalizedEditor.webcam.sourcePath) { - await window.electronAPI.setCurrentRecordingSession?.({ - videoPath: sourcePath, - webcamPath: normalizedEditor.webcam.sourcePath, - timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, - }); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath); - } + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(await resolveVideoUrl(sourcePath)); + setCurrentProjectPath(path ?? null); + pendingFreshRecordingAutoZoomPathRef.current = null; + if (normalizedEditor.webcam.sourcePath) { + await window.electronAPI.setCurrentRecordingSession?.({ + videoPath: sourcePath, + webcamPath: normalizedEditor.webcam.sourcePath, + timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, + }, { + preserveProjectPath: Boolean(path), + }); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + } setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); @@ -1711,9 +1742,11 @@ export default function VideoEditor() { : webcamPath ? webcam.timeOffsetMs : DEFAULT_WEBCAM_TIME_OFFSET_MS, + }, { + preserveProjectPath: Boolean(currentProjectPath), }); }, - [currentSourcePath, webcam.timeOffsetMs], + [currentProjectPath, currentSourcePath, webcam.timeOffsetMs], ); const syncActiveVideoSource = useCallback( @@ -1723,13 +1756,17 @@ export default function VideoEditor() { videoPath: sourcePath, webcamPath, timeOffsetMs: webcam.timeOffsetMs, + }, { + preserveProjectPath: Boolean(currentProjectPath), }); return; } - await window.electronAPI.setCurrentVideoPath(sourcePath); + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(currentProjectPath), + }); }, - [webcam.timeOffsetMs], + [currentProjectPath, webcam.timeOffsetMs], ); const handleUploadWebcam = useCallback(async () => { @@ -2237,83 +2274,106 @@ export default function VideoEditor() { }, []); const saveProject = useCallback( - async (forceSaveAs: boolean) => { - if (!currentSourcePath) { - toast.error("No video loaded"); - return false; - } + async (forceSaveAs: boolean, options?: SaveProjectOptions) => { + clearPendingProjectAutosave(); + return queueProjectSave(async () => { + if (!currentSourcePath) { + if (!options?.silent) { + toast.error("No video loaded"); + } + return false; + } - try { - const projectData = - currentProjectSnapshot?.videoPath === currentSourcePath - ? currentProjectSnapshot - : createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, - ); + const shouldCaptureThumbnail = options?.captureThumbnail ?? true; + const shouldRefreshLibrary = options?.refreshLibraryAfterSave ?? true; + const shouldRemountPreview = options?.remountPreviewAfterSave ?? true; + + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); - const fileNameBase = - currentSourcePath - .split(/[\\/]/) - .pop() - ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); - - if (!forceSaveAs && !targetProjectPath) { - const activeProjectResult = await window.electronAPI.loadCurrentProjectFile(); - if (activeProjectResult.success && activeProjectResult.path) { - targetProjectPath = activeProjectResult.path; - setCurrentProjectPath(activeProjectResult.path); + const fileNameBase = + currentSourcePath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; + let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); + + if (!forceSaveAs && !targetProjectPath) { + const activeProjectResult = await window.electronAPI.loadCurrentProjectFile(); + if (activeProjectResult.success && activeProjectResult.path) { + targetProjectPath = activeProjectResult.path; + setCurrentProjectPath(activeProjectResult.path); + } } - } - const thumbnailDataUrl = await captureProjectThumbnail(); + const thumbnailDataUrl = shouldCaptureThumbnail + ? await captureProjectThumbnail() + : undefined; - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - targetProjectPath, - thumbnailDataUrl, - ); + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + targetProjectPath, + thumbnailDataUrl, + ); - if (result.canceled) { - toast.info("Project save canceled"); - return false; - } + if (result.canceled) { + if (!options?.silent) { + toast.info("Project save canceled"); + } + return false; + } - if (!result.success) { - toast.error(result.message || "Failed to save project"); - return false; - } + if (!result.success) { + if (!options?.silent) { + toast.error(result.message || "Failed to save project"); + } + return false; + } - if (result.path) { - setCurrentProjectPath(result.path); - } - setLastSavedSnapshot( - cloneStructured( - createProjectData( - projectData.videoPath, - projectData.editor, - result.projectId ?? projectData.projectId ?? null, + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), ), - ), - ); - await refreshProjectLibrary(); + ); + if (shouldRefreshLibrary) { + await refreshProjectLibrary(); + } - toast.success(`Project saved to ${result.path}`); - return true; - } finally { - remountPreview(); - } + if (!options?.silent) { + toast.success(`Project saved to ${result.path}`); + } + return true; + } finally { + if (shouldRemountPreview) { + remountPreview(); + } + } + }); }, [ captureProjectThumbnail, + clearPendingProjectAutosave, currentSourcePath, currentProjectPath, currentProjectSnapshot, currentPersistedEditorState, lastSavedSnapshot?.projectId, + queueProjectSave, refreshProjectLibrary, remountPreview, ], @@ -2342,6 +2402,27 @@ export default function VideoEditor() { } }, [saveProject]); + useEffect(() => { + if (!currentProjectPath || !hasUnsavedChanges) { + clearPendingProjectAutosave(); + return; + } + + projectAutosaveTimeoutRef.current = window.setTimeout(() => { + projectAutosaveTimeoutRef.current = null; + void saveProject(false, { + silent: true, + remountPreviewAfterSave: false, + refreshLibraryAfterSave: false, + captureThumbnail: false, + }); + }, PROJECT_AUTOSAVE_DELAY_MS); + + return () => { + clearPendingProjectAutosave(); + }; + }, [clearPendingProjectAutosave, currentProjectPath, hasUnsavedChanges, saveProject]); + /** * Saves the current project directly into the projects library under a chosen name. */ diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index edd0c012..2aa70013 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -240,7 +240,7 @@ describe("editorPreferences", () => { cursorClickBounceDuration: 350, cursorSway: 1.5, borderRadius: 18, - padding: 30, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, frame: DEFAULT_EDITOR_PREFERENCES.frame, aspectRatio: "4:5", exportEncodingMode: "quality", @@ -282,7 +282,7 @@ describe("editorPreferences", () => { cursorClickBounceDuration: 350, cursorSway: 1.5, borderRadius: 18, - padding: 30, + padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true }, frame: DEFAULT_EDITOR_PREFERENCES.frame, aspectRatio: "4:5", exportEncodingMode: "quality", diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index 5483fc16..a55b34ad 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -3,6 +3,7 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist const NOOP = () => undefined; const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i; const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]); +const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"]; export function isAbsoluteLocalPath(resource: string) { return ( @@ -12,6 +13,10 @@ export function isAbsoluteLocalPath(resource: string) { ); } +function isBundledAssetPath(resource: string) { + return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix)); +} + function getLocalMediaServerPath(resource: string) { if (!/^https?:\/\//i.test(resource)) { return null; @@ -44,6 +49,10 @@ export function getLocalFilePath(resource: string) { return fromFileUrl(resource); } + if (isBundledAssetPath(resource)) { + return null; + } + return isAbsoluteLocalPath(resource) ? resource : null; } diff --git a/src/lib/wallpapers.test.ts b/src/lib/wallpapers.test.ts new file mode 100644 index 00000000..f848501a --- /dev/null +++ b/src/lib/wallpapers.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + BUILT_IN_WALLPAPERS, + DEFAULT_WALLPAPER_PATH, + DEFAULT_WALLPAPER_RELATIVE_PATH, + getAvailableWallpapers, +} from "./wallpapers"; + +describe("wallpapers", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps the curated wallpaper list and default path aligned", () => { + expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/midnight-8.jpg"); + expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/midnight-8.jpg"); + expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH); + expect(BUILT_IN_WALLPAPERS).toHaveLength(25); + }); + + it("preserves the curated order when asset discovery returns extra files", async () => { + vi.stubGlobal("window", { + electronAPI: { + listAssetDirectory: vi.fn(async () => ({ + success: true, + files: [ + "wallpaper1.jpg", + "energy-17.jpg", + "midnight-8.jpg", + "wallpaper4.jpg", + "wispysky.mp4", + "cityscape.jpg", + "ipad-17-light.jpg", + ], + })), + }, + }); + + await expect(getAvailableWallpapers()).resolves.toEqual([ + BUILT_IN_WALLPAPERS[0], + BUILT_IN_WALLPAPERS[2], + BUILT_IN_WALLPAPERS[15], + BUILT_IN_WALLPAPERS[16], + BUILT_IN_WALLPAPERS[23], + BUILT_IN_WALLPAPERS[24], + ]); + }); +}); \ No newline at end of file diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts index ee964c77..b72ce7cb 100644 --- a/src/lib/wallpapers.ts +++ b/src/lib/wallpapers.ts @@ -9,158 +9,39 @@ const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i; const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i; export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [ - { - id: "wallpaper-1", - label: "Wallpaper 1", - relativePath: "wallpapers/wallpaper1.jpg", - publicPath: "/wallpapers/wallpaper1.jpg", - }, - { - id: "wallpaper-2", - label: "Wallpaper 2", - relativePath: "wallpapers/wallpaper2.jpg", - publicPath: "/wallpapers/wallpaper2.jpg", - }, - { - id: "wallpaper-3", - label: "Wallpaper 3", - relativePath: "wallpapers/wallpaper3.jpg", - publicPath: "/wallpapers/wallpaper3.jpg", - }, - { - id: "wallpaper-4", - label: "Wallpaper 4", - relativePath: "wallpapers/wallpaper4.jpg", - publicPath: "/wallpapers/wallpaper4.jpg", - }, - { - id: "wallpaper-5", - label: "Wallpaper 5", - relativePath: "wallpapers/wallpaper5.jpg", - publicPath: "/wallpapers/wallpaper5.jpg", - }, - { - id: "wallpaper-6", - label: "Wallpaper 6", - relativePath: "wallpapers/wallpaper6.jpg", - publicPath: "/wallpapers/wallpaper6.jpg", - }, - { - id: "wallpaper-7", - label: "Wallpaper 7", - relativePath: "wallpapers/wallpaper7.jpg", - publicPath: "/wallpapers/wallpaper7.jpg", - }, - { - id: "wallpaper-8", - label: "Wallpaper 8", - relativePath: "wallpapers/wallpaper8.jpg", - publicPath: "/wallpapers/wallpaper8.jpg", - }, - { - id: "wallpaper-9", - label: "Wallpaper 9", - relativePath: "wallpapers/wallpaper9.jpg", - publicPath: "/wallpapers/wallpaper9.jpg", - }, - { - id: "wallpaper-10", - label: "Wallpaper 10", - relativePath: "wallpapers/wallpaper10.jpg", - publicPath: "/wallpapers/wallpaper10.jpg", - }, - { - id: "wallpaper-11", - label: "Wallpaper 11", - relativePath: "wallpapers/wallpaper11.jpg", - publicPath: "/wallpapers/wallpaper11.jpg", - }, - { - id: "wallpaper-12", - label: "Wallpaper 12", - relativePath: "wallpapers/wallpaper12.jpg", - publicPath: "/wallpapers/wallpaper12.jpg", - }, - { - id: "wallpaper-13", - label: "Wallpaper 13", - relativePath: "wallpapers/wallpaper13.jpg", - publicPath: "/wallpapers/wallpaper13.jpg", - }, - { - id: "wallpaper-14", - label: "Wallpaper 14", - relativePath: "wallpapers/wallpaper14.jpg", - publicPath: "/wallpapers/wallpaper14.jpg", - }, - { - id: "wallpaper-15", - label: "Wallpaper 15", - relativePath: "wallpapers/wallpaper15.jpg", - publicPath: "/wallpapers/wallpaper15.jpg", - }, - { - id: "wallpaper-16", - label: "Wallpaper 16", - relativePath: "wallpapers/wallpaper16.jpg", - publicPath: "/wallpapers/wallpaper16.jpg", - }, - { - id: "wallpaper-17", - label: "Wallpaper 17", - relativePath: "wallpapers/wallpaper17.jpg", - publicPath: "/wallpapers/wallpaper17.jpg", - }, - { - id: "wallpaper-18", - label: "Wallpaper 18", - relativePath: "wallpapers/wallpaper18.jpg", - publicPath: "/wallpapers/wallpaper18.jpg", - }, - { - id: "cityscape", - label: "Cityscape", - relativePath: "wallpapers/cityscape.jpg", - publicPath: "/wallpapers/cityscape.jpg", - }, - { - id: "farmvalley", - label: "Farm Valley", - relativePath: "wallpapers/farmvalley.jpg", - publicPath: "/wallpapers/farmvalley.jpg", - }, - { - id: "levels", - label: "Levels", - relativePath: "wallpapers/levels.jpg", - publicPath: "/wallpapers/levels.jpg", - }, - { - id: "mountaintrees", - label: "Mountain Trees", - relativePath: "wallpapers/mountaintrees.jpg", - publicPath: "/wallpapers/mountaintrees.jpg", - }, - { - id: "luisdelrio", - label: "Luis Del Rio", - relativePath: "wallpapers/luisdelrio.jpg", - publicPath: "/wallpapers/luisdelrio.jpg", - }, - { - id: "wispysky", - label: "Wispy Sky", - relativePath: "wallpapers/wispysky.mp4", - publicPath: "/wallpapers/wispysky.mp4", - }, + createWallpaperEntry("midnight-8.jpg", "Midnight 8"), + createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"), + createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"), + createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"), + createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"), + createWallpaperEntry("ventura.jpg", "Ventura"), + createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"), + createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"), + createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"), + createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"), + createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"), + createWallpaperEntry("glassmorphism-3.jpg", "Glassmorphism 3"), + createWallpaperEntry("glassmorphism-4.jpg", "Glassmorphism 4"), + createWallpaperEntry("energy-19.jpg", "Energy 19"), + createWallpaperEntry("wallpaper3.jpg", "Wallpaper 3"), + createWallpaperEntry("wallpaper4.jpg", "Wallpaper 4"), + createWallpaperEntry("cityscape.jpg", "Cityscape"), + createWallpaperEntry("levels.jpg", "Levels"), + createWallpaperEntry("wallpaper10.jpg", "Wallpaper 10"), + createWallpaperEntry("ventura-dark.jpg", "Ventura Dark"), + createWallpaperEntry("sonoma-evening.jpg", "Sonoma Evening"), + createWallpaperEntry("sonoma-horizon.jpg", "Sonoma Horizon"), + createWallpaperEntry("iridescent-9.jpg", "Iridescent 9"), + createWallpaperEntry("energy-17.jpg", "Energy 17"), + createWallpaperEntry("wispysky.mp4", "Wispy Sky"), ]; export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath); export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map( (wallpaper) => wallpaper.relativePath, ); -export const DEFAULT_WALLPAPER_PATH = "/wallpapers/wallpaper2.jpg"; -export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/wallpaper2.jpg"; +export const DEFAULT_WALLPAPER_PATH = "/wallpapers/midnight-8.jpg"; +export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/midnight-8.jpg"; export function isVideoWallpaperSource(value: string): boolean { if (!value) { @@ -192,22 +73,16 @@ function toWallpaperLabel(fileName: string) { .replace(/\b\w/g, (match) => match.toUpperCase()); } -function createWallpaperEntry(fileName: string): BuiltInWallpaper { +function createWallpaperEntry(fileName: string, label = toWallpaperLabel(fileName)): BuiltInWallpaper { const encodedFileName = encodeURIComponent(fileName); return { id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`, - label: toWallpaperLabel(fileName), + label, relativePath: `wallpapers/${fileName}`, publicPath: `/wallpapers/${encodedFileName}`, }; } -function sortWallpaperFiles(fileNames: string[]) { - return [...fileNames].sort( - new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare, - ); -} - export async function getAvailableWallpapers(): Promise { const fallbackWallpapers = BUILT_IN_WALLPAPERS; @@ -221,18 +96,22 @@ export async function getAvailableWallpapers(): Promise { return fallbackWallpapers; } - const discoveredFiles = sortWallpaperFiles( + const discoveredFiles = new Set( result.files.filter( (fileName) => IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName), ), ); - if (discoveredFiles.length === 0) { + if (discoveredFiles.size === 0) { return fallbackWallpapers; } - return discoveredFiles.map(createWallpaperEntry); + const curatedWallpapers = fallbackWallpapers.filter((wallpaper) => + discoveredFiles.has(wallpaper.relativePath.replace(/^wallpapers\//, "")), + ); + + return curatedWallpapers.length > 0 ? curatedWallpapers : fallbackWallpapers; } catch { return fallbackWallpapers; }