672 lines
26 KiB
TypeScript
672 lines
26 KiB
TypeScript
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
|
import { X, Download, FolderOpen, Film, 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'
|
|
import {
|
|
selectActiveTimeline,
|
|
selectAssets,
|
|
selectClipPathFromAssets,
|
|
selectClips,
|
|
selectShowExportModal,
|
|
selectSubtitles,
|
|
selectTracks,
|
|
} from '../views/editor/editor-selectors'
|
|
import { useEditorActions, useEditorStore } from '../views/editor/editor-store'
|
|
|
|
interface ExportModalProps {
|
|
projectName: string
|
|
}
|
|
|
|
type ExportStatus = 'idle' | 'exporting' | 'done' | 'error'
|
|
type ExportCodec = 'h264' | 'prores' | 'vp9'
|
|
|
|
interface ExportSettings {
|
|
codec: ExportCodec
|
|
width: number
|
|
height: number
|
|
fps: number
|
|
quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9
|
|
}
|
|
|
|
const CODEC_INFO: Record<ExportCodec, { label: string; ext: string; description: string; filterName: string }> = {
|
|
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 RESOLUTIONS = [
|
|
{ label: '4K (3840 x 2160)', width: 3840, height: 2160 },
|
|
{ label: '1080p (1920 x 1080)', width: 1920, height: 1080 },
|
|
{ label: '720p (1280 x 720)', width: 1280, height: 720 },
|
|
]
|
|
|
|
const FRAME_RATES = [24, 25, 30, 60]
|
|
|
|
const PRORES_PROFILES = [
|
|
{ value: 0, label: 'Proxy' },
|
|
{ value: 1, label: 'LT' },
|
|
{ value: 2, label: 'Standard' },
|
|
{ value: 3, label: 'HQ' },
|
|
]
|
|
|
|
const LETTERBOX_RATIO_MAP: Record<string, number> = {
|
|
'2.35:1': 2.35,
|
|
'2.39:1': 2.39,
|
|
'2.76:1': 2.76,
|
|
'1.85:1': 1.85,
|
|
'4:3': 4 / 3,
|
|
}
|
|
|
|
// Generate FCPXML for Premiere / DaVinci
|
|
function generateFCPXML(
|
|
clips: TimelineClip[],
|
|
tracks: Track[],
|
|
projectName: string,
|
|
timelineName: string,
|
|
fps: number = 24
|
|
): string {
|
|
const frameDuration = `${Math.round(100 * fps)}/${100 * fps}s`
|
|
const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0)
|
|
const totalFrames = Math.ceil(totalDuration * fps)
|
|
|
|
const assetEntries: string[] = []
|
|
const seenAssets = new Set<string>()
|
|
for (const clip of clips) {
|
|
const assetId = clip.assetId || clip.id
|
|
if (seenAssets.has(assetId)) continue
|
|
seenAssets.add(assetId)
|
|
|
|
const assetPath = clip.asset?.path || ''
|
|
const dur = clip.asset?.duration || clip.duration
|
|
const durFrames = Math.ceil(dur * fps)
|
|
const format = clip.type === 'audio' ? 'audio' : 'video'
|
|
|
|
assetEntries.push(
|
|
` <asset id="${escapeXml(assetId)}" name="${escapeXml(clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip')}" src="${escapeXml(assetPath)}" start="0s" duration="${durFrames}/${fps}s" hasVideo="${format === 'video' ? '1' : '0'}" hasAudio="1" format="r1" />`
|
|
)
|
|
}
|
|
|
|
const trackGroups: Map<number, TimelineClip[]> = new Map()
|
|
for (const clip of clips) {
|
|
if (!trackGroups.has(clip.trackIndex)) trackGroups.set(clip.trackIndex, [])
|
|
trackGroups.get(clip.trackIndex)!.push(clip)
|
|
}
|
|
|
|
const laneXml: string[] = []
|
|
const sortedTrackIndices = [...trackGroups.keys()].sort((a, b) => a - b)
|
|
|
|
for (const trackIdx of sortedTrackIndices) {
|
|
const trackClips = trackGroups.get(trackIdx)!.sort((a, b) => a.startTime - b.startTime)
|
|
const clipElements: string[] = []
|
|
|
|
for (const clip of trackClips) {
|
|
const assetId = clip.assetId || clip.id
|
|
const startFrame = Math.round(clip.startTime * fps)
|
|
const durFrames = Math.round(clip.duration * fps)
|
|
const trimStartFrame = Math.round(clip.trimStart * fps)
|
|
const name = clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip'
|
|
|
|
let clipXml = ` <asset-clip ref="${escapeXml(assetId)}" name="${escapeXml(name)}" offset="${startFrame}/${fps}s" duration="${durFrames}/${fps}s" start="${trimStartFrame}/${fps}s"`
|
|
if (clip.speed !== 1) {
|
|
clipXml += ` tcFormat="NDF"`
|
|
}
|
|
clipXml += ` />`
|
|
clipElements.push(clipXml)
|
|
}
|
|
|
|
const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}`
|
|
laneXml.push(
|
|
` <!-- ${escapeXml(trackName)} -->\n` +
|
|
clipElements.join('\n')
|
|
)
|
|
}
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.10">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat${fps === 24 ? '1080p2398' : '1080p' + fps}" frameDuration="${frameDuration}" width="1920" height="1080" />
|
|
${assetEntries.join('\n')}
|
|
</resources>
|
|
<library>
|
|
<event name="${escapeXml(projectName)}">
|
|
<project name="${escapeXml(timelineName)}">
|
|
<sequence format="r1" duration="${totalFrames}/${fps}s" tcStart="0s" tcFormat="NDF">
|
|
<spine>
|
|
${laneXml.join('\n')}
|
|
</spine>
|
|
</sequence>
|
|
</project>
|
|
</event>
|
|
</library>
|
|
</fcpxml>`
|
|
}
|
|
|
|
function escapeXml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
export function ExportModal({ projectName }: ExportModalProps) {
|
|
const { closeExportModal } = useEditorActions()
|
|
const isOpen = useEditorStore(selectShowExportModal)
|
|
const timeline = useEditorStore(selectActiveTimeline)
|
|
const assets = useEditorStore(selectAssets)
|
|
const clips = useEditorStore(selectClips)
|
|
const tracks = useEditorStore(selectTracks)
|
|
const subtitles = useEditorStore(selectSubtitles)
|
|
|
|
const exportClips = useMemo(() => (
|
|
clips
|
|
.filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio')
|
|
.filter(clip => tracks[clip.trackIndex]?.enabled !== false)
|
|
.map(clip => ({
|
|
path: selectClipPathFromAssets(assets, clip),
|
|
type: clip.type,
|
|
startTime: clip.startTime,
|
|
duration: clip.duration,
|
|
trimStart: clip.trimStart,
|
|
speed: clip.speed || 1,
|
|
reversed: clip.reversed || false,
|
|
flipH: clip.flipH || false,
|
|
flipV: clip.flipV || false,
|
|
opacity: clip.opacity ?? 100,
|
|
trackIndex: clip.trackIndex,
|
|
muted: clip.muted || false,
|
|
volume: clip.volume ?? 1,
|
|
}))
|
|
), [assets, clips, tracks])
|
|
|
|
const subtitleData = useMemo(() => (
|
|
subtitles.map(subtitle => {
|
|
const track = tracks[subtitle.trackIndex]
|
|
return {
|
|
text: subtitle.text,
|
|
startTime: subtitle.startTime,
|
|
endTime: subtitle.endTime,
|
|
style: {
|
|
...DEFAULT_SUBTITLE_STYLE,
|
|
...(track?.subtitleStyle || {}),
|
|
...(subtitle.style || {}),
|
|
},
|
|
}
|
|
})
|
|
), [subtitles, tracks])
|
|
|
|
const letterbox = useMemo(() => {
|
|
const adjustmentClips = clips.filter(
|
|
clip =>
|
|
clip.type === 'adjustment'
|
|
&& clip.letterbox?.enabled
|
|
&& tracks[clip.trackIndex]?.enabled !== false,
|
|
)
|
|
if (adjustmentClips.length === 0) return null
|
|
const best = adjustmentClips.reduce((currentBest, candidate) => (
|
|
candidate.duration > currentBest.duration ? candidate : currentBest
|
|
))
|
|
const config = best.letterbox!
|
|
return {
|
|
ratio: config.aspectRatio === 'custom'
|
|
? (config.customRatio || 2.35)
|
|
: (LETTERBOX_RATIO_MAP[config.aspectRatio] || 2.35),
|
|
color: config.color || '#000000',
|
|
opacity: (config.opacity ?? 100) / 100,
|
|
}
|
|
}, [clips, tracks])
|
|
|
|
const [exportStatus, setExportStatus] = useState<ExportStatus>('idle')
|
|
const [exportType, setExportType] = useState<'package' | 'video' | null>(null)
|
|
const [exportProgress, setExportProgress] = useState(0)
|
|
const [exportError, setExportError] = useState<string | null>(null)
|
|
const [exportPath, setExportPath] = useState<string | null>(null)
|
|
const [exportFrameInfo, setExportFrameInfo] = useState('')
|
|
const abortRef = useRef(false)
|
|
|
|
// Export settings
|
|
const [settings, setSettings] = useState<ExportSettings>({
|
|
codec: 'h264',
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 24,
|
|
quality: 18, // CRF 18 for h264
|
|
})
|
|
const [burnSubtitles, setBurnSubtitles] = useState(true)
|
|
|
|
const closeModal = useCallback(() => {
|
|
closeExportModal()
|
|
}, [closeExportModal])
|
|
|
|
const hasSubtitles = subtitleData.length > 0
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
setExportStatus('idle')
|
|
setExportType(null)
|
|
setExportProgress(0)
|
|
setExportError(null)
|
|
setExportPath(null)
|
|
setExportFrameInfo('')
|
|
abortRef.current = false
|
|
}, [isOpen])
|
|
|
|
// Update quality default when codec changes
|
|
const handleCodecChange = useCallback((codec: ExportCodec) => {
|
|
let quality = 18
|
|
if (codec === 'prores') quality = 3 // HQ profile
|
|
if (codec === 'vp9') quality = 8 // 8 Mbps
|
|
setSettings(prev => ({ ...prev, codec, quality }))
|
|
}, [])
|
|
|
|
const handleExportPackage = useCallback(async () => {
|
|
if (!timeline) return
|
|
setExportType('package')
|
|
setExportStatus('exporting')
|
|
setExportProgress(0)
|
|
setExportError(null)
|
|
|
|
try {
|
|
const filePath = await window.electronAPI?.showSaveDialog({
|
|
title: 'Export FCPXML Package',
|
|
defaultPath: `${projectName}_${timeline.name}.fcpxml`,
|
|
filters: [
|
|
{ name: 'Final Cut Pro XML', extensions: ['fcpxml'] },
|
|
{ name: 'All Files', extensions: ['*'] },
|
|
],
|
|
})
|
|
|
|
if (!filePath) {
|
|
setExportStatus('idle')
|
|
return
|
|
}
|
|
|
|
setExportProgress(50)
|
|
const xml = generateFCPXML(clips, tracks, projectName, timeline.name)
|
|
const result = await window.electronAPI?.saveFile({ filePath, data: xml })
|
|
if (result?.success) {
|
|
setExportProgress(100)
|
|
setExportPath(filePath)
|
|
setExportStatus('done')
|
|
} else {
|
|
throw new Error(result && !result.success ? result.error : 'Failed to save file')
|
|
}
|
|
} catch (err) {
|
|
setExportError(String(err))
|
|
setExportStatus('error')
|
|
}
|
|
}, [clips, tracks, timeline, projectName])
|
|
|
|
const handleExportVideo = useCallback(async () => {
|
|
if (!timeline || clips.length === 0) return
|
|
setExportType('video')
|
|
setExportStatus('exporting')
|
|
setExportProgress(0)
|
|
setExportError(null)
|
|
setExportFrameInfo('Preparing...')
|
|
abortRef.current = false
|
|
|
|
try {
|
|
const codecInfo = CODEC_INFO[settings.codec]
|
|
|
|
const filePath = await window.electronAPI?.showSaveDialog({
|
|
title: `Export ${codecInfo.label}`,
|
|
defaultPath: `${projectName}_${timeline.name}.${codecInfo.ext}`,
|
|
filters: [
|
|
{ name: codecInfo.filterName, extensions: [codecInfo.ext] },
|
|
{ name: 'All Files', extensions: ['*'] },
|
|
],
|
|
})
|
|
|
|
if (!filePath) {
|
|
setExportStatus('idle')
|
|
return
|
|
}
|
|
|
|
// Build clip data for ffmpeg native export (video/image + audio clips)
|
|
setExportFrameInfo('Starting ffmpeg...')
|
|
|
|
const result = await window.electronAPI?.exportNative({
|
|
clips: exportClips,
|
|
outputPath: filePath,
|
|
codec: settings.codec,
|
|
width: settings.width,
|
|
height: settings.height,
|
|
fps: settings.fps,
|
|
quality: settings.quality,
|
|
letterbox: letterbox || undefined,
|
|
subtitles: burnSubtitles && subtitleData.length > 0 ? subtitleData : undefined,
|
|
})
|
|
|
|
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')
|
|
}
|
|
}, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline])
|
|
|
|
const handleCancel = useCallback(async () => {
|
|
abortRef.current = true
|
|
window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {})
|
|
setExportStatus('idle')
|
|
}, [])
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={closeModal}>
|
|
<div
|
|
className="bg-zinc-900 rounded-2xl border border-zinc-700/50 shadow-2xl w-full max-w-lg relative overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
|
<h2 className="text-lg font-bold text-white">Export</h2>
|
|
<button
|
|
onClick={closeModal}
|
|
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 overflow-y-auto flex-1">
|
|
{/* Exporting state */}
|
|
{exportStatus === 'exporting' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
|
<span className="text-sm text-zinc-300">
|
|
{exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-zinc-800 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all duration-300"
|
|
style={{ width: `${exportProgress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-zinc-500">{exportProgress}% complete</p>
|
|
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
|
</div>
|
|
{exportType === 'video' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-zinc-700 text-zinc-400"
|
|
onClick={handleCancel}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Done state */}
|
|
{exportStatus === 'done' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center">
|
|
<Check className="h-5 w-5 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-white font-medium">Export complete</p>
|
|
<p className="text-xs text-zinc-500 truncate max-w-[340px]">{exportPath}</p>
|
|
{exportFrameInfo && <p className="text-xs text-zinc-500">{exportFrameInfo}</p>}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-zinc-700 text-zinc-300"
|
|
onClick={() => {
|
|
if (exportPath) {
|
|
window.electronAPI?.openParentFolderOfFile({ filePath: exportPath })
|
|
}
|
|
}}
|
|
>
|
|
<FolderOpen className="h-4 w-4 mr-2" />
|
|
Show in Folder
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-zinc-700 text-zinc-300"
|
|
onClick={() => {
|
|
setExportStatus('idle')
|
|
setExportType(null)
|
|
}}
|
|
>
|
|
Export Another
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{exportStatus === 'error' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-white font-medium">Export failed</p>
|
|
<p className="text-xs text-red-400 max-w-[340px] break-words">{exportError}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-zinc-700 text-zinc-300"
|
|
onClick={() => {
|
|
setExportStatus('idle')
|
|
setExportType(null)
|
|
}}
|
|
>
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Idle state — settings */}
|
|
{exportStatus === 'idle' && (
|
|
<div className="space-y-5">
|
|
{/* Package export (compact) */}
|
|
<button
|
|
onClick={handleExportPackage}
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl border border-zinc-700/50 bg-zinc-800/50 hover:bg-zinc-800 hover:border-zinc-600 transition-all group"
|
|
>
|
|
<div className="w-10 h-10 rounded-lg bg-zinc-700/50 flex items-center justify-center flex-shrink-0">
|
|
<Package className="h-5 w-5 text-zinc-300" />
|
|
</div>
|
|
<div className="flex-1 text-left">
|
|
<p className="text-sm font-semibold text-white">Package (FCPXML)</p>
|
|
<p className="text-[10px] text-zinc-500">For Premiere Pro & DaVinci Resolve</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="DaVinci Resolve">
|
|
<span className="text-[8px] font-bold text-orange-400">DR</span>
|
|
</div>
|
|
<div className="w-6 h-6 rounded bg-zinc-700 flex items-center justify-center" title="Premiere Pro">
|
|
<span className="text-[8px] font-bold text-blue-400">Pr</span>
|
|
</div>
|
|
<Download className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300 transition-colors ml-1" />
|
|
</div>
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-zinc-800" />
|
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Video Export</span>
|
|
<div className="flex-1 h-px bg-zinc-800" />
|
|
</div>
|
|
|
|
{/* Format selector */}
|
|
<div>
|
|
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-2 block">Format</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => (
|
|
<button
|
|
key={codec}
|
|
onClick={() => handleCodecChange(codec)}
|
|
className={`p-2.5 rounded-lg border text-center transition-all ${
|
|
settings.codec === codec
|
|
? 'border-blue-500 bg-blue-500/10 text-white'
|
|
: 'border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
<p className="text-xs font-semibold">{CODEC_INFO[codec].label.split(' / ')[0]}</p>
|
|
<p className="text-[9px] text-zinc-500 mt-0.5">.{CODEC_INFO[codec].ext}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resolution & Frame rate row */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Resolution</label>
|
|
<div className="relative">
|
|
<select
|
|
value={`${settings.width}x${settings.height}`}
|
|
onChange={(e) => {
|
|
const [w, h] = e.target.value.split('x').map(Number)
|
|
setSettings(prev => ({ ...prev, width: w, height: h }))
|
|
}}
|
|
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
|
>
|
|
{RESOLUTIONS.map(r => (
|
|
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
|
|
{r.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Frame Rate</label>
|
|
<div className="relative">
|
|
<select
|
|
value={settings.fps}
|
|
onChange={(e) => setSettings(prev => ({ ...prev, fps: parseInt(e.target.value) }))}
|
|
className="w-full appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500 pr-8 cursor-pointer"
|
|
>
|
|
{FRAME_RATES.map(fps => (
|
|
<option key={fps} value={fps}>{fps} fps</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-zinc-500 pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quality */}
|
|
<div>
|
|
<label className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold mb-1.5 block">Quality</label>
|
|
{settings.codec === 'h264' && (
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min={15}
|
|
max={28}
|
|
step={1}
|
|
value={settings.quality}
|
|
onChange={(e) => 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)
|
|
/>
|
|
<span className="text-xs text-zinc-400 w-16 text-right">
|
|
{settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'}
|
|
<span className="text-zinc-600 ml-1">({settings.quality})</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
{settings.codec === 'prores' && (
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{PRORES_PROFILES.map(p => (
|
|
<button
|
|
key={p.value}
|
|
onClick={() => setSettings(prev => ({ ...prev, quality: p.value }))}
|
|
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
|
|
settings.quality === p.value
|
|
? 'bg-blue-500/20 border border-blue-500 text-blue-300'
|
|
: 'bg-zinc-800 border border-zinc-700 text-zinc-400 hover:border-zinc-600'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{settings.codec === 'vp9' && (
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min={2}
|
|
max={20}
|
|
step={1}
|
|
value={settings.quality}
|
|
onChange={(e) => setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))}
|
|
className="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
|
/>
|
|
<span className="text-xs text-zinc-400 w-20 text-right">
|
|
{settings.quality} Mbps
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options */}
|
|
{hasSubtitles && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-zinc-800" />
|
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider font-semibold">Options</span>
|
|
<div className="flex-1 h-px bg-zinc-800" />
|
|
</div>
|
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
<input
|
|
type="checkbox"
|
|
checked={burnSubtitles}
|
|
onChange={(e) => setBurnSubtitles(e.target.checked)}
|
|
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 accent-blue-500 cursor-pointer"
|
|
/>
|
|
<span className="text-xs text-zinc-300 group-hover:text-white transition-colors">Burn-in subtitles</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Export button */}
|
|
<button
|
|
onClick={handleExportVideo}
|
|
disabled={clips.length === 0 || window.electronAPI.platform === 'web'}
|
|
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold text-sm flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
<Film className="h-4 w-4" />
|
|
Timeline rendering not yet available in web preview
|
|
</button>
|
|
|
|
{clips.length === 0 && (
|
|
<p className="text-xs text-zinc-500 text-center">Add clips to the timeline to export.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|