From 0836b9e1e8c50ccca6d9ff2fb55dc7888965441b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:18:53 +1100 Subject: [PATCH] Read export local media via IPC --- src/lib/exporter/audioEncoder.ts | 14 ++-- src/lib/exporter/frameRenderer.ts | 12 +++- src/lib/exporter/localMediaSource.test.ts | 60 ++++++++++++++++ src/lib/exporter/localMediaSource.ts | 84 +++++++++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/lib/exporter/localMediaSource.test.ts create mode 100644 src/lib/exporter/localMediaSource.ts diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index bd1c20cd..7b6b268d 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -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 { + 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 diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index a8cff4d1..d4005c45 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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 { 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; diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts new file mode 100644 index 00000000..2882efb8 --- /dev/null +++ b/src/lib/exporter/localMediaSource.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts new file mode 100644 index 00000000..7aa81585 --- /dev/null +++ b/src/lib/exporter/localMediaSource.ts @@ -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 }; + } +}