From 023a8a27a0fc7c5da334974eb5671dd76c4bb692 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:51:52 +1100 Subject: [PATCH] fix: stabilize editor preview and wallpaper defaults Follow-up to PR #69: fix: restore editor preview responsiveness after export (#68). --- electron/electron-env.d.ts | 5 ++ electron/ipc/handlers.ts | 39 +++++++++-- electron/preload.ts | 3 + src/components/video-editor/SettingsPanel.tsx | 33 ++++++--- src/components/video-editor/VideoEditor.tsx | 31 ++------ src/components/video-editor/VideoPlayback.tsx | 16 +++-- .../video-editor/editorPreferences.test.ts | 13 ++++ .../video-editor/projectPersistence.ts | 4 +- src/components/video-editor/types.ts | 2 +- .../videoPlayback/videoEventHandlers.ts | 12 ---- src/lib/wallpapers.ts | 70 ++++++++++++++++++- 11 files changed, 164 insertions(+), 64 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6ae43fe3..262d6a6d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -78,6 +78,11 @@ interface Window { fileName: string, ) => Promise<{ success: boolean; path?: string; message?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + listAssetDirectory: (relativeDir: string) => Promise<{ + success: boolean; + files?: string[]; + error?: string; + }>; readLocalFile: ( filePath: string, ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index a7c8f260..a4e38c7c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -27,6 +27,14 @@ const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env['VITE_DEV_SERVER_URL']) const RECORDING_SESSION_MANIFEST_SUFFIX = '.recordly-session.json' +function getAssetRootPath() { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'assets') + } + + return path.join(app.getAppPath(), 'public') +} + function getScreen() { return nodeRequire('electron').screen as typeof import('electron').screen } @@ -2869,11 +2877,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} // Return base path for assets so renderer can resolve file:// paths in production ipcMain.handle('get-asset-base-path', () => { try { - if (app.isPackaged) { - const assetPath = path.join(process.resourcesPath, 'assets') - return pathToFileURL(`${assetPath}${path.sep}`).toString() - } - const assetPath = path.join(app.getAppPath(), 'public') + const assetPath = getAssetRootPath() return pathToFileURL(`${assetPath}${path.sep}`).toString() } catch (err) { console.error('Failed to resolve asset base path:', err) @@ -2881,6 +2885,31 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } }) + ipcMain.handle('list-asset-directory', async (_, relativeDir: string) => { + try { + const normalizedRelativeDir = String(relativeDir ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + + const assetRootPath = path.resolve(getAssetRootPath()) + const targetDirPath = path.resolve(assetRootPath, normalizedRelativeDir) + if (targetDirPath !== assetRootPath && !targetDirPath.startsWith(`${assetRootPath}${path.sep}`)) { + return { success: false, error: 'Invalid asset directory' } + } + + const entries = await fs.readdir(targetDirPath, { withFileTypes: true }) + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare) + + return { success: true, files } + } catch (error) { + console.error('Failed to list asset directory:', error) + return { success: false, error: String(error) } + } + }) + ipcMain.handle('read-local-file', async (_, filePath: string) => { try { const data = await fs.readFile(filePath) diff --git a/electron/preload.ts b/electron/preload.ts index 06659046..1f6f48b6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld("electronAPI", { getAssetBasePath: async () => { return await ipcRenderer.invoke("get-asset-base-path"); }, + listAssetDirectory: (relativeDir: string) => { + return ipcRenderer.invoke("list-asset-directory", relativeDir); + }, readLocalFile: (filePath: string) => { return ipcRenderer.invoke("read-local-file", filePath); }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b61edfe4..8c1cf2a8 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -12,7 +12,8 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; import { cn } from "@/lib/utils"; -import { BUILT_IN_WALLPAPERS, WALLPAPER_PATHS, WALLPAPER_RELATIVE_PATHS } from "@/lib/wallpapers"; +import { BUILT_IN_WALLPAPERS, getAvailableWallpapers } from "@/lib/wallpapers"; +import type { BuiltInWallpaper } from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; @@ -253,6 +254,9 @@ export function SettingsPanel({ const { t } = useI18n(); const isBackgroundPanel = panelMode === "background"; const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); + const [builtInWallpapers, setBuiltInWallpapers] = useState( + BUILT_IN_WALLPAPERS, + ); const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); const [customImages, setCustomImages] = useState( initialEditorPreferences.customWallpapers, @@ -262,19 +266,30 @@ export function SettingsPanel({ padding: number; } | null>(null); const fileInputRef = useRef(null); + const builtInWallpaperPaths = useMemo( + () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), + [builtInWallpapers], + ); useEffect(() => { let mounted = true; (async () => { try { + const availableWallpapers = await getAvailableWallpapers(); const resolved = await Promise.all( - WALLPAPER_RELATIVE_PATHS.map(async (path) => - getRenderableAssetUrl(await getAssetPath(path)), + availableWallpapers.map(async (wallpaper) => + getRenderableAssetUrl(await getAssetPath(wallpaper.relativePath)), ), ); - if (mounted) setWallpaperPreviewPaths(resolved); + if (mounted) { + setBuiltInWallpapers(availableWallpapers); + setWallpaperPreviewPaths(resolved); + } } catch { - if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); + if (mounted) { + setBuiltInWallpapers(BUILT_IN_WALLPAPERS); + setWallpaperPreviewPaths(BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath)); + } } })(); return () => { @@ -575,7 +590,7 @@ export function SettingsPanel({ setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); // If the removed image was selected, clear selection if (selected === imageUrl) { - onWallpaperChange(WALLPAPER_PATHS[0]); + onWallpaperChange(builtInWallpaperPaths[0] ?? BUILT_IN_WALLPAPERS[0]?.publicPath ?? ""); } }; @@ -686,10 +701,10 @@ export function SettingsPanel({ {(wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths - : WALLPAPER_PATHS + : builtInWallpaperPaths ).map((previewPath, index) => { - const wallpaper = BUILT_IN_WALLPAPERS[index]; - const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath; + const wallpaper = builtInWallpapers[index] ?? BUILT_IN_WALLPAPERS[index]; + const wallpaperValue = wallpaper?.publicPath ?? builtInWallpaperPaths[index] ?? previewPath; const isSelected = getWallpaperTileState(wallpaperValue, previewPath); return renderWallpaperImageTile(previewPath, isSelected, { key: wallpaperValue, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e043870a..11535e63 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -15,7 +15,6 @@ import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import type { AppLocale } from "@/i18n/config"; import { SUPPORTED_LOCALES } from "@/i18n/config"; -import { getAssetPath } from "@/lib/assetPath"; import { calculateOutputDimensions, type ExportFormat, @@ -29,7 +28,6 @@ import { VideoExporter, } from "@/lib/exporter"; import { matchesShortcut } from "@/lib/shortcuts"; -import { DEFAULT_WALLPAPER_RELATIVE_PATH } from "@/lib/wallpapers"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; @@ -196,6 +194,7 @@ export default function VideoEditor() { const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const [showCropModal, setShowCropModal] = useState(false); + const [previewVersion, setPreviewVersion] = useState(0); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); @@ -1108,31 +1107,12 @@ export default function VideoEditor() { autoSuggestedVideoPathRef.current = videoPath; }, [videoPath, duration, effectiveCursorTelemetry, loopCursor, zoomRegions.length]); - // Initialize default wallpaper with resolved asset path - useEffect(() => { - let mounted = true; - (async () => { - try { - const resolvedPath = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); - if (mounted) { - setWallpaper(resolvedPath); - } - } catch (err) { - // If resolution fails, keep the fallback - console.warn("Failed to resolve default wallpaper path:", err); - } - })(); - return () => { - mounted = false; - }; - }, []); - function togglePlayPause() { const playback = videoPlaybackRef.current; const video = playback?.video; if (!playback || !video) return; - if (isPlaying) { + if (!video.paused && !video.ended) { playback.pause(); } else { playback.play().catch((err) => console.error("Video play failed:", err)); @@ -1968,7 +1948,6 @@ export default function VideoEditor() { videoPlaybackRef.current?.play(); } else { video.currentTime = restoreTime; - await videoPlaybackRef.current?.refreshFrame(); } } catch (error) { console.error("Export error:", error); @@ -1976,13 +1955,11 @@ export default function VideoEditor() { setExportError(errorMessage); toast.error(`Export failed: ${errorMessage}`); } finally { - if (!isPlaying) { - await videoPlaybackRef.current?.refreshFrame().catch(() => undefined); - } setIsExporting(false); exporterRef.current = null; setShowExportDropdown(keepExportDialogOpen); setExportProgress(null); + setPreviewVersion((version) => version + 1); } }, [ @@ -2421,7 +2398,7 @@ export default function VideoEditor() { }} > ( cursorSwayRef.current = cursorSway; }, [cursorSway]); + useEffect(() => { + currentTimeRef.current = currentTime * 1000; + }, [currentTime]); + useEffect(() => { if (!pixiReady || !videoReady) return; @@ -672,11 +676,7 @@ const VideoPlayback = forwardRef( } animationStateRef.current = createPlaybackAnimationState(); - - // Reset cursor overlay smoothing on layout change cursorOverlayRef.current?.reset(); - - // Reset motion blur state for clean transitions motionBlurStateRef.current = createMotionBlurState(); if (blurFilterRef.current) { @@ -1248,10 +1248,14 @@ const VideoPlayback = forwardRef( ) => { const video = e.currentTarget; onDurationChange(video.duration); - video.currentTime = 0; + const targetTime = clampMediaTimeToDuration( + currentTime, + Number.isFinite(video.duration) ? video.duration : null, + ); + video.currentTime = targetTime; video.pause(); allowPlaybackRef.current = false; - currentTimeRef.current = 0; + currentTimeRef.current = targetTime * 1000; if (videoReadyRafRef.current) { cancelAnimationFrame(videoReadyRafRef.current); diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index fce25280..009aff45 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -101,6 +101,19 @@ describe("editorPreferences", () => { }); }); + it("preserves a stored wallpaper choice on startup", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + wallpaper: "/wallpapers/wallpaper1.jpg", + }), + }), + ); + + expect(loadEditorPreferences().wallpaper).toBe("/wallpapers/wallpaper1.jpg"); + }); + it("preserves the last valid custom aspect inputs while typing", () => { const localStorage = createStorageMock({ [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 0b42b53d..e7d90232 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,5 +1,5 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; -import { WALLPAPER_PATHS } from "@/lib/wallpapers"; +import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, @@ -369,7 +369,7 @@ export function normalizeProjectEditor(editor: Partial): Pro : null; return { - wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], + wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH, shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, backgroundBlur: normalizedBackgroundBlur, zoomMotionBlur: normalizedZoomMotionBlur, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 1b5da4a7..d224958e 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -79,7 +79,7 @@ export const DEFAULT_CURSOR_SWAY = 0.25; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; export const DEFAULT_WEBCAM_SIZE = 40; export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true; -export const DEFAULT_WEBCAM_CORNER_RADIUS = 82; +export const DEFAULT_WEBCAM_CORNER_RADIUS = 90; export const DEFAULT_WEBCAM_SHADOW = 0.67; export const DEFAULT_WEBCAM_MARGIN = 24; export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right"; diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index a74d582c..9ee7ae54 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -78,11 +78,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { } const handlePlay = () => { - if (isSeekingRef.current) { - video.pause(); - return; - } - if (!allowPlaybackRef.current) { video.pause(); return; @@ -123,19 +118,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { emitTime(skipToTime); } } else { - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } emitTime(video.currentTime); } }; const handleSeeking = () => { isSeekingRef.current = true; - - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } emitTime(video.currentTime); }; diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts index 4540fe6c..944e1619 100644 --- a/src/lib/wallpapers.ts +++ b/src/lib/wallpapers.ts @@ -5,6 +5,8 @@ export interface BuiltInWallpaper { publicPath: string; } +const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/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' }, @@ -33,5 +35,69 @@ export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [ 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 = WALLPAPER_PATHS[0]; -export const DEFAULT_WALLPAPER_RELATIVE_PATH = WALLPAPER_RELATIVE_PATHS[0]; +export const DEFAULT_WALLPAPER_PATH = '/wallpapers/wallpaper2.jpg'; +export const DEFAULT_WALLPAPER_RELATIVE_PATH = 'wallpapers/wallpaper2.jpg'; + +function toWallpaperId(fileName: string) { + return fileName + .replace(/\.[^.]+$/, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function toWallpaperLabel(fileName: string) { + const baseName = fileName.replace(/\.[^.]+$/, "").trim(); + if (!baseName) { + return "Wallpaper"; + } + + return baseName + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + +function createWallpaperEntry(fileName: string): BuiltInWallpaper { + const encodedFileName = encodeURIComponent(fileName); + return { + id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`, + label: toWallpaperLabel(fileName), + 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; + + if (typeof window === "undefined" || !window.electronAPI?.listAssetDirectory) { + return fallbackWallpapers; + } + + try { + const result = await window.electronAPI.listAssetDirectory("wallpapers"); + if (!result.success || !result.files?.length) { + return fallbackWallpapers; + } + + const discoveredFiles = sortWallpaperFiles( + result.files.filter((fileName) => IMAGE_FILE_PATTERN.test(fileName)), + ); + + if (discoveredFiles.length === 0) { + return fallbackWallpapers; + } + + return discoveredFiles.map(createWallpaperEntry); + } catch { + return fallbackWallpapers; + } +}