diff --git a/electron/export/export-handler.ts b/electron/export/export-handler.ts index 5a9014984..a09588445 100644 --- a/electron/export/export-handler.ts +++ b/electron/export/export-handler.ts @@ -119,4 +119,52 @@ export function registerExportHandlers(): void { stopExportProcess() return { success: true } }) + + handle('exportAudio', async ({ clips, outputPath, format, sampleRate, bitrate }) => { + const ffmpegPath = findFfmpegPath() + if (!ffmpegPath) return { success: false, error: 'FFmpeg not found' } + + try { + validatePath(outputPath, getAllowedRoots()) + for (const clip of clips) { + const fp = clip.path + if (fp) validatePath(fp, getAllowedRoots()) + } + } catch (err) { + return { success: false, error: String(err) } + } + + const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0) + if (totalDuration <= 0) return { success: false, error: 'No clips to export' } + + const tmpDir = os.tmpdir() + const tmpRawPcm = path.join(tmpDir, `ltx-export-audio-${Date.now()}.raw`) + const cleanup = () => { try { fs.unlinkSync(tmpRawPcm) } catch {} } + + try { + logger.info('[Export] Audio-only export: mixing down PCM') + const { pcmBuffer, sampleRate: pcmRate, channels } = await mixAudioToPcm(clips, totalDuration, ffmpegPath) + fs.writeFileSync(tmpRawPcm, pcmBuffer) + + const codecArgs: Record = { + mp3: ['-c:a', 'libmp3lame', '-b:a', `${bitrate || 192}k`], + aac: ['-c:a', 'aac', '-b:a', `${bitrate || 192}k`], + wav: ['-c:a', 'pcm_s16le'], + flac: ['-c:a', 'flac'], + } + if (!codecArgs[format]) { cleanup(); return { success: false, error: `Unknown format: ${format}` } } + + const r = await runFfmpeg(ffmpegPath, [ + '-y', '-f', 's16le', '-ar', String(pcmRate), '-ac', String(channels), '-i', tmpRawPcm, + ...codecArgs[format], '-ar', String(sampleRate), outputPath, + ]) + cleanup() + if (!r.success) return { success: false, error: r.error } + logger.info(`[Export] Audio export done: ${outputPath}`) + return { success: true } + } catch (err) { + cleanup() + return { success: false, error: String(err) } + } + }) } diff --git a/frontend/components/ExportModal.tsx b/frontend/components/ExportModal.tsx index 28befde2b..8c2092cdb 100644 --- a/frontend/components/ExportModal.tsx +++ b/frontend/components/ExportModal.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react' -import { X, Download, FolderOpen, Film, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react' +import { X, Download, FolderOpen, Film, Music, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react' import { Button } from './ui/button' import { DEFAULT_SUBTITLE_STYLE } from '../types/project-model' import type { Track, TimelineClip } from '../types/project-model' @@ -20,6 +20,8 @@ interface ExportModalProps { type ExportStatus = 'idle' | 'exporting' | 'done' | 'error' type ExportCodec = 'h264' | 'prores' | 'vp9' +type ExportMode = 'video' | 'audio' +type AudioFormat = 'mp3' | 'aac' | 'wav' | 'flac' interface ExportSettings { codec: ExportCodec @@ -29,12 +31,32 @@ interface ExportSettings { quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9 } +interface ExportAudioSettings { + format: AudioFormat + sampleRate: number + bitrate: number // kbps, only used for lossy formats +} + const CODEC_INFO: Record = { h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' }, prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' }, vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' }, } +const AUDIO_FORMAT_INFO: Record = { + mp3: { label: 'MP3', ext: 'mp3', description: 'Most compatible, small size', filterName: 'MP3 Audio', lossy: true }, + aac: { label: 'AAC', ext: 'm4a', description: 'Smaller size, high quality', filterName: 'AAC Audio', lossy: true }, + wav: { label: 'WAV', ext: 'wav', description: 'Uncompressed', filterName: 'WAV Audio', lossy: false }, + flac: { label: 'FLAC', ext: 'flac', description: 'Lossless compression', filterName: 'FLAC Audio', lossy: false }, +} + +const SAMPLE_RATES = [ + { value: 44100, label: '44.1 kHz' }, + { value: 48000, label: '48 kHz' }, +] + +const BITRATES = [128, 192, 256, 320] + const RESOLUTIONS = [ { label: '4K (3840 x 2160)', width: 3840, height: 2160 }, { label: '1080p (1920 x 1080)', width: 1920, height: 1080 }, @@ -220,7 +242,7 @@ export function ExportModal({ projectName }: ExportModalProps) { }, [clips, tracks]) const [exportStatus, setExportStatus] = useState('idle') - const [exportType, setExportType] = useState<'package' | 'video' | null>(null) + const [exportType, setExportType] = useState<'package' | 'video' | 'audio' | null>(null) const [exportProgress, setExportProgress] = useState(0) const [exportError, setExportError] = useState(null) const [exportPath, setExportPath] = useState(null) @@ -228,6 +250,7 @@ export function ExportModal({ projectName }: ExportModalProps) { const abortRef = useRef(false) // Export settings + const [exportMode, setExportMode] = useState('video') const [settings, setSettings] = useState({ codec: 'h264', width: 1920, @@ -235,6 +258,11 @@ export function ExportModal({ projectName }: ExportModalProps) { fps: 24, quality: 18, // CRF 18 for h264 }) + const [audioSettings, setAudioSettings] = useState({ + format: 'mp3', + sampleRate: 44100, + bitrate: 192, + }) const [burnSubtitles, setBurnSubtitles] = useState(true) const closeModal = useCallback(() => { @@ -355,6 +383,56 @@ export function ExportModal({ projectName }: ExportModalProps) { } }, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline]) + const handleExportAudio = useCallback(async () => { + if (!timeline || clips.length === 0) return + setExportType('audio') + setExportStatus('exporting') + setExportProgress(0) + setExportError(null) + setExportFrameInfo('Preparing...') + abortRef.current = false + + try { + const formatInfo = AUDIO_FORMAT_INFO[audioSettings.format] + + const filePath = await window.electronAPI?.showSaveDialog({ + title: `Export ${formatInfo.label}`, + defaultPath: `${projectName}_${timeline.name}.${formatInfo.ext}`, + filters: [ + { name: formatInfo.filterName, extensions: [formatInfo.ext] }, + { name: 'All Files', extensions: ['*'] }, + ], + }) + + if (!filePath) { + setExportStatus('idle') + return + } + + setExportFrameInfo('Mixing audio...') + + const result = await window.electronAPI?.exportAudio({ + clips: exportClips, + outputPath: filePath, + format: audioSettings.format, + sampleRate: audioSettings.sampleRate, + bitrate: audioSettings.bitrate, + }) + + if (result && !result.success) { + throw new Error(result.error) + } + + setExportProgress(100) + setExportPath(filePath) + setExportFrameInfo('Export complete') + setExportStatus('done') + } catch (err) { + setExportError(String(err)) + setExportStatus('error') + } + }, [audioSettings, exportClips, projectName, timeline]) + const handleCancel = useCallback(async () => { abortRef.current = true window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {}) @@ -385,11 +463,11 @@ export function ExportModal({ projectName }: ExportModalProps) {
- {exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'} + {exportType === 'package' ? 'Generating FCPXML...' : exportType === 'audio' ? 'Extracting audio...' : 'Rendering video...'}
-
@@ -398,7 +476,7 @@ export function ExportModal({ projectName }: ExportModalProps) {

{exportProgress}% complete

{exportFrameInfo &&

{exportFrameInfo}

}
- {exportType === 'video' && ( + {(exportType === 'video' || exportType === 'audio') && ( - {/* Divider */} + {/* Divider + mode toggle */}
- Video Export + Export
- - {/* Format selector */} -
- -
- {(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => ( - - ))} -
+
+ +
- {/* Resolution & Frame rate row */} -
-
- -
- - +
-
-
- -
- - -
-
-
- {/* Quality */} -
- - {settings.codec === 'h264' && ( -
- setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} - className="flex-1 h-1.5 accent-blue-500 cursor-pointer" - // Note: lower CRF = higher quality (inverted display) - /> - - {settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'} - ({settings.quality}) - + {/* Resolution & Frame rate row */} +
+
+ +
+ + +
+
+
+ +
+ + +
+
- )} - {settings.codec === 'prores' && ( -
- {PRORES_PROFILES.map(p => ( - - ))} + + {/* Quality */} +
+ + {settings.codec === 'h264' && ( +
+ setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} + className="flex-1 h-1.5 accent-blue-500 cursor-pointer" + // Note: lower CRF = higher quality (inverted display) + /> + + {settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'} + ({settings.quality}) + +
+ )} + {settings.codec === 'prores' && ( +
+ {PRORES_PROFILES.map(p => ( + + ))} +
+ )} + {settings.codec === 'vp9' && ( +
+ setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} + className="flex-1 h-1.5 accent-blue-500 cursor-pointer" + /> + + {settings.quality} Mbps + +
+ )}
- )} - {settings.codec === 'vp9' && ( -
- setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} - className="flex-1 h-1.5 accent-blue-500 cursor-pointer" - /> - - {settings.quality} Mbps - + + {/* Options */} + {hasSubtitles && ( +
+
+
+ Options +
+
+ +
+ )} + + {/* Export button */} + + + )} + + {exportMode === 'audio' && ( + <> + {/* Format selector */} +
+ +
+ {(Object.keys(AUDIO_FORMAT_INFO) as AudioFormat[]).map(format => ( + + ))} +
- )} -
- {/* Options */} - {hasSubtitles && ( -
-
-
- Options -
+ {/* Sample rate */} +
+ +
+ + +
- -
- )} - {/* Export button */} - + {/* Quality (bitrate) — lossy formats only */} + {AUDIO_FORMAT_INFO[audioSettings.format].lossy && ( +
+ +
+ {BITRATES.map(rate => ( + + ))} +
+
+ )} + + {/* Export button */} + + + )} {clips.length === 0 && (

Add clips to the timeline to export.

diff --git a/shared/electron-api-schema.ts b/shared/electron-api-schema.ts index dd8a36d84..db60edbaf 100644 --- a/shared/electron-api-schema.ts +++ b/shared/electron-api-schema.ts @@ -262,6 +262,16 @@ export const electronAPISchemas = { input: z.object({ sessionId: z.string() }), output: emptyResult, }, + exportAudio: { + input: z.object({ + clips: z.array(exportClip), + outputPath: z.string(), + format: z.enum(['mp3', 'aac', 'wav', 'flac']), + sampleRate: z.number(), + bitrate: z.number().optional(), + }), + output: emptyResult, + }, // Python setup checkPythonReady: {