mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-29 01:05:37 +00:00
Read export local media via IPC
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user