Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e02184c8f | |||
| e935241fd8 |
+116
-85
@@ -269,6 +269,109 @@ def extract_cover_image(input_file: str, output_path: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_inputs(cmd: List[str], input_file: str, cover_image_path: Optional[str]) -> None:
|
||||
"""Add input files to the command."""
|
||||
if cover_image_path:
|
||||
cmd.extend(['-i', cover_image_path])
|
||||
cmd.extend(['-i', input_file])
|
||||
|
||||
|
||||
def _build_time_options(cmd: List[str], start_seconds: int, duration_seconds: int) -> None:
|
||||
"""Add time-based options to the command."""
|
||||
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
|
||||
|
||||
|
||||
def _build_metadata(cmd: List[str], metadata: Optional[Dict]) -> None:
|
||||
"""Add metadata options to the command."""
|
||||
cmd.append('-map_metadata')
|
||||
cmd.append('-1')
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value is not None and value != '':
|
||||
cmd.extend(['-metadata', f"{key}={value}"])
|
||||
|
||||
|
||||
def _build_cover_image_mapping(
|
||||
cmd: List[str],
|
||||
audio_codec: Optional[str],
|
||||
video_codec: Optional[str],
|
||||
video_quality: Optional[int],
|
||||
) -> None:
|
||||
"""Build stream mapping for cover image extraction (audio + cover video)."""
|
||||
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
|
||||
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
else:
|
||||
cmd.extend(['-c:v', 'png'])
|
||||
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
cmd.append('-sn')
|
||||
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
|
||||
|
||||
def _build_standard_mapping(
|
||||
cmd: List[str],
|
||||
stream_info: Dict,
|
||||
audio_codec: Optional[str],
|
||||
video_codec: Optional[str],
|
||||
subtitle_codec: Optional[str],
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> None:
|
||||
"""Build stream mapping for standard extraction (no cover image)."""
|
||||
if drop_video and drop_subs:
|
||||
cmd.extend(['-map', '0:a:0'])
|
||||
elif drop_video:
|
||||
cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
|
||||
elif drop_subs:
|
||||
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
|
||||
else:
|
||||
cmd.extend(['-map', '0'])
|
||||
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
if not drop_video and stream_info.get('has_video'):
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
else:
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
else:
|
||||
cmd.append('-vn')
|
||||
|
||||
if not drop_subs and stream_info.get('has_subtitle'):
|
||||
if subtitle_codec and subtitle_codec != 'copy':
|
||||
cmd.extend(['-c:s', subtitle_codec])
|
||||
else:
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
else:
|
||||
cmd.append('-sn')
|
||||
|
||||
|
||||
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
|
||||
"""Add output format option if specified."""
|
||||
if format_opt:
|
||||
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
|
||||
cmd.extend(['-f', ffmpeg_format])
|
||||
|
||||
|
||||
def _build_output(cmd: List[str], output_path: str) -> None:
|
||||
"""Add output file to the command."""
|
||||
cmd.extend(['-y', output_path])
|
||||
|
||||
|
||||
def build_ffmpeg_command(
|
||||
input_file: str,
|
||||
start_seconds: int,
|
||||
@@ -309,93 +412,21 @@ def build_ffmpeg_command(
|
||||
"""
|
||||
cmd = ['ffmpeg']
|
||||
|
||||
# Add cover image as first input if provided
|
||||
_build_inputs(cmd, input_file, cover_image_path)
|
||||
_build_time_options(cmd, start_seconds, duration_seconds)
|
||||
_build_metadata(cmd, metadata)
|
||||
|
||||
if cover_image_path:
|
||||
cmd.extend(['-i', cover_image_path])
|
||||
|
||||
# Add main input file
|
||||
cmd.extend(['-i', input_file])
|
||||
|
||||
# Time options
|
||||
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
|
||||
|
||||
# Clear all original metadata.
|
||||
cmd.append('-map_metadata')
|
||||
cmd.append('-1')
|
||||
|
||||
# Apply custom metadata.
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value is not None and value != '':
|
||||
cmd.extend(['-metadata', f"{key}={value}"])
|
||||
|
||||
# ---------- Stream mapping and codecs ----------
|
||||
if cover_image_path:
|
||||
# We have two inputs: index 0 = cover image, index 1 = main input
|
||||
# Map audio from main input (index 1) and video from cover image (index 0)
|
||||
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
|
||||
|
||||
# Video codec: use user-specified codec if provided, otherwise fallback to png
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
_build_cover_image_mapping(cmd, audio_codec, video_codec, video_quality)
|
||||
else:
|
||||
cmd.extend(['-c:v', 'png'])
|
||||
_build_standard_mapping(
|
||||
cmd, stream_info, audio_codec, video_codec, subtitle_codec,
|
||||
video_quality, drop_video, drop_subs
|
||||
)
|
||||
|
||||
# Audio codec
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
_build_format(cmd, format_opt)
|
||||
_build_output(cmd, output_path)
|
||||
|
||||
# Subtitle: we don't copy subtitles when using cover image (they would be from main input)
|
||||
cmd.append('-sn')
|
||||
|
||||
# Video quality if specified
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
|
||||
else:
|
||||
# Standard mapping (no cover image)
|
||||
if drop_video and drop_subs:
|
||||
cmd.extend(['-map', '0:a:0'])
|
||||
elif drop_video:
|
||||
cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
|
||||
elif drop_subs:
|
||||
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
|
||||
else:
|
||||
cmd.extend(['-map', '0'])
|
||||
|
||||
# Audio codec
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
# Video codec
|
||||
if not drop_video and stream_info.get('has_video'):
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
# Add video quality if specified (only when re-encoding)
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
else:
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
else:
|
||||
cmd.append('-vn')
|
||||
|
||||
# Subtitle codec
|
||||
if not drop_subs and stream_info.get('has_subtitle'):
|
||||
if subtitle_codec and subtitle_codec != 'copy':
|
||||
cmd.extend(['-c:s', subtitle_codec])
|
||||
else:
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
else:
|
||||
cmd.append('-sn')
|
||||
|
||||
# Output format
|
||||
if format_opt:
|
||||
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
|
||||
cmd.extend(['-f', ffmpeg_format])
|
||||
|
||||
cmd.extend(['-y', output_path])
|
||||
return cmd
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useUploadStore } from '../stores/uploadStore'
|
||||
import { useValidationStore } from '../stores/validationStore'
|
||||
import { getFormats } from '../api/client'
|
||||
import { SplitOptions } from '../types'
|
||||
import { useFilterCodecs } from '../hooks/useFilterCodecs'
|
||||
import { useFormatValidation } from '../hooks/useFormatValidation'
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Section component (collapsible)
|
||||
@@ -91,64 +93,12 @@ export const OptionsPanel: React.FC = () => {
|
||||
// --------------------------------------------------------------
|
||||
// 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])
|
||||
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(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])
|
||||
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Handlers
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CodecInfo } from '../types'
|
||||
|
||||
export interface CompatibilityEntry {
|
||||
audio: string[] | null
|
||||
video: string[] | null
|
||||
}
|
||||
|
||||
export function useFilterCodecs(
|
||||
compatibility: Record<string, CompatibilityEntry>,
|
||||
container: string | null,
|
||||
codecs: CodecInfo[]
|
||||
) {
|
||||
const filteredAudioCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[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, container, codecs])
|
||||
|
||||
const filteredVideoCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[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, container, codecs])
|
||||
|
||||
return { filteredAudioCodecs, filteredVideoCodecs }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ContainerInfo } from '../types'
|
||||
import { CompatibilityEntry } from './useFilterCodecs'
|
||||
|
||||
export function useFormatValidation(
|
||||
options: {
|
||||
container: string | null
|
||||
audio_codec: string
|
||||
video_codec: string
|
||||
drop_video: boolean
|
||||
},
|
||||
hasVideo: boolean,
|
||||
compatibility: Record<string, CompatibilityEntry>,
|
||||
containers: ContainerInfo[],
|
||||
setFormatError: (error: string | null) => void
|
||||
) {
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user