diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c6abd25b..4eff6ece 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -330,6 +330,9 @@ interface Window { getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; deleteRecordingFile: (filePath: string) => Promise<{ success: boolean; error?: string }>; + getLocalMediaUrl: (filePath: string) => Promise< + { success: true; url: string } | { success: false } + >; saveProjectFile: ( projectData: unknown, suggestedName?: string, diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index cf9b6208..1831247b 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; +import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import { PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSIONS, @@ -14,6 +15,7 @@ import { setCurrentVideoPath, currentRecordingSession, setCurrentRecordingSession, + approvedLocalReadPaths, } from "../state"; import { normalizeVideoSourcePath } from "../utils"; import { isPathInsideDirectory, replaceApprovedSessionLocalReadPaths } from "../project/manager"; @@ -377,4 +379,17 @@ export function registerProjectHandlers() { } }); + ipcMain.handle('get-local-media-url', (_, filePath: string) => { + const baseUrl = getMediaServerBaseUrl(); + if (!baseUrl || !filePath) { + return { success: false as const }; + } + const resolved = path.resolve(filePath); + if (!approvedLocalReadPaths.has(resolved)) { + console.warn(`[get-local-media-url] Blocked unapproved path: ${resolved}`); + return { success: false as const }; + } + return { success: true as const, url: buildMediaUrl(baseUrl, filePath) }; + }); + } diff --git a/electron/main.ts b/electron/main.ts index 04c4efa6..4a572ac4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,6 +23,7 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import { ensureMediaServer } from "./mediaServer"; import { ensurePackagedRendererServer } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { @@ -836,6 +837,12 @@ app.whenReady().then(async () => { } } + try { + await ensureMediaServer(); + } catch (error) { + console.warn("[media-server] Failed to start media server:", error); + } + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts new file mode 100644 index 00000000..2f790a97 --- /dev/null +++ b/electron/mediaServer.ts @@ -0,0 +1,234 @@ +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; +import { approvedLocalReadPaths } from "./ipc/state"; + +const MEDIA_MIME_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", +}; + +let mediaServerBaseUrl: string | null = null; +let mediaServerStartPromise: Promise | null = null; + +function getMediaContentType(filePath: string): string { + return MEDIA_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +async function resolveRealPath(filePath: string): Promise { + try { + return await fs.realpath(path.resolve(filePath)); + } catch { + return null; + } +} + +function isAllowedMediaPath(realPath: string): boolean { + return approvedLocalReadPaths.has(realPath); +} + +async function handleMediaRequest( + request: IncomingMessage, + response: ServerResponse, +): Promise { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + + if (url.pathname !== "/video") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + const rawPath = url.searchParams.get("path"); + if (!rawPath) { + response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Missing path parameter"); + return; + } + + const resolvedPath = await resolveRealPath(rawPath); + if (!resolvedPath || !isAllowedMediaPath(resolvedPath)) { + console.warn(`[media-server] Blocked access to unapproved path: ${rawPath}`); + response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + const stat = await fs.stat(resolvedPath); + if (!stat.isFile()) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + const contentType = getMediaContentType(resolvedPath); + const fileSize = stat.size; + const rangeHeader = request.headers.range; + + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "false", + "Access-Control-Expose-Headers": "Content-Range, Content-Length, Accept-Ranges", + }; + + if (request.method === "OPTIONS") { + response.writeHead(204, { + ...corsHeaders, + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "Range", + }); + response.end(); + return; + } + + if (rangeHeader) { + const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); + if (!match || (!match[1] && !match[2])) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); + response.end(); + return; + } + + let start: number; + let end: number; + + if (!match[1] && match[2]) { + // Suffix range: bytes=-500 + const suffixLength = Number.parseInt(match[2], 10); + if (Number.isNaN(suffixLength) || suffixLength <= 0) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); + response.end(); + return; + } + start = Math.max(0, fileSize - suffixLength); + end = fileSize - 1; + } else { + start = Number.parseInt(match[1], 10); + end = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1; + } + + if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= fileSize || end >= fileSize) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */${fileSize}` }); + response.end(); + return; + } + + if (fileSize === 0) { + response.writeHead(416, { ...corsHeaders, "Content-Range": `bytes */0` }); + response.end(); + return; + } + + const chunkSize = end - start + 1; + response.writeHead(206, { + ...corsHeaders, + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + "Content-Length": String(chunkSize), + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + const stream = createReadStream(resolvedPath, { start, end }); + stream.pipe(response); + stream.on("error", () => { + if (!response.headersSent) { + response.writeHead(500, { "Content-Type": "text/plain" }); + } + response.end(); + }); + } else { + response.writeHead(200, { + ...corsHeaders, + "Accept-Ranges": "bytes", + "Content-Length": String(fileSize), + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + + const stream = createReadStream(resolvedPath); + stream.pipe(response); + stream.on("error", () => { + if (!response.headersSent) { + response.writeHead(500, { "Content-Type": "text/plain" }); + } + response.end(); + }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not Found"); + return; + } + + console.error("[media-server] Error handling request:", error); + response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Internal Server Error"); + } +} + +export function getMediaServerBaseUrl(): string | null { + return mediaServerBaseUrl; +} + +export async function ensureMediaServer(): Promise { + if (mediaServerBaseUrl) { + return mediaServerBaseUrl; + } + + if (mediaServerStartPromise) { + return mediaServerStartPromise; + } + + mediaServerStartPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleMediaRequest(request, response); + }); + + server.once("error", (error) => { + reject(error); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Media server did not expose a TCP address")); + return; + } + + mediaServerBaseUrl = `http://127.0.0.1:${address.port}`; + console.log(`[media-server] Listening at ${mediaServerBaseUrl}`); + resolve(mediaServerBaseUrl); + }); + }); + + return mediaServerStartPromise; +} + +export function buildMediaUrl(baseUrl: string, filePath: string): string { + const resolved = path.resolve(filePath); + return `${baseUrl}/video?path=${encodeURIComponent(resolved)}`; +} diff --git a/electron/preload.ts b/electron/preload.ts index 1c5eb6fa..4ae5d8bf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -388,6 +388,11 @@ contextBridge.exposeInMainWorld("electronAPI", { deleteRecordingFile: (filePath: string) => { return ipcRenderer.invoke("delete-recording-file", filePath); }, + getLocalMediaUrl: (filePath: string) => { + return ipcRenderer.invoke("get-local-media-url", filePath) as Promise< + { success: true; url: string } | { success: false } + >; + }, saveProjectFile: ( projectData: unknown, suggestedName?: string, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c63d2339..4fef5c49 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -110,6 +110,7 @@ import { type EditorProjectData, fromFileUrl, normalizeProjectEditor, + resolveVideoUrl, toFileUrl, validateProjectData, } from "./projectPersistence"; @@ -551,6 +552,7 @@ export default function VideoEditor() { const [webcam, setWebcam] = useState( initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, ); + const [resolvedWebcamVideoUrl, setResolvedWebcamVideoUrl] = useState(null); const [zoomRegions, setZoomRegions] = useState([]); const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); @@ -1459,7 +1461,7 @@ export default function VideoEditor() { setError(null); setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); + setVideoPath(await resolveVideoUrl(sourcePath)); setCurrentProjectPath(path ?? null); pendingFreshRecordingAutoZoomPathRef.current = null; if (normalizedEditor.webcam.sourcePath) { @@ -1687,7 +1689,7 @@ export default function VideoEditor() { } const sourcePath = fromFileUrl(smokeExportConfig.inputPath); - const sourceVideoUrl = toFileUrl(sourcePath); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); const smokeWebcamSourcePath = smokeExportConfig.webcamInputPath ? fromFileUrl(smokeExportConfig.webcamInputPath) : null; @@ -1746,7 +1748,7 @@ export default function VideoEditor() { const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); if (sessionResult?.success && sessionResult.session?.videoPath) { const sourcePath = fromFileUrl(sessionResult.session.videoPath); - const sourceVideoUrl = toFileUrl(sourcePath); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); setVideoSourcePath(sourcePath); setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); @@ -1765,7 +1767,7 @@ export default function VideoEditor() { const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const sourcePath = fromFileUrl(result.path); - const sourceVideoUrl = toFileUrl(sourcePath); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); setVideoSourcePath(sourcePath); setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); @@ -1798,6 +1800,21 @@ export default function VideoEditor() { smokeExportConfig.webcamSize, ]); + useEffect(() => { + let cancelled = false; + if (!webcam.sourcePath) { + setResolvedWebcamVideoUrl(null); + return; + } + setResolvedWebcamVideoUrl(null); + void resolveVideoUrl(webcam.sourcePath).then((url) => { + if (!cancelled) setResolvedWebcamVideoUrl(url); + }); + return () => { + cancelled = true; + }; + }, [webcam.sourcePath]); + useEffect(() => { if (!autoApplyFreshRecordingAutoZooms) { pendingFreshRecordingAutoZoomPathRef.current = null; @@ -2016,7 +2033,7 @@ export default function VideoEditor() { if (sourcePath !== videoSourcePath) { setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); + setVideoPath(await resolveVideoUrl(sourcePath)); } await syncActiveVideoSource(sourcePath, webcam.sourcePath ?? null); @@ -2216,7 +2233,7 @@ export default function VideoEditor() { let retryAttempts = 0; async function loadCursorTelemetry() { - if (!videoPath) { + if (!videoPath || !videoSourcePath) { if (mounted) { setCursorTelemetry([]); } @@ -2224,7 +2241,7 @@ export default function VideoEditor() { } try { - const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath)); + const result = await window.electronAPI.getCursorTelemetry(videoSourcePath); if (mounted) { const samples = result.success ? result.samples : []; setCursorTelemetry(samples); @@ -2279,7 +2296,7 @@ export default function VideoEditor() { pendingTelemetryRetryTimeoutRef.current = null; } }; - }, [videoPath]); + }, [videoPath, videoSourcePath]); const normalizedCursorTelemetry = useMemo(() => { if (cursorTelemetry.length === 0) { @@ -4909,7 +4926,7 @@ export default function VideoEditor() { webcam={webcam} webcamVideoPath={ webcam.sourcePath - ? toFileUrl(webcam.sourcePath) + ? resolvedWebcamVideoUrl : null } trimRegions={trimRegions} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index e6021d90..045d9304 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2441,7 +2441,26 @@ const VideoPlayback = forwardRef( onDurationChange={(e) => { onDurationChange(e.currentTarget.duration); }} - onError={() => onError("Failed to load video")} + onError={(e) => { + const mediaError = e.currentTarget.error; + const code = mediaError?.code; + const msg = mediaError?.message; + const detail = + code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ? "format not supported" + : code === MediaError.MEDIA_ERR_NETWORK + ? "network error" + : code === MediaError.MEDIA_ERR_DECODE + ? "decode error" + : msg || `code ${code ?? "unknown"}`; + console.error( + "[VideoPlayback] Video load error:", + detail, + "src:", + videoPath, + ); + onError(`Failed to load video (${detail})`); + }} /> ); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 3ce02651..81c59744 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -258,6 +258,25 @@ export function deriveNextId(prefix: string, ids: string[]): number { return max + 1; } +/** + * Resolve a local file path to a URL the `