mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
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:
Vendored
+3
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user