fix(export): handle loopback media URLs during export

This commit is contained in:
webadderall
2026-04-21 19:22:05 +10:00
parent 2e513adef6
commit 4005687de4
7 changed files with 105 additions and 110 deletions
+4 -26
View File
@@ -1,6 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder";
import { getLocalFilePath } from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -38,30 +39,6 @@ export class ForwardFrameSource {
private firstFrameTimestampUs: number | null = null;
private frameTimelineOffsetUs = 0;
private toLocalFilePath(resourceUrl: string): string | null {
if (!resourceUrl.startsWith("file:")) {
return null;
}
try {
const url = new URL(resourceUrl);
let filePath = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
return `\\\\${url.host}${filePath.replace(/\//g, "\\")}`;
}
if (/^\/[A-Za-z]:/.test(filePath)) {
filePath = filePath.slice(1);
}
return filePath;
} catch {
const uncMatch = resourceUrl.match(/^file:\/\/([^/]+)(\/.*)$/i);
if (uncMatch && uncMatch[1].toLowerCase() !== "localhost") {
return `\\\\${uncMatch[1]}${decodeURIComponent(uncMatch[2]).replace(/\//g, "\\")}`;
}
return resourceUrl.replace(/^file:\/\//, "");
}
}
private inferMimeType(fileName: string): string {
const extension = fileName.split(".").pop()?.toLowerCase();
switch (extension) {
@@ -80,8 +57,9 @@ export class ForwardFrameSource {
}
private async loadVideoFile(resourceUrl: string): Promise<File> {
const filename = resourceUrl.split("/").pop() || "video";
const localFilePath = this.toLocalFilePath(resourceUrl);
const localFilePath = getLocalFilePath(resourceUrl);
const filename =
(localFilePath ?? resourceUrl).split(/[\\/]/).pop()?.split("?")[0] || "video";
if (localFilePath) {
const result = await window.electronAPI.readLocalFile(localFilePath);
+20 -1
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMediaElementSource } from "./localMediaSource";
const NativeURL = URL;
describe("resolveMediaElementSource", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -12,10 +14,12 @@ describe("resolveMediaElementSource", () => {
},
},
});
vi.stubGlobal("URL", {
class MockURL extends NativeURL {}
Object.assign(MockURL, {
createObjectURL: vi.fn(() => "blob:mock-local-media"),
revokeObjectURL: vi.fn(),
});
vi.stubGlobal("URL", MockURL);
});
it("reads file URLs through Electron IPC and returns an object URL", async () => {
@@ -48,6 +52,21 @@ describe("resolveMediaElementSource", () => {
expect(result.src).toBe("blob:mock-local-media");
});
it("reads loopback media-server URLs through Electron IPC", async () => {
const readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([7, 8, 9]),
}));
(window as any).electronAPI.readLocalFile = readLocalFile;
const result = await resolveMediaElementSource(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
);
expect(readLocalFile).toHaveBeenCalledWith("/tmp/example clip.mp4");
expect(result.src).toBe("blob:mock-local-media");
});
it("leaves remote URLs untouched", async () => {
const readLocalFile = vi.fn();
(window as any).electronAPI.readLocalFile = readLocalFile;
+39 -3
View File
@@ -2,8 +2,9 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist
const NOOP = () => undefined;
const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i;
const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]);
function isAbsoluteLocalPath(resource: string) {
export function isAbsoluteLocalPath(resource: string) {
return (
resource.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(resource) ||
@@ -11,7 +12,34 @@ function isAbsoluteLocalPath(resource: string) {
);
}
function getLocalFilePath(resource: string) {
function getLocalMediaServerPath(resource: string) {
if (!/^https?:\/\//i.test(resource)) {
return null;
}
try {
const url = new URL(resource);
if (!LOOPBACK_MEDIA_HOSTS.has(url.hostname) || url.pathname !== "/video") {
return null;
}
const mediaPath = url.searchParams.get("path");
return mediaPath && mediaPath.trim().length > 0 ? mediaPath : null;
} catch {
return null;
}
}
export function isLocalMediaServerUrl(resource: string) {
return getLocalMediaServerPath(resource) !== null;
}
export function getLocalFilePath(resource: string) {
const localMediaServerPath = getLocalMediaServerPath(resource);
if (localMediaServerPath) {
return localMediaServerPath;
}
if (/^file:\/\//i.test(resource)) {
return fromFileUrl(resource);
}
@@ -19,12 +47,20 @@ function getLocalFilePath(resource: string) {
return isAbsoluteLocalPath(resource) ? resource : null;
}
function isRemoteMediaResource(resource: string) {
return REMOTE_MEDIA_URL_PATTERN.test(resource) && !isLocalMediaServerUrl(resource);
}
function getNormalizedResourceUrl(resource: string) {
const localFilePath = getLocalFilePath(resource);
if (!localFilePath) {
return resource;
}
if (isLocalMediaServerUrl(resource)) {
return resource;
}
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
}
@@ -51,7 +87,7 @@ export async function resolveMediaElementSource(resource: string): Promise<{
src: string;
revoke: () => void;
}> {
if (!resource || REMOTE_MEDIA_URL_PATTERN.test(resource)) {
if (!resource || isRemoteMediaResource(resource)) {
return { src: resource, revoke: NOOP };
}
+2 -30
View File
@@ -29,6 +29,7 @@ import {
INITIAL_FINALIZATION_PROGRESS_STATE,
withFinalizationTimeout,
} from "./finalizationTimeout";
import { getLocalFilePath } from "./localMediaSource";
import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer";
import {
getOrderedSupportedMp4EncoderCandidates,
@@ -681,36 +682,7 @@ export class ModernVideoExporter {
}
private getNativeVideoSourcePath(): string | null {
const resource = this.config.videoUrl;
if (!resource) {
return null;
}
if (/^file:\/\//i.test(resource)) {
try {
const url = new URL(resource);
const pathname = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
return `//${url.host}${pathname}`;
}
if (/^\/[A-Za-z]:/.test(pathname)) {
return pathname.slice(1);
}
return pathname;
} catch {
return resource.replace(/^file:\/\//i, "");
}
}
if (
resource.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(resource) ||
/^\\\\[^\\]+\\[^\\]+/.test(resource)
) {
return resource;
}
return null;
return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null;
}
private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> {
+34 -1
View File
@@ -1,9 +1,42 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getDecodedFrameStartupOffsetUs,
getDecodedFrameTimelineOffsetUs,
StreamingVideoDecoder,
} from "./streamingDecoder";
describe("StreamingVideoDecoder local media loading", () => {
beforeEach(() => {
vi.restoreAllMocks();
Object.assign(globalThis, {
window: {
electronAPI: {
readLocalFile: vi.fn(),
},
},
});
});
it("loads loopback media-server URLs through Electron IPC instead of fetch", async () => {
const readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([1, 2, 3]),
}));
(window as any).electronAPI.readLocalFile = readLocalFile;
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const decoder = new StreamingVideoDecoder();
const file = await (decoder as any).loadVideoFile(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4",
);
expect(readLocalFile).toHaveBeenCalledWith("/tmp/capture.mp4");
expect(fetchSpy).not.toHaveBeenCalled();
expect(file.name).toBe("capture.mp4");
});
});
describe("getDecodedFrameStartupOffsetUs", () => {
it("ignores positive stream start metadata when the first decoded frame matches it", () => {
expect(
+4 -19
View File
@@ -1,6 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { getLocalFilePath } from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -79,23 +80,6 @@ export class StreamingVideoDecoder {
);
}
private toLocalFilePath(resourceUrl: string): string | null {
if (!resourceUrl.startsWith("file:")) {
return null;
}
try {
const url = new URL(resourceUrl);
let filePath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:/.test(filePath)) {
filePath = filePath.slice(1);
}
return filePath;
} catch {
return resourceUrl.replace(/^file:\/\//, "");
}
}
private inferMimeType(fileName: string): string {
const extension = fileName.split(".").pop()?.toLowerCase();
switch (extension) {
@@ -114,8 +98,9 @@ export class StreamingVideoDecoder {
}
private async loadVideoFile(resourceUrl: string): Promise<File> {
const filename = resourceUrl.split("/").pop() || "video";
const localFilePath = this.toLocalFilePath(resourceUrl);
const localFilePath = getLocalFilePath(resourceUrl);
const filename =
(localFilePath ?? resourceUrl).split(/[\\/]/).pop()?.split("?")[0] || "video";
if (localFilePath) {
const result = await window.electronAPI.readLocalFile(localFilePath);
+2 -30
View File
@@ -21,6 +21,7 @@ import {
withFinalizationTimeout,
} from "./finalizationTimeout";
import { FrameRenderer } from "./frameRenderer";
import { getLocalFilePath } from "./localMediaSource";
import type { SupportedMp4EncoderPath } from "./mp4Support";
import { VideoMuxer } from "./muxer";
import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder";
@@ -448,36 +449,7 @@ export class VideoExporter {
}
private getNativeVideoSourcePath(): string | null {
const resource = this.config.videoUrl;
if (!resource) {
return null;
}
if (/^file:\/\//i.test(resource)) {
try {
const url = new URL(resource);
const pathname = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
return `//${url.host}${pathname}`;
}
if (/^\/[A-Za-z]:/.test(pathname)) {
return pathname.slice(1);
}
return pathname;
} catch {
return resource.replace(/^file:\/\//i, "");
}
}
if (
resource.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(resource) ||
/^\\\\[^\\]+\\[^\\]+/.test(resource)
) {
return resource;
}
return null;
return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null;
}
private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> {