FEATURE: codecs/containers/file_extensions inconsistency fixed
This commit is contained in:
+108
-37
@@ -1,53 +1,124 @@
|
||||
# audio_splitter/constants.py
|
||||
"""Global constants for the audio splitter."""
|
||||
"""Global constants for the audio splitter.
|
||||
|
||||
This file serves as the single source of truth for:
|
||||
- Container formats and their properties
|
||||
- Audio codecs and their recommended containers
|
||||
- File extensions for each codec/container combination
|
||||
|
||||
Codec != Container != File Extension.
|
||||
Example: Opus (codec) → Ogg (container) → .opus (extension)
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Container information (used for output format selection)
|
||||
# Container information
|
||||
# ------------------------------------------------------------------------------
|
||||
# Each container entry:
|
||||
# - name: internal identifier used in the code
|
||||
# - ffmpeg: name passed to FFmpeg's -f option
|
||||
# - extension: default file extension
|
||||
# - audio_only: whether the container supports video/subtitle streams
|
||||
# - supports_video: whether video streams can be stored
|
||||
# - supports_subs: whether subtitle streams can be stored
|
||||
CONTAINER_INFO = [
|
||||
# Audio-only containers
|
||||
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, # MP4 container, .m4a extension for audio-only
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
|
||||
|
||||
# Containers that support video and subtitles
|
||||
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, # Ogg supports video (Theora, Dirac) and subtitles
|
||||
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False}, # WebM is a subset of Matroska
|
||||
]
|
||||
|
||||
# Legacy FORMAT_INFO for backward compatibility with existing code
|
||||
# Maps container name → FFmpeg format name, extension, and audio_only flag
|
||||
FORMAT_INFO = {
|
||||
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
||||
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
||||
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
||||
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
||||
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
|
||||
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
|
||||
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
|
||||
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
|
||||
container['name']: {
|
||||
'ffmpeg': container['ffmpeg'],
|
||||
'ext': container['extension'],
|
||||
'audio_only': container['audio_only'],
|
||||
}
|
||||
for container in CONTAINER_INFO
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Container list with display names and extensions (for frontend)
|
||||
# ------------------------------------------------------------------------------
|
||||
CONTAINER_INFO = [
|
||||
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True},
|
||||
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True},
|
||||
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False},
|
||||
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False},
|
||||
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': True},
|
||||
{'name': 'opus', 'ffmpeg': 'ogg', 'extension': '.opus', 'audio_only': True},
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True},
|
||||
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True},
|
||||
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True},
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Codec information (used for transcoding options)
|
||||
# Codec information
|
||||
# ------------------------------------------------------------------------------
|
||||
# Each codec entry:
|
||||
# - name: codec name (used in code)
|
||||
# - ffmpeg: encoder name passed to FFmpeg's -c:a option
|
||||
# - recommended_container: the container format recommended for this codec
|
||||
# - recommended_extension: the recommended file extension for this codec
|
||||
# - supports_transcoding: whether this codec can be used as output via FFmpeg
|
||||
# - supports_video: whether this codec is for video (True) or audio (False)
|
||||
CODEC_INFO = [
|
||||
# codec name, ffmpeg encoder name, recommended container, supports transcoding
|
||||
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'supports_transcoding': True},
|
||||
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'm4a', 'supports_transcoding': True},
|
||||
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'opus', 'supports_transcoding': True},
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'supports_transcoding': True},
|
||||
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'supports_transcoding': True},
|
||||
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'm4a', 'supports_transcoding': True},
|
||||
{'name': 'pcm_s16le','ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'supports_transcoding': True},
|
||||
# Audio codecs
|
||||
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
|
||||
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False},
|
||||
|
||||
# Video codecs (for reference, not used for transcoding selection in the UI)
|
||||
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': False, 'supports_video': True},
|
||||
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True},
|
||||
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True},
|
||||
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': False, 'supports_video': True},
|
||||
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True},
|
||||
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True},
|
||||
]
|
||||
|
||||
# Map codec name → recommended container
|
||||
# ------------------------------------------------------------------------------
|
||||
# Video codec support per container
|
||||
# ------------------------------------------------------------------------------
|
||||
# For each container, list of video codecs it supports.
|
||||
# Use '*' to indicate that the container supports all video codecs (e.g., Matroska).
|
||||
CONTAINER_VIDEO_CODEC_SUPPORT = {
|
||||
'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'], # MP4 supports these via ISO BMFF
|
||||
'mkv': ['*'], # Matroska supports virtually all video codecs
|
||||
'matroska': ['*'],
|
||||
'ogg': ['theora', 'dirac', 'vp8'], # Ogg supports Theora, Dirac, VP8
|
||||
'webm': ['vp8', 'vp9', 'av1'], # WebM is a subset of Matroska with VP8/VP9/AV1
|
||||
'mp3': [], # Audio-only, no video support
|
||||
'm4a': [], # Audio-only, no video support
|
||||
'flac': [], # Audio-only
|
||||
'wav': [], # Audio-only
|
||||
'aac': [], # Audio-only
|
||||
}
|
||||
|
||||
# Map codec name to its type (audio/video)
|
||||
# This is used to determine if a stream is audio or video
|
||||
CODEC_TYPE_MAP = {
|
||||
'opus': 'audio',
|
||||
'vorbis': 'audio',
|
||||
'flac': 'audio',
|
||||
'aac': 'audio',
|
||||
'mp3': 'audio',
|
||||
'alac': 'audio',
|
||||
'pcm_s16le': 'audio',
|
||||
'theora': 'video',
|
||||
'vp8': 'video',
|
||||
'vp9': 'video',
|
||||
'av1': 'video',
|
||||
'h264': 'video',
|
||||
'h265': 'video',
|
||||
'png': 'video', # PNG is often used as attached picture (cover art)
|
||||
'mjpeg': 'video', # MJPEG also used for attached pictures
|
||||
}
|
||||
|
||||
# Map codec name → recommended container name
|
||||
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
|
||||
|
||||
# Map codec name → recommended file extension
|
||||
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
|
||||
|
||||
# Default bad characters (unchanged)
|
||||
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
||||
|
||||
@@ -48,7 +48,7 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
print(f"Output container: {output_format}")
|
||||
|
||||
validate_format_compatibility(output_format, stream_info,
|
||||
args.drop_video, args.drop_subs)
|
||||
args.drop_video, args.drop_subs, input_file)
|
||||
|
||||
# Determine file extension.
|
||||
extension_info = FORMAT_INFO.get(output_format, {})
|
||||
|
||||
@@ -257,3 +257,24 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds:
|
||||
|
||||
cmd.extend(['-y', output_path])
|
||||
return cmd
|
||||
|
||||
def get_video_codec(input_file: str) -> Optional[str]:
|
||||
"""
|
||||
Return the codec name of the first video stream.
|
||||
|
||||
Args:
|
||||
input_file: Path to the media file.
|
||||
|
||||
Returns:
|
||||
Codec name as a lowercase string, or None if no video stream exists.
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
codec = result.stdout.strip().lower()
|
||||
return codec if codec else None
|
||||
|
||||
+79
-53
@@ -3,8 +3,7 @@
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO
|
||||
|
||||
from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO, CONTAINER_VIDEO_CODEC_SUPPORT, CODEC_TYPE_MAP
|
||||
|
||||
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
@@ -35,60 +34,10 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
|
||||
return None
|
||||
|
||||
|
||||
def determine_output_format(stream_info: Dict, user_format: Optional[str],
|
||||
transcode_audio: Optional[str], input_file: Optional[str] = None) -> str:
|
||||
"""
|
||||
Decide which container format to use.
|
||||
|
||||
If user_format is provided, use it.
|
||||
Else, try to detect the input file's container and codec, and use the recommended format.
|
||||
If detection fails or format is not supported, fallback to:
|
||||
- MKV if video/subtitles exist
|
||||
- MP3 if the audio codec is MP3
|
||||
- MP4 (M4A) otherwise
|
||||
|
||||
Args:
|
||||
stream_info: Dict from get_stream_info().
|
||||
user_format: User‑requested format (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
|
||||
input_file: Path to the input file (optional, used to detect container and codec).
|
||||
|
||||
Returns:
|
||||
A format name that exists in FORMAT_INFO.
|
||||
"""
|
||||
if user_format:
|
||||
return user_format
|
||||
|
||||
# If input_file is provided, try to detect container and codec
|
||||
if input_file:
|
||||
try:
|
||||
from .ffmpeg import get_container_format, get_audio_codec
|
||||
container = get_container_format(input_file)
|
||||
codec = get_audio_codec(input_file)
|
||||
fmt = determine_default_format(container, codec)
|
||||
if fmt in FORMAT_INFO:
|
||||
return fmt
|
||||
except Exception:
|
||||
# If detection fails, fall through to legacy logic
|
||||
pass
|
||||
|
||||
# Fallback: legacy behavior
|
||||
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
|
||||
return 'matroska'
|
||||
audio_codec = stream_info.get('audio_codec', '')
|
||||
if audio_codec == 'mp3':
|
||||
return 'mp3'
|
||||
else:
|
||||
return 'mp4' # .m4a
|
||||
|
||||
|
||||
def validate_format_compatibility(format_name: str, stream_info: Dict,
|
||||
drop_video: bool, drop_subs: bool) -> None:
|
||||
drop_video: bool, drop_subs: bool, input_file: Optional[str] = None) -> None:
|
||||
"""
|
||||
Ensure the chosen container can accommodate the streams we intend to keep.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is incompatible with the intended streams.
|
||||
"""
|
||||
info = FORMAT_INFO.get(format_name)
|
||||
if not info:
|
||||
@@ -106,3 +55,80 @@ def validate_format_compatibility(format_name: str, stream_info: Dict,
|
||||
f"Format '{format_name}' does not support subtitle streams. "
|
||||
"Please use --drop-subs or choose a container that supports subtitles."
|
||||
)
|
||||
else:
|
||||
# If video is present and not dropped, check if the container supports the video codec
|
||||
if stream_info.get('has_video') and not drop_video and input_file:
|
||||
from .ffmpeg import get_video_codec
|
||||
video_codec = get_video_codec(input_file)
|
||||
if video_codec and not is_video_codec_supported(format_name, video_codec):
|
||||
raise ValueError(
|
||||
f"Container '{format_name}' does not support video codec '{video_codec}'. "
|
||||
"Please use --drop-video or choose a container that supports this video codec (e.g., MKV)."
|
||||
)
|
||||
|
||||
def is_video_codec_supported(container_name: str, video_codec: str) -> bool:
|
||||
"""
|
||||
Check if a given container supports a specific video codec.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container (e.g., 'mkv', 'ogg')
|
||||
video_codec: Video codec name (e.g., 'theora', 'h264', 'png')
|
||||
|
||||
Returns:
|
||||
True if supported, False otherwise.
|
||||
"""
|
||||
support = CONTAINER_VIDEO_CODEC_SUPPORT.get(container_name, [])
|
||||
if not support:
|
||||
return False
|
||||
if '*' in support:
|
||||
return True
|
||||
return video_codec in support
|
||||
|
||||
|
||||
def determine_output_format(stream_info: Dict, user_format: Optional[str],
|
||||
transcode_audio: Optional[str], input_file: Optional[str] = None) -> str:
|
||||
"""
|
||||
Decide which container format to use.
|
||||
|
||||
If user_format is provided, use it.
|
||||
Else, try to detect the input file's container and codec, and use the recommended format.
|
||||
If detection fails or format is not supported, fallback to:
|
||||
- MKV if video/subtitles exist and the video codec is not supported by the recommended container
|
||||
- MP3 if the audio codec is MP3
|
||||
- MP4 (M4A) otherwise
|
||||
"""
|
||||
if user_format:
|
||||
return user_format
|
||||
|
||||
# Try to detect container and codec from input file
|
||||
if input_file:
|
||||
try:
|
||||
from .ffmpeg import get_container_format, get_audio_codec, get_video_codec
|
||||
container = get_container_format(input_file)
|
||||
audio_codec = get_audio_codec(input_file)
|
||||
video_codec = get_video_codec(input_file) # NEW: get video codec
|
||||
|
||||
# Determine recommended format based on audio codec
|
||||
fmt = determine_default_format(container, audio_codec)
|
||||
|
||||
# If video is present and not dropped, check if the recommended container supports the video codec
|
||||
if stream_info.get('has_video') and not transcode_audio: # transcode_audio doesn't affect video
|
||||
# If the recommended container doesn't support the video codec, fallback to MKV
|
||||
if fmt and not is_video_codec_supported(fmt, video_codec):
|
||||
fmt = 'matroska' # MKV supports virtually all video codecs
|
||||
# Optionally log a warning
|
||||
# print(f"Warning: Container '{fmt}' does not support video codec '{video_codec}'. Falling back to MKV.")
|
||||
|
||||
if fmt in FORMAT_INFO:
|
||||
return fmt
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: legacy behavior
|
||||
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
|
||||
return 'matroska'
|
||||
audio_codec = stream_info.get('audio_codec', '')
|
||||
if audio_codec == 'mp3':
|
||||
return 'mp3'
|
||||
else:
|
||||
return 'mp4' # .m4a
|
||||
|
||||
Reference in New Issue
Block a user