diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b0bf353..70d3ae4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron"; import { RECORDINGS_DIR } from "../main"; @@ -18,6 +19,27 @@ function normalizePath(filePath: string) { return path.resolve(filePath); } +function normalizeVideoSourcePath(videoPath?: string | null): string | null { + if (typeof videoPath !== "string") { + return null; + } + + const trimmed = videoPath.trim(); + if (!trimmed) { + return null; + } + + if (/^file:\/\//i.test(trimmed)) { + try { + return fileURLToPath(trimmed); + } catch { + // Fall through and keep best-effort string path below. + } + } + + return trimmed; +} + function isTrustedProjectPath(filePath?: string | null) { if (!filePath || !currentProjectPath) { return false; @@ -199,7 +221,7 @@ export function registerIpcHandlers( }); ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { - const targetVideoPath = videoPath ?? currentVideoPath; + const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); if (!targetVideoPath) { return { success: true, samples: [] }; } @@ -265,9 +287,11 @@ export function registerIpcHandlers( ipcMain.handle("get-asset-base-path", () => { try { if (app.isPackaged) { - return path.join(process.resourcesPath, "assets"); + const assetPath = path.join(process.resourcesPath, "assets"); + return pathToFileURL(`${assetPath}${path.sep}`).toString(); } - return path.join(app.getAppPath(), "public", "assets"); + const assetPath = path.join(app.getAppPath(), "public", "assets"); + return pathToFileURL(`${assetPath}${path.sep}`).toString(); } catch (err) { console.error("Failed to resolve asset base path:", err); return null; @@ -456,7 +480,7 @@ export function registerIpcHandlers( const project = JSON.parse(content); currentProjectPath = filePath; if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = project.videoPath; + currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; } return { @@ -483,7 +507,7 @@ export function registerIpcHandlers( const content = await fs.readFile(currentProjectPath, "utf-8"); const project = JSON.parse(content); if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = project.videoPath; + currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; } return { success: true, @@ -500,7 +524,7 @@ export function registerIpcHandlers( } }); ipcMain.handle("set-current-video-path", (_, path: string) => { - currentVideoPath = path; + currentVideoPath = normalizeVideoSourcePath(path) ?? path; currentProjectPath = null; return { success: true }; }); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 67e81d7..49aab9b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -118,7 +118,7 @@ export default function VideoEditor() { } const project = candidate; - const sourcePath = project.videoPath; + const sourcePath = fromFileUrl(project.videoPath); const normalizedEditor = normalizeProjectEditor(project.editor); try { @@ -259,8 +259,9 @@ export default function VideoEditor() { const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { - setVideoSourcePath(result.path); - setVideoPath(toFileUrl(result.path)); + const sourcePath = fromFileUrl(result.path); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); setCurrentProjectPath(null); setLastSavedSnapshot(null); } else { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index b1aa346..a73a1a8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -58,21 +58,50 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function isFileUrl(value: string): boolean { + return /^file:\/\//i.test(value); +} + +function encodePathSegments(pathname: string, keepWindowsDrive = false): string { + return pathname + .split("/") + .map((segment, index) => { + if (!segment) return ""; + if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) { + return segment; + } + return encodeURIComponent(segment); + }) + .join("/"); +} + export function toFileUrl(filePath: string): string { const normalized = filePath.replace(/\\/g, "/"); - if (normalized.match(/^[a-zA-Z]:/)) { - return `file:///${encodeURI(normalized)}`; + + // Windows drive path: C:/Users/... + if (/^[a-zA-Z]:\//.test(normalized)) { + return `file://${encodePathSegments(`/${normalized}`, true)}`; } - return `file://${encodeURI(normalized)}`; + + // UNC path: //server/share/... + if (normalized.startsWith("//")) { + const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/"); + const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); + return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`; + } + + const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodePathSegments(absolutePath)}`; } export function fromFileUrl(fileUrl: string): string { - if (!fileUrl.startsWith("file://")) { + const value = fileUrl.trim(); + if (!isFileUrl(value)) { return fileUrl; } try { - const url = new URL(fileUrl); + const url = new URL(value); const pathname = decodeURIComponent(url.pathname); if (url.host && url.host !== "localhost") { @@ -85,7 +114,13 @@ export function fromFileUrl(fileUrl: string): string { return pathname; } catch { - const fallbackPath = decodeURIComponent(fileUrl.replace(/^file:\/\//, "")); + const rawFallbackPath = value.replace(/^file:\/\//i, ""); + let fallbackPath = rawFallbackPath; + try { + fallbackPath = decodeURIComponent(rawFallbackPath); + } catch { + // Keep raw best-effort path if percent decoding fails. + } return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1"); } } diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index 121983c..8188de5 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -1,4 +1,19 @@ +function encodeRelativeAssetPath(relativePath: string): string { + return relativePath + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + export async function getAssetPath(relativePath: string): Promise { + const encodedRelativePath = encodeRelativeAssetPath(relativePath); + try { if (typeof window !== "undefined") { // If running in a dev server (http/https), prefer the web-served path @@ -7,14 +22,13 @@ export async function getAssetPath(relativePath: string): Promise { window.location.protocol && window.location.protocol.startsWith("http") ) { - return `/${relativePath.replace(/^\//, "")}`; + return `/${encodedRelativePath}`; } if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") { const base = await window.electronAPI.getAssetBasePath(); if (base) { - const normalized = base.replace(/\\/g, "/"); - return `file://${normalized}/${relativePath}`; + return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString(); } } } @@ -23,7 +37,7 @@ export async function getAssetPath(relativePath: string): Promise { } // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...' - return `/${relativePath.replace(/^\//, "")}`; + return `/${encodedRelativePath}`; } export default getAssetPath; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index dbbb1f3..7ad5204 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -23,6 +23,7 @@ import { import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { applyZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform"; +import { getAssetPath } from "@/lib/assetPath"; import { renderAnnotations } from "./annotationRenderer"; interface FrameRenderConfig { @@ -178,18 +179,14 @@ export class FrameRenderer { ) { // Image background const img = new Image(); - // Don't set crossOrigin for same-origin images to avoid CORS taint - // Only set it for cross-origin URLs - let imageUrl: string; - if (wallpaper.startsWith("http")) { - imageUrl = wallpaper; - if (!imageUrl.startsWith(window.location.origin)) { - img.crossOrigin = "anonymous"; - } - } else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) { - imageUrl = wallpaper; - } else { - imageUrl = window.location.origin + wallpaper; + const imageUrl = await this.resolveWallpaperImageUrl(wallpaper); + // Don't set crossOrigin for same-origin images to avoid CORS taint. + if ( + imageUrl.startsWith("http") && + window.location.origin && + !imageUrl.startsWith(window.location.origin) + ) { + img.crossOrigin = "anonymous"; } await new Promise((resolve, reject) => { @@ -283,6 +280,23 @@ export class FrameRenderer { this.backgroundSprite = bgCanvas; } + private async resolveWallpaperImageUrl(wallpaper: string): Promise { + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("http") + ) { + return wallpaper; + } + + const resolved = await getAssetPath(wallpaper.replace(/^\/+/, "")); + if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) { + return `${window.location.origin}${resolved}`; + } + + return resolved; + } + async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { if (!this.app || !this.videoContainer || !this.cameraContainer) { throw new Error("Renderer not initialized");