Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 24 additions & 23 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
/// <reference types="vite-plugin-electron/electron-env" />
/// <reference types="vite-plugin-electron/electron-env" />

declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
}

type CursorTrackMetadata = {
Expand Down Expand Up @@ -148,7 +148,7 @@ interface Window {
openPermissionSettings: (target: PermissionSettingsTarget) => Promise<{ success: boolean; message?: string }>
openPermissionChecker: () => Promise<{ success: boolean }>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: unknown) => Promise<unknown>
getSelectedSource: () => Promise<unknown>
storeRecordedVideo: (
Expand Down Expand Up @@ -226,7 +226,8 @@ interface Window {
path?: string
metadata?: { frameRate?: number; width?: number; height?: number; mimeType?: string; capturedAt?: number; systemCursorMode?: 'always' | 'never'; hasMicrophoneAudio?: boolean; cursorTrack?: CursorTrackMetadata }
}>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
readCurrentVideoBytes: () => Promise<{ success: boolean; buffer?: ArrayBuffer; error?: string }>
getPlatform: () => Promise<string>
startVideoAnalysis: (options?: {
videoPath?: string
Expand Down Expand Up @@ -269,7 +270,7 @@ interface Window {
hudOverlayClose: () => void;
}
}

interface ProcessedDesktopSource {
id: string
name: string
Expand Down
16 changes: 16 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,22 @@ export function registerIpcHandlers(
return { success: true };
});

ipcMain.handle('read-current-video-bytes', async () => {
if (!currentVideoPath) {
return { success: false, error: 'No current video path is set.' };
}
try {
const data = await fs.readFile(currentVideoPath);
const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
return { success: true, buffer };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
});

ipcMain.handle('get-platform', () => {
return process.platform;
});
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path')
},
readCurrentVideoBytes: () => {
return ipcRenderer.invoke('read-current-video-bytes')
},
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
Expand Down
83 changes: 58 additions & 25 deletions src/lib/exporter/videoExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type AudioEnergyStats,
type NormalizedExportAudioProcessingConfig,
} from '@/lib/audio/exportAudioProcessing';
import { ALL_FORMATS, AudioBufferSink, BlobSource, Input, UrlSource, type InputAudioTrack } from 'mediabunny';
import { ALL_FORMATS, AudioBufferSink, BlobSource, BufferSource, Input, UrlSource, type InputAudioTrack } from 'mediabunny';

interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
Expand Down Expand Up @@ -348,40 +348,73 @@ export class VideoExporter {
this.sourceAudioTrack = null;
}

private async openSourceInputFromNativeBytes(): Promise<Input | null> {
const readCurrentVideoBytes = window.electronAPI?.readCurrentVideoBytes;
if (typeof readCurrentVideoBytes !== 'function') {
return null;
}

const result = await readCurrentVideoBytes();
if (!result.success || !result.buffer) {
throw new Error(`Failed to read source media bytes: ${result.error ?? 'unknown error'}`);
}

return new Input({
formats: ALL_FORMATS,
source: new BufferSource(result.buffer),
});
}

private async resolveSourceAudioTrack(): Promise<boolean> {
this.disposeSourceAudioInput();
if (this.config.audioEnabled === false) {
return false;
}

try {
const input = await this.openSourceInputFromUrl();
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack) {
this.sourceAudioInput = input;
this.sourceAudioTrack = audioTrack;
return true;
const attempts: Array<{ label: string; open: () => Promise<Input | null> }> = [
{ label: 'UrlSource', open: () => this.openSourceInputFromUrl() },
{ label: 'BlobSource', open: () => this.openSourceInputFromBlob() },
{ label: 'NativeBytes', open: () => this.openSourceInputFromNativeBytes() },
];

const errors: unknown[] = [];

for (const attempt of attempts) {
let input: Input | null = null;
try {
input = await attempt.open();
if (!input) {
continue;
}
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack) {
this.sourceAudioInput = input;
this.sourceAudioTrack = audioTrack;
return true;
}
// The container parsed successfully and genuinely has no audio track.
input.dispose();
return false;
} catch (error) {
errors.push(error);
console.warn(`[VideoExporter] Unable to read source audio via ${attempt.label}.`, error);
if (input) {
try {
input.dispose();
} catch { /* already failed */ }
}
}
input.dispose();
} catch (urlError) {
console.warn('[VideoExporter] Unable to read source audio via UrlSource. Retrying with BlobSource.', urlError);
}

try {
const input = await this.openSourceInputFromBlob();
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack) {
this.sourceAudioInput = input;
this.sourceAudioTrack = audioTrack;
return true;
}
input.dispose();
return false;
} catch (blobError) {
console.warn('[VideoExporter] Audio track extraction failed; continuing with video-only export.', blobError);
this.disposeSourceAudioInput();
return false;
this.disposeSourceAudioInput();
if (errors.length > 0) {
const detail = errors[errors.length - 1];
throw new Error(
`Source audio track could not be read (${errors.length} attempts failed). ` +
`Last error: ${detail instanceof Error ? detail.message : String(detail)}`,
);
}
return false;
}

private addWarning(warningKey: string): void {
Expand Down
55 changes: 55 additions & 0 deletions src/lib/exporter/videoExporterAudio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,58 @@ describe('VideoExporter createAudioSlice', () => {
expect(result!.getChannelData(1)[0]).toBeCloseTo(0.6, 6);
});
});

describe('VideoExporter resolveSourceAudioTrack', () => {
function makeInput(audioTrack: unknown): any {
return {
getPrimaryAudioTrack: async () => audioTrack,
dispose: () => {},
};
}

it('returns false without attempting extraction when audio is disabled', async () => {
const exporter = makeExporter();
exporter.config.audioEnabled = false;
exporter.openSourceInputFromUrl = async () => { throw new Error('should not be called'); };
exporter.openSourceInputFromBlob = async () => { throw new Error('should not be called'); };
exporter.openSourceInputFromNativeBytes = async () => { throw new Error('should not be called'); };

await expect(exporter.resolveSourceAudioTrack()).resolves.toBe(false);
});

it('falls back to the next source when an earlier one fails', async () => {
const exporter = makeExporter();
const track = { codec: 'aac' };
exporter.openSourceInputFromUrl = async () => { throw new Error('url read failed'); };
exporter.openSourceInputFromBlob = async () => { throw new Error('blob read failed'); };
exporter.openSourceInputFromNativeBytes = async () => makeInput(track);

await expect(exporter.resolveSourceAudioTrack()).resolves.toBe(true);
expect(exporter.sourceAudioTrack).toBe(track);
});

it('returns false when the container parses but has no audio track', async () => {
const exporter = makeExporter();
exporter.openSourceInputFromUrl = async () => makeInput(null);

await expect(exporter.resolveSourceAudioTrack()).resolves.toBe(false);
});

it('throws instead of silently degrading when every source fails', async () => {
const exporter = makeExporter();
exporter.openSourceInputFromUrl = async () => { throw new Error('url read failed'); };
exporter.openSourceInputFromBlob = async () => { throw new Error('blob read failed'); };
exporter.openSourceInputFromNativeBytes = async () => { throw new Error('ipc read failed'); };

await expect(exporter.resolveSourceAudioTrack()).rejects.toThrow(/Source audio track could not be read/);
});

it('skips the native-bytes source when the bridge is unavailable', async () => {
const exporter = makeExporter();
exporter.openSourceInputFromUrl = async () => { throw new Error('url read failed'); };
exporter.openSourceInputFromBlob = async () => { throw new Error('blob read failed'); };
exporter.openSourceInputFromNativeBytes = async () => null;

await expect(exporter.resolveSourceAudioTrack()).rejects.toThrow(/2 attempts failed/);
});
});
11 changes: 6 additions & 5 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />

interface ProcessedDesktopSource {
id: string;
name: string;
Expand Down Expand Up @@ -123,7 +123,7 @@ type VideoAnalysisMetadata = {
subtitleCues: SubtitleCueMetadata[];
roughCutSuggestions: RoughCutSuggestionMetadata[];
};

interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
Expand All @@ -137,7 +137,7 @@ interface Window {
openPermissionSettings: (target: PermissionSettingsTarget) => Promise<{ success: boolean; message?: string }>
openPermissionChecker: () => Promise<{ success: boolean }>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: unknown) => Promise<unknown>
getSelectedSource: () => Promise<unknown>
storeRecordedVideo: (
Expand Down Expand Up @@ -233,6 +233,7 @@ interface Window {
metadata?: { frameRate?: number; width?: number; height?: number; mimeType?: string; capturedAt?: number; systemCursorMode?: 'always' | 'never'; hasMicrophoneAudio?: boolean; cursorTrack?: CursorTrackMetadata }
}>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
readCurrentVideoBytes: () => Promise<{ success: boolean; buffer?: ArrayBuffer; error?: string }>
getPlatform: () => Promise<string>
startVideoAnalysis: (options?: {
videoPath?: string
Expand Down