fix(export): keep large local media on the streaming path

This commit is contained in:
wiiiii123
2026-07-10 15:53:18 +07:00
parent 641d2230a4
commit 3c12536a2e
7 changed files with 119 additions and 95 deletions
+3 -6
View File
@@ -1,10 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder";
import {
createReadableMediaResourceFile,
resolveMediaResourceUrl,
} from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -59,7 +56,7 @@ export class ForwardFrameSource {
mediaInfo = await loadMediaInfo(resourceUrl);
} catch (error) {
console.warn(
"[ForwardFrameSource] Direct source load failed, retrying with file fallback:",
"[ForwardFrameSource] Direct source load failed, retrying with a fresh media source:",
error,
);
const currentDemuxer = this.demuxer;
@@ -70,7 +67,7 @@ export class ForwardFrameSource {
// Ignore cleanup errors before fallback re-init.
}
}
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
}
const videoStream = mediaInfo.streams.find(
+46 -22
View File
@@ -1,18 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMediaElementSource } from "./localMediaSource";
import { createFallbackDemuxerSource, resolveMediaElementSource } from "./localMediaSource";
const readLocalFile = vi.fn();
const getLocalMediaUrl = vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
describe("resolveMediaElementSource", () => {
beforeEach(() => {
vi.restoreAllMocks();
readLocalFile.mockReset();
getLocalMediaUrl.mockReset();
getLocalMediaUrl.mockImplementation(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
Object.assign(globalThis, {
window: {
electronAPI: {
readLocalFile: vi.fn(),
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
readLocalFile,
getLocalMediaUrl,
},
},
});
@@ -21,20 +30,16 @@ describe("resolveMediaElementSource", () => {
it("resolves file URLs through the local media server for media elements", async () => {
const result = await resolveMediaElementSource("file:///tmp/example.mp4");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/example.mp4",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.mp4");
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.mp4");
});
it("resolves absolute local paths through the local media server without copying them into blobs", async () => {
const result = await resolveMediaElementSource("/tmp/example.wav");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/example.wav",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.wav");
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav");
});
@@ -43,20 +48,39 @@ describe("resolveMediaElementSource", () => {
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
);
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect((window as any).electronAPI.getLocalMediaUrl).not.toHaveBeenCalled();
expect(result.src).toBe(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4",
);
expect(readLocalFile).not.toHaveBeenCalled();
expect(getLocalMediaUrl).not.toHaveBeenCalled();
expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4");
});
it("leaves remote URLs untouched", async () => {
const readLocalFile = vi.fn();
(window as any).electronAPI.readLocalFile = readLocalFile;
const result = await resolveMediaElementSource("https://example.com/video.mp4");
expect(result.src).toBe("https://example.com/video.mp4");
expect(readLocalFile).not.toHaveBeenCalled();
});
it("keeps local demuxer fallback on the range-streamed media URL", async () => {
const source = await createFallbackDemuxerSource("/tmp/large-recording.mp4");
expect(source).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Flarge-recording.mp4");
expect(readLocalFile).not.toHaveBeenCalled();
});
it("retains the readable File fallback for remote media", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "video/mp4" }),
}));
vi.stubGlobal("fetch", fetchMock);
try {
const source = await createFallbackDemuxerSource("https://example.com/video.mp4");
expect(source).toBeInstanceOf(File);
expect(fetchMock).toHaveBeenCalledWith("https://example.com/video.mp4");
} finally {
vi.unstubAllGlobals();
}
});
});
+14 -21
View File
@@ -116,36 +116,29 @@ export async function resolveMediaResourceUrl(resource: string): Promise<string>
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
}
export async function createReadableMediaResourceFile(resource: string): Promise<File> {
const localFilePath = getLocalFilePath(resource);
const filename = (localFilePath ?? resource).split(/[\\/]/).pop()?.split("?")[0] || "media";
if (localFilePath && typeof window !== "undefined" && window.electronAPI?.readLocalFile) {
const result = await window.electronAPI.readLocalFile(localFilePath);
if (!result.success || !result.data) {
throw new Error(result.error || "Failed to read local media file");
}
const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
return new File([arrayBuffer], filename, { type: inferMimeType(filename) });
}
async function createReadableMediaResourceFile(resource: string): Promise<File> {
const filename = resource.split(/[\\/]/).pop()?.split("?")[0] || "media";
const resourceUrl = await resolveMediaResourceUrl(resource);
const response = await fetch(resourceUrl);
if (!response.ok) {
throw new Error(
`Failed to load media resource: ${response.status} ${response.statusText}`,
);
throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
return new File([blob], filename, { type: blob.type || inferMimeType(filename) });
}
export async function createFallbackDemuxerSource(resource: string): Promise<string | File> {
// Local media already has a random-access transport: WebDemuxer issues bounded
// byte-range requests against this URL. Converting it to a File would copy the
// complete recording through Electron IPC and make memory use scale with file size.
if (getLocalFilePath(resource)) {
return resolveMediaResourceUrl(resource);
}
return createReadableMediaResourceFile(resource);
}
export async function resolveMediaElementSource(resource: string): Promise<{
src: string;
revoke: () => void;
@@ -238,7 +238,7 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.streamingDecoderLoadMetadata).not.toHaveBeenCalled();
}, 15_000);
it("retries the main decode path once with a readable file-backed source", async () => {
it("retries the main decode path once with a fresh media source", async () => {
mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1);
mocks.streamingDecoderDecodeAll
.mockRejectedValueOnce(
@@ -277,13 +277,13 @@ describe("ModernVideoExporter native fallback routing", () => {
expect(mocks.streamingDecoderLoadMetadata.mock.calls[0]).toEqual([
"file:///recording.mp4",
{
forceReadableFileSource: false,
useFallbackMediaSource: false,
},
]);
expect(mocks.streamingDecoderLoadMetadata.mock.calls[1]).toEqual([
"file:///recording.mp4",
{
forceReadableFileSource: true,
useFallbackMediaSource: true,
},
]);
expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2);
+16 -18
View File
@@ -288,7 +288,7 @@ type NativeStaticLayoutZoomSample = {
};
const NATIVE_EXPORT_ENGINE_NAME = "Breeze";
const READABLE_SOURCE_RETRY_ERROR_TOKENS = [
const MEDIA_SOURCE_RETRY_ERROR_TOKENS = [
"readavpacket",
"get_media_info",
"avfoundation",
@@ -371,11 +371,11 @@ export class ModernVideoExporter {
}
async export(): Promise<ExportResult> {
let preferReadableFileSource = false;
let retriedWithReadableFileSource = false;
let useFallbackMediaSource = false;
let retriedWithFallbackMediaSource = false;
while (true) {
let shouldRetryWithReadableFileSource = false;
let shouldRetryWithFallbackMediaSource = false;
try {
this.cleanup();
this.cancelled = false;
@@ -522,7 +522,7 @@ export class ModernVideoExporter {
});
stageStartedAt = this.getNowMs();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, {
forceReadableFileSource: preferReadableFileSource,
useFallbackMediaSource,
});
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
@@ -892,15 +892,15 @@ export class ModernVideoExporter {
};
} catch (error) {
if (
!preferReadableFileSource &&
!retriedWithReadableFileSource &&
this.shouldRetryWithReadableFileSource(error)
!useFallbackMediaSource &&
!retriedWithFallbackMediaSource &&
this.shouldRetryWithFallbackMediaSource(error)
) {
retriedWithReadableFileSource = true;
preferReadableFileSource = true;
shouldRetryWithReadableFileSource = true;
retriedWithFallbackMediaSource = true;
useFallbackMediaSource = true;
shouldRetryWithFallbackMediaSource = true;
console.warn(
"[VideoExporter] Primary decode path failed; retrying export once with a readable file-backed media source.",
"[VideoExporter] Primary decode path failed; retrying export once with a fresh media source.",
error,
);
} else {
@@ -921,7 +921,7 @@ export class ModernVideoExporter {
};
}
} finally {
if (!shouldRetryWithReadableFileSource && this.totalExportStartTimeMs > 0) {
if (!shouldRetryWithFallbackMediaSource && this.totalExportStartTimeMs > 0) {
console.log(
`[VideoExporter] Final metrics ${JSON.stringify(this.buildExportMetrics())}`,
);
@@ -929,20 +929,18 @@ export class ModernVideoExporter {
this.cleanup();
}
if (shouldRetryWithReadableFileSource) {
if (shouldRetryWithFallbackMediaSource) {
continue;
}
}
}
private shouldRetryWithReadableFileSource(error: unknown): boolean {
private shouldRetryWithFallbackMediaSource(error: unknown): boolean {
const resolvedError = this.encoderError ?? error;
const message =
resolvedError instanceof Error ? resolvedError.message : String(resolvedError);
const normalizedMessage = message.toLowerCase();
return READABLE_SOURCE_RETRY_ERROR_TOKENS.some((token) =>
normalizedMessage.includes(token),
);
return MEDIA_SOURCE_RETRY_ERROR_TOKENS.some((token) => normalizedMessage.includes(token));
}
private getPlatformLabel(): string {
+31 -19
View File
@@ -40,6 +40,12 @@ vi.mock("web-demuxer", () => ({
},
}));
const mockReadLocalFile = vi.fn();
const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
describe("StreamingVideoDecoder local media loading", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -47,17 +53,20 @@ describe("StreamingVideoDecoder local media loading", () => {
mockDemuxerGetMediaInfo.mockClear();
mockDemuxerDestroy.mockClear();
mockDemuxerGetDecoderConfig.mockClear();
mockReadLocalFile.mockReset();
mockGetLocalMediaUrl.mockReset();
mockGetLocalMediaUrl.mockImplementation(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
}));
Object.assign(globalThis, {
window: {
location: {
href: "http://localhost:5173/",
},
electronAPI: {
readLocalFile: vi.fn(),
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
readLocalFile: mockReadLocalFile,
getLocalMediaUrl: mockGetLocalMediaUrl,
},
},
});
@@ -67,45 +76,48 @@ describe("StreamingVideoDecoder local media loading", () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4");
expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled();
expect(mockReadLocalFile).not.toHaveBeenCalled();
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4",
);
});
it("normalizes absolute local paths to file URLs before loading them", async () => {
it("resolves absolute local paths to range-streamed media URLs", async () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/capture.mp4");
expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith(
"/tmp/capture.mp4",
);
expect(mockGetLocalMediaUrl).toHaveBeenCalledWith("/tmp/capture.mp4");
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:4321/video?path=%2Ftmp%2Fcapture.mp4",
);
});
it("falls back to a readable File when direct loading fails", async () => {
it("retries the range-streamed URL when direct local loading fails", async () => {
mockDemuxerLoad.mockReset();
mockDemuxerLoad
.mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts"))
.mockResolvedValueOnce(undefined);
(window as any).electronAPI.readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([1, 2, 3]),
}));
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/fallback.mp4");
expect(mockDemuxerLoad).toHaveBeenNthCalledWith(
1,
2,
"http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4",
);
expect(mockDemuxerLoad.mock.calls[1]?.[0]).toBeInstanceOf(File);
expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith(
"/tmp/fallback.mp4",
expect(mockReadLocalFile).not.toHaveBeenCalled();
});
it("keeps an explicit local retry on the range-streamed URL", async () => {
const decoder = new StreamingVideoDecoder();
await decoder.loadMetadata("/tmp/retry.mp4", {
useFallbackMediaSource: true,
});
expect(mockDemuxerLoad).toHaveBeenCalledWith(
"http://127.0.0.1:4321/video?path=%2Ftmp%2Fretry.mp4",
);
expect(mockReadLocalFile).not.toHaveBeenCalled();
});
});
+6 -6
View File
@@ -1,7 +1,7 @@
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource";
import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource";
const DEFAULT_MAX_DECODE_QUEUE = 12;
const DEFAULT_MAX_PENDING_FRAMES = 32;
@@ -24,7 +24,7 @@ export interface DecodedVideoInfo {
}
interface StreamingVideoDecoderLoadOptions {
forceReadableFileSource?: boolean;
useFallbackMediaSource?: boolean;
}
/** Decoder retains ownership of the VideoFrame and closes it after use. */
@@ -126,14 +126,14 @@ export class StreamingVideoDecoder {
};
let mediaInfo;
if (options.forceReadableFileSource) {
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
if (options.useFallbackMediaSource) {
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
} else {
try {
mediaInfo = await loadMediaInfo(resourceUrl);
} catch (error) {
console.warn(
"[StreamingVideoDecoder] Direct source load failed, retrying with file fallback:",
"[StreamingVideoDecoder] Direct source load failed, retrying with a fresh media source:",
error,
);
const currentDemuxer = this.demuxer;
@@ -144,7 +144,7 @@ export class StreamingVideoDecoder {
// Ignore cleanup errors before fallback re-init.
}
}
mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl));
mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl));
}
}