mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(export): keep large local media on the streaming path
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user