mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
fix(export): handle loopback media URLs during export
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user