Read export local media via IPC

This commit is contained in:
webadderall
2026-03-18 16:18:53 +11:00
parent 9d17cb7427
commit 0836b9e1e8
4 changed files with 165 additions and 5 deletions
+10 -4
View File
@@ -1,7 +1,7 @@
import { WebDemuxer } from 'web-demuxer'
import type { SpeedRegion, TrimRegion, AudioRegion } from '@/components/video-editor/types'
import { toFileUrl } from '@/components/video-editor/projectPersistence'
import type { VideoMuxer } from './muxer'
import { resolveMediaElementSource } from './localMediaSource'
const AUDIO_BITRATE = 128_000
const DECODE_BACKPRESSURE_LIMIT = 20
@@ -172,8 +172,9 @@ export class AudioProcessor {
speedRegions: SpeedRegion[],
audioRegions: AudioRegion[],
): Promise<Blob> {
const mediaSource = await resolveMediaElementSource(videoUrl)
const media = document.createElement('audio')
media.src = videoUrl
media.src = mediaSource.src
media.preload = 'auto'
const pitchMedia = media as HTMLMediaElement & {
@@ -203,15 +204,18 @@ export class AudioProcessor {
sourceNode: MediaElementAudioSourceNode
gainNode: GainNode
region: AudioRegion
cleanup: () => void
}[] = []
for (const region of audioRegions) {
const regionSource = await resolveMediaElementSource(region.audioPath)
const audioEl = document.createElement('audio')
audioEl.src = toFileUrl(region.audioPath)
audioEl.src = regionSource.src
audioEl.preload = 'auto'
try {
await this.waitForLoadedMetadata(audioEl)
} catch {
regionSource.revoke()
console.warn('[AudioProcessor] Failed to load audio region:', region.audioPath)
continue
}
@@ -223,7 +227,7 @@ export class AudioProcessor {
regionSource.connect(gainNode)
gainNode.connect(destinationNode)
audioRegionElements.push({ media: audioEl, sourceNode: regionSource, gainNode, region })
audioRegionElements.push({ media: audioEl, sourceNode: regionSource, gainNode, region, cleanup: regionSource.revoke })
}
const { recorder, recordedBlobPromise } = this.startAudioRecording(destinationNode.stream)
@@ -327,6 +331,7 @@ export class AudioProcessor {
entry.gainNode.disconnect()
entry.media.src = ''
entry.media.load()
entry.cleanup()
}
if (recorder.state !== 'inactive') {
recorder.stop()
@@ -337,6 +342,7 @@ export class AudioProcessor {
await audioContext.close()
media.src = ''
media.load()
mediaSource.revoke()
}
const recordedBlob = await recordedBlobPromise
+11 -1
View File
@@ -37,6 +37,7 @@ import {
preloadCursorAssets,
} from "@/components/video-editor/videoPlayback/cursorRenderer";
import { getWebcamOverlaySizePx } from "@/components/video-editor/webcamOverlay";
import { resolveMediaElementSource } from "./localMediaSource";
interface FrameRenderConfig {
width: number;
@@ -118,6 +119,7 @@ export class FrameRenderer {
private webcamFrameCacheCanvas: HTMLCanvasElement | null = null;
private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null;
private lastSyncedWebcamTime: number | null = null;
private cleanupWebcamSource: (() => void) | null = null;
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -422,6 +424,8 @@ export class FrameRenderer {
private async setupWebcamSource(): Promise<void> {
const webcamUrl = this.config.webcamUrl;
if (!this.config.webcam?.enabled || !webcamUrl) {
this.cleanupWebcamSource?.();
this.cleanupWebcamSource = null;
this.webcamVideoElement = null;
this.webcamFrameCacheCanvas = null;
this.webcamFrameCacheCtx = null;
@@ -429,8 +433,12 @@ export class FrameRenderer {
return;
}
this.cleanupWebcamSource?.();
const webcamSource = await resolveMediaElementSource(webcamUrl);
this.cleanupWebcamSource = webcamSource.revoke;
const video = document.createElement("video");
video.src = webcamUrl;
video.src = webcamSource.src;
video.muted = true;
video.preload = "auto";
video.playsInline = true;
@@ -1094,6 +1102,8 @@ export class FrameRenderer {
this.webcamVideoElement.load();
this.webcamVideoElement = null;
}
this.cleanupWebcamSource?.();
this.cleanupWebcamSource = null;
this.webcamFrameCacheCanvas = null;
this.webcamFrameCacheCtx = null;
this.lastSyncedWebcamTime = null;
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMediaElementSource } from "./localMediaSource";
describe("resolveMediaElementSource", () => {
beforeEach(() => {
vi.restoreAllMocks();
Object.assign(globalThis, {
window: {
electronAPI: {
readLocalFile: vi.fn(),
},
},
});
vi.stubGlobal("URL", {
createObjectURL: vi.fn(() => "blob:mock-local-media"),
revokeObjectURL: vi.fn(),
});
});
it("reads file URLs through Electron IPC and returns an object URL", async () => {
const readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([1, 2, 3]),
}));
(window as any).electronAPI.readLocalFile = readLocalFile;
const result = await resolveMediaElementSource("file:///tmp/example.mp4");
expect(readLocalFile).toHaveBeenCalledWith("/tmp/example.mp4");
expect(URL.createObjectURL).toHaveBeenCalledOnce();
expect(result.src).toBe("blob:mock-local-media");
result.revoke();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-local-media");
});
it("reads absolute local paths through Electron IPC and normalizes fallback URLs", async () => {
const readLocalFile = vi.fn(async () => ({
success: true,
data: new Uint8Array([4, 5, 6]),
}));
(window as any).electronAPI.readLocalFile = readLocalFile;
const result = await resolveMediaElementSource("/tmp/example.wav");
expect(readLocalFile).toHaveBeenCalledWith("/tmp/example.wav");
expect(result.src).toBe("blob:mock-local-media");
});
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();
});
});
+84
View File
@@ -0,0 +1,84 @@
import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersistence";
const NOOP = () => {};
const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i;
function isAbsoluteLocalPath(resource: string) {
return (
resource.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(resource) ||
/^\\\\[^\\]+\\[^\\]+/.test(resource)
);
}
function getLocalFilePath(resource: string) {
if (/^file:\/\//i.test(resource)) {
return fromFileUrl(resource);
}
return isAbsoluteLocalPath(resource) ? resource : null;
}
function getNormalizedResourceUrl(resource: string) {
const localFilePath = getLocalFilePath(resource);
if (!localFilePath) {
return resource;
}
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
}
function inferMimeType(filePath: string) {
const normalized = filePath.split("?")[0]?.toLowerCase() ?? filePath.toLowerCase();
if (normalized.endsWith(".mp4") || normalized.endsWith(".m4v")) return "video/mp4";
if (normalized.endsWith(".mov")) return "video/quicktime";
if (normalized.endsWith(".webm")) return "video/webm";
if (normalized.endsWith(".mkv")) return "video/x-matroska";
if (normalized.endsWith(".avi")) return "video/x-msvideo";
if (normalized.endsWith(".mp3")) return "audio/mpeg";
if (normalized.endsWith(".wav")) return "audio/wav";
if (normalized.endsWith(".m4a")) return "audio/mp4";
if (normalized.endsWith(".aac")) return "audio/aac";
if (normalized.endsWith(".ogg")) return "audio/ogg";
if (normalized.endsWith(".opus")) return "audio/ogg;codecs=opus";
if (normalized.endsWith(".flac")) return "audio/flac";
return "application/octet-stream";
}
export async function resolveMediaElementSource(resource: string): Promise<{
src: string;
revoke: () => void;
}> {
if (!resource || REMOTE_MEDIA_URL_PATTERN.test(resource)) {
return { src: resource, revoke: NOOP };
}
const normalizedResource = getNormalizedResourceUrl(resource);
const localFilePath = getLocalFilePath(resource) ?? getLocalFilePath(normalizedResource);
if (!localFilePath || typeof window === "undefined" || !window.electronAPI?.readLocalFile) {
return { src: normalizedResource, revoke: NOOP };
}
try {
const result = await window.electronAPI.readLocalFile(localFilePath);
if (!result.success || !result.data) {
return { src: normalizedResource, revoke: NOOP };
}
const bytes =
result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
const blob = new Blob([bytes], { type: inferMimeType(localFilePath) });
const objectUrl = URL.createObjectURL(blob);
return {
src: objectUrl,
revoke: () => {
URL.revokeObjectURL(objectUrl);
},
};
} catch {
return { src: normalizedResource, revoke: NOOP };
}
}