Merge pull request #257 from webadderall/fix/video-loading-windows

fix: serve video files via local HTTP server to fix loading on Windows
This commit is contained in:
webadderall
2026-04-18 17:34:11 +10:00
committed by GitHub
8 changed files with 329 additions and 10 deletions
+3
View File
@@ -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,
+15
View File
@@ -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) };
});
}
+7
View File
@@ -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,
+234
View File
@@ -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<string, string> = {
".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<string> | null = null;
function getMediaContentType(filePath: string): string {
return MEDIA_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
}
async function resolveRealPath(filePath: string): Promise<string | null> {
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<void> {
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<string> {
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)}`;
}
+5
View File
@@ -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,
+26 -9
View File
@@ -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<WebcamOverlaySettings>(
initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY,
);
const [resolvedWebcamVideoUrl, setResolvedWebcamVideoUrl] = useState<string | null>(null);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(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}
+20 -1
View File
@@ -2441,7 +2441,26 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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})`);
}}
/>
</div>
);
@@ -258,6 +258,25 @@ export function deriveNextId(prefix: string, ids: string[]): number {
return max + 1;
}
/**
* Resolve a local file path to a URL the `<video>` element can load.
*
* Prefers the local media HTTP server (works on all platforms regardless of
* Chromium's `file://` restrictions). Falls back to a `file://` URL if the
* media server is unavailable.
*/
export async function resolveVideoUrl(sourcePath: string): Promise<string> {
try {
const result = await window.electronAPI.getLocalMediaUrl(sourcePath);
if (result.success) {
return result.url;
}
} catch {
// Media server unavailable — fall through to file:// URL.
}
return toFileUrl(sourcePath);
}
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;