FEATURE: propagate improvements from CLI and web-API versions to web-frontend
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@@ -17,6 +17,7 @@ import { useOptionsStore } from '../stores/optionsStore'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { useValidationStore } from '../stores/validationStore'
|
||||
import { getFormats } from '../api/client'
|
||||
import { SplitOptions } from '../types'
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Section component (collapsible)
|
||||
@@ -29,7 +30,6 @@ interface SectionProps {
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
@@ -56,7 +56,7 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Main OptionsPanel component
|
||||
// Main OptionsPanel
|
||||
// ------------------------------------------------------------------------------
|
||||
export const OptionsPanel: React.FC = () => {
|
||||
const {
|
||||
@@ -64,57 +64,103 @@ export const OptionsPanel: React.FC = () => {
|
||||
setOptions,
|
||||
containers,
|
||||
codecs,
|
||||
compatibility,
|
||||
setContainers,
|
||||
setCodecs,
|
||||
setCompatibility,
|
||||
} = useOptionsStore()
|
||||
|
||||
const { hasVideo } = useUploadStore()
|
||||
const { formatError, setFormatError } = useValidationStore()
|
||||
|
||||
// Fetch containers and codecs from backend on mount
|
||||
// Fetch formats on mount
|
||||
useEffect(() => {
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const data = await getFormats()
|
||||
setContainers(data.containers)
|
||||
setCodecs(data.codecs)
|
||||
setContainers(data.containers || [])
|
||||
setCodecs(data.codecs || [])
|
||||
setCompatibility(data.compatibility || {})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch formats:', err)
|
||||
}
|
||||
}
|
||||
fetchFormats()
|
||||
}, [setContainers, setCodecs])
|
||||
}, [setContainers, setCodecs, setCompatibility])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Filter codec options based on selected container
|
||||
// --------------------------------------------------------------
|
||||
const filteredAudioCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[options.container || '']
|
||||
if (!entry) return codecs.filter(c => c.supports_transcoding)
|
||||
const audioList = entry.audio
|
||||
if (audioList === null) return codecs.filter(c => c.supports_transcoding)
|
||||
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name))
|
||||
}, [compatibility, options.container, codecs])
|
||||
|
||||
const filteredVideoCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[options.container || '']
|
||||
if (!entry) return codecs.filter(c => c.supports_video)
|
||||
const videoList = entry.video
|
||||
if (videoList === null) return codecs.filter(c => c.supports_video)
|
||||
return codecs.filter(c => c.supports_video && videoList.includes(c.name))
|
||||
}, [compatibility, options.container, codecs])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Validate compatibility
|
||||
// --------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const selectedContainer = containers.find(c => c.name === options.container)
|
||||
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check audio codec compatibility
|
||||
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
|
||||
const entry = compatibility?.[options.container]
|
||||
if (entry) {
|
||||
const audioList = entry.audio
|
||||
if (audioList !== null && !audioList.includes(options.audio_codec)) {
|
||||
setFormatError(
|
||||
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check video codec compatibility
|
||||
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
|
||||
const entry = compatibility?.[options.container]
|
||||
if (entry) {
|
||||
const videoList = entry.video
|
||||
if (videoList !== null && !videoList.includes(options.video_codec)) {
|
||||
setFormatError(
|
||||
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormatError(null)
|
||||
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Handlers
|
||||
// --------------------------------------------------------------
|
||||
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFormat = e.target.value
|
||||
setOptions({ format: newFormat })
|
||||
}
|
||||
|
||||
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked
|
||||
setOptions({ drop_video: checked })
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
const handleChange = (field: keyof SplitOptions, value: any) => {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Format validation – re-run when relevant state changes
|
||||
// --------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const selectedContainer = containers.find(c => c.name === options.format)
|
||||
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Container '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
)
|
||||
} else {
|
||||
setFormatError(null)
|
||||
}
|
||||
}, [containers, options.format, options.drop_video, hasVideo, setFormatError])
|
||||
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value === '' ? null : e.target.value
|
||||
handleChange('container', value)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Render
|
||||
@@ -140,56 +186,92 @@ export const OptionsPanel: React.FC = () => {
|
||||
<TextField
|
||||
label="Container"
|
||||
select
|
||||
value={options.format}
|
||||
onChange={handleFormatChange}
|
||||
value={options.container ?? ''}
|
||||
onChange={handleContainerChange}
|
||||
fullWidth
|
||||
size="small"
|
||||
error={!!formatError}
|
||||
helperText={
|
||||
formatError || "Determines the output file extension and container structure."
|
||||
formatError || "Select a container (auto-detect if empty)."
|
||||
}
|
||||
>
|
||||
{containers.map((container) => (
|
||||
<MenuItem key={container.name} value={container.name}>
|
||||
{container.name.toUpperCase()} ({container.extension})
|
||||
<MenuItem value="">Auto-detect</MenuItem>
|
||||
{containers.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()} ({c.extension})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Audio Codec (Transcode) dropdown */}
|
||||
{/* Audio Codec dropdown */}
|
||||
<TextField
|
||||
label="Audio Codec (Transcode)"
|
||||
label="Audio Codec"
|
||||
select
|
||||
value={options.transcode_to || ''}
|
||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||
value={options.audio_codec}
|
||||
onChange={(e) => handleChange('audio_codec', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
||||
helperText="Audio codec (copy = keep original)"
|
||||
>
|
||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||
{codecs
|
||||
.filter(c => c.supports_transcoding)
|
||||
.map((codec) => (
|
||||
<MenuItem key={codec.name} value={codec.ffmpeg}>
|
||||
{codec.name.toUpperCase()} (→ {codec.recommended_container})
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="copy">Copy (original)</MenuItem>
|
||||
{filteredAudioCodecs.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Drop video stream (only shown if video present) */}
|
||||
{/* Video Codec dropdown */}
|
||||
<TextField
|
||||
label="Video Codec"
|
||||
select
|
||||
value={options.video_codec}
|
||||
onChange={(e) => handleChange('video_codec', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Video codec (copy = keep original)"
|
||||
disabled={!hasVideo || options.drop_video}
|
||||
>
|
||||
<MenuItem value="copy">Copy (original)</MenuItem>
|
||||
{filteredVideoCodecs.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Video Quality */}
|
||||
<TextField
|
||||
label="Video Quality"
|
||||
type="number"
|
||||
value={options.video_quality ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
|
||||
handleChange('video_quality', val)
|
||||
}}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!hasVideo || options.drop_video}
|
||||
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
|
||||
InputProps={{
|
||||
inputProps: { min: 0, max: 51, step: 1 },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drop video (if video present) */}
|
||||
{hasVideo && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_video}
|
||||
onChange={handleDropVideoChange}
|
||||
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Drop video streams"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drop subtitle streams */}
|
||||
{/* Drop subtitles */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
@@ -293,7 +375,7 @@ export const OptionsPanel: React.FC = () => {
|
||||
type="number"
|
||||
value={options.comment_stream ?? ''}
|
||||
onChange={(e) =>
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
|
||||
}
|
||||
size="small"
|
||||
disabled={options.no_comment}
|
||||
|
||||
Reference in New Issue
Block a user