Skip to content

Commit 5f65767

Browse files
normalize paths on all OS
1 parent 16dea49 commit 5f65767

5 files changed

Lines changed: 119 additions & 31 deletions

File tree

electron/ipc/handlers.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import { fileURLToPath, pathToFileURL } from "node:url";
34
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
45
import { RECORDINGS_DIR } from "../main";
56

@@ -18,6 +19,27 @@ function normalizePath(filePath: string) {
1819
return path.resolve(filePath);
1920
}
2021

22+
function normalizeVideoSourcePath(videoPath?: string | null): string | null {
23+
if (typeof videoPath !== "string") {
24+
return null;
25+
}
26+
27+
const trimmed = videoPath.trim();
28+
if (!trimmed) {
29+
return null;
30+
}
31+
32+
if (/^file:\/\//i.test(trimmed)) {
33+
try {
34+
return fileURLToPath(trimmed);
35+
} catch {
36+
// Fall through and keep best-effort string path below.
37+
}
38+
}
39+
40+
return trimmed;
41+
}
42+
2143
function isTrustedProjectPath(filePath?: string | null) {
2244
if (!filePath || !currentProjectPath) {
2345
return false;
@@ -199,7 +221,7 @@ export function registerIpcHandlers(
199221
});
200222

201223
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
202-
const targetVideoPath = videoPath ?? currentVideoPath;
224+
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
203225
if (!targetVideoPath) {
204226
return { success: true, samples: [] };
205227
}
@@ -265,9 +287,11 @@ export function registerIpcHandlers(
265287
ipcMain.handle("get-asset-base-path", () => {
266288
try {
267289
if (app.isPackaged) {
268-
return path.join(process.resourcesPath, "assets");
290+
const assetPath = path.join(process.resourcesPath, "assets");
291+
return pathToFileURL(`${assetPath}${path.sep}`).toString();
269292
}
270-
return path.join(app.getAppPath(), "public", "assets");
293+
const assetPath = path.join(app.getAppPath(), "public", "assets");
294+
return pathToFileURL(`${assetPath}${path.sep}`).toString();
271295
} catch (err) {
272296
console.error("Failed to resolve asset base path:", err);
273297
return null;
@@ -456,7 +480,7 @@ export function registerIpcHandlers(
456480
const project = JSON.parse(content);
457481
currentProjectPath = filePath;
458482
if (project && typeof project === "object" && typeof project.videoPath === "string") {
459-
currentVideoPath = project.videoPath;
483+
currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath;
460484
}
461485

462486
return {
@@ -483,7 +507,7 @@ export function registerIpcHandlers(
483507
const content = await fs.readFile(currentProjectPath, "utf-8");
484508
const project = JSON.parse(content);
485509
if (project && typeof project === "object" && typeof project.videoPath === "string") {
486-
currentVideoPath = project.videoPath;
510+
currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath;
487511
}
488512
return {
489513
success: true,
@@ -500,7 +524,7 @@ export function registerIpcHandlers(
500524
}
501525
});
502526
ipcMain.handle("set-current-video-path", (_, path: string) => {
503-
currentVideoPath = path;
527+
currentVideoPath = normalizeVideoSourcePath(path) ?? path;
504528
currentProjectPath = null;
505529
return { success: true };
506530
});

src/components/video-editor/VideoEditor.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export default function VideoEditor() {
118118
}
119119

120120
const project = candidate;
121-
const sourcePath = project.videoPath;
121+
const sourcePath = fromFileUrl(project.videoPath);
122122
const normalizedEditor = normalizeProjectEditor(project.editor);
123123

124124
try {
@@ -259,8 +259,9 @@ export default function VideoEditor() {
259259

260260
const result = await window.electronAPI.getCurrentVideoPath();
261261
if (result.success && result.path) {
262-
setVideoSourcePath(result.path);
263-
setVideoPath(toFileUrl(result.path));
262+
const sourcePath = fromFileUrl(result.path);
263+
setVideoSourcePath(sourcePath);
264+
setVideoPath(toFileUrl(sourcePath));
264265
setCurrentProjectPath(null);
265266
setLastSavedSnapshot(null);
266267
} else {

src/components/video-editor/projectPersistence.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,50 @@ function clamp(value: number, min: number, max: number) {
5858
return Math.min(max, Math.max(min, value));
5959
}
6060

61+
function isFileUrl(value: string): boolean {
62+
return /^file:\/\//i.test(value);
63+
}
64+
65+
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
66+
return pathname
67+
.split("/")
68+
.map((segment, index) => {
69+
if (!segment) return "";
70+
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
71+
return segment;
72+
}
73+
return encodeURIComponent(segment);
74+
})
75+
.join("/");
76+
}
77+
6178
export function toFileUrl(filePath: string): string {
6279
const normalized = filePath.replace(/\\/g, "/");
63-
if (normalized.match(/^[a-zA-Z]:/)) {
64-
return `file:///${encodeURI(normalized)}`;
80+
81+
// Windows drive path: C:/Users/...
82+
if (/^[a-zA-Z]:\//.test(normalized)) {
83+
return `file://${encodePathSegments(`/${normalized}`, true)}`;
6584
}
66-
return `file://${encodeURI(normalized)}`;
85+
86+
// UNC path: //server/share/...
87+
if (normalized.startsWith("//")) {
88+
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
89+
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
90+
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
91+
}
92+
93+
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
94+
return `file://${encodePathSegments(absolutePath)}`;
6795
}
6896

6997
export function fromFileUrl(fileUrl: string): string {
70-
if (!fileUrl.startsWith("file://")) {
98+
const value = fileUrl.trim();
99+
if (!isFileUrl(value)) {
71100
return fileUrl;
72101
}
73102

74103
try {
75-
const url = new URL(fileUrl);
104+
const url = new URL(value);
76105
const pathname = decodeURIComponent(url.pathname);
77106

78107
if (url.host && url.host !== "localhost") {
@@ -85,7 +114,13 @@ export function fromFileUrl(fileUrl: string): string {
85114

86115
return pathname;
87116
} catch {
88-
const fallbackPath = decodeURIComponent(fileUrl.replace(/^file:\/\//, ""));
117+
const rawFallbackPath = value.replace(/^file:\/\//i, "");
118+
let fallbackPath = rawFallbackPath;
119+
try {
120+
fallbackPath = decodeURIComponent(rawFallbackPath);
121+
} catch {
122+
// Keep raw best-effort path if percent decoding fails.
123+
}
89124
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
90125
}
91126
}

src/lib/assetPath.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1+
function encodeRelativeAssetPath(relativePath: string): string {
2+
return relativePath
3+
.replace(/^\/+/, "")
4+
.split("/")
5+
.filter(Boolean)
6+
.map((part) => encodeURIComponent(part))
7+
.join("/");
8+
}
9+
10+
function ensureTrailingSlash(value: string): string {
11+
return value.endsWith("/") ? value : `${value}/`;
12+
}
13+
114
export async function getAssetPath(relativePath: string): Promise<string> {
15+
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
16+
217
try {
318
if (typeof window !== "undefined") {
419
// If running in a dev server (http/https), prefer the web-served path
@@ -7,14 +22,13 @@ export async function getAssetPath(relativePath: string): Promise<string> {
722
window.location.protocol &&
823
window.location.protocol.startsWith("http")
924
) {
10-
return `/${relativePath.replace(/^\//, "")}`;
25+
return `/${encodedRelativePath}`;
1126
}
1227

1328
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
1429
const base = await window.electronAPI.getAssetBasePath();
1530
if (base) {
16-
const normalized = base.replace(/\\/g, "/");
17-
return `file://${normalized}/${relativePath}`;
31+
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
1832
}
1933
}
2034
}
@@ -23,7 +37,7 @@ export async function getAssetPath(relativePath: string): Promise<string> {
2337
}
2438

2539
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
26-
return `/${relativePath.replace(/^\//, "")}`;
40+
return `/${encodedRelativePath}`;
2741
}
2842

2943
export default getAssetPath;

src/lib/exporter/frameRenderer.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
2424
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
2525
import { applyZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform";
26+
import { getAssetPath } from "@/lib/assetPath";
2627
import { renderAnnotations } from "./annotationRenderer";
2728

2829
interface FrameRenderConfig {
@@ -178,18 +179,14 @@ export class FrameRenderer {
178179
) {
179180
// Image background
180181
const img = new Image();
181-
// Don't set crossOrigin for same-origin images to avoid CORS taint
182-
// Only set it for cross-origin URLs
183-
let imageUrl: string;
184-
if (wallpaper.startsWith("http")) {
185-
imageUrl = wallpaper;
186-
if (!imageUrl.startsWith(window.location.origin)) {
187-
img.crossOrigin = "anonymous";
188-
}
189-
} else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) {
190-
imageUrl = wallpaper;
191-
} else {
192-
imageUrl = window.location.origin + wallpaper;
182+
const imageUrl = await this.resolveWallpaperImageUrl(wallpaper);
183+
// Don't set crossOrigin for same-origin images to avoid CORS taint.
184+
if (
185+
imageUrl.startsWith("http") &&
186+
window.location.origin &&
187+
!imageUrl.startsWith(window.location.origin)
188+
) {
189+
img.crossOrigin = "anonymous";
193190
}
194191

195192
await new Promise<void>((resolve, reject) => {
@@ -283,6 +280,23 @@ export class FrameRenderer {
283280
this.backgroundSprite = bgCanvas;
284281
}
285282

283+
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
284+
if (
285+
wallpaper.startsWith("file://") ||
286+
wallpaper.startsWith("data:") ||
287+
wallpaper.startsWith("http")
288+
) {
289+
return wallpaper;
290+
}
291+
292+
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ""));
293+
if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) {
294+
return `${window.location.origin}${resolved}`;
295+
}
296+
297+
return resolved;
298+
}
299+
286300
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
287301
if (!this.app || !this.videoContainer || !this.cameraContainer) {
288302
throw new Error("Renderer not initialized");

0 commit comments

Comments
 (0)