FEATURE: codecs/containers/file_extensions inconsistency fixed
This commit is contained in:
+108
-37
@@ -1,53 +1,124 @@
|
|||||||
# audio_splitter/constants.py
|
# 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 = {
|
FORMAT_INFO = {
|
||||||
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
container['name']: {
|
||||||
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
'ffmpeg': container['ffmpeg'],
|
||||||
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
'ext': container['extension'],
|
||||||
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
'audio_only': container['audio_only'],
|
||||||
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
}
|
||||||
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
for container in CONTAINER_INFO
|
||||||
'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 list with display names and extensions (for frontend)
|
# Codec information
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
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)
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
# 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_INFO = [
|
||||||
# codec name, ffmpeg encoder name, recommended container, supports transcoding
|
# Audio codecs
|
||||||
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'supports_transcoding': True},
|
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
|
||||||
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'm4a', 'supports_transcoding': True},
|
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
|
||||||
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'opus', 'supports_transcoding': True},
|
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
|
||||||
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'supports_transcoding': True},
|
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
|
||||||
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'supports_transcoding': True},
|
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
|
||||||
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'm4a', 'supports_transcoding': True},
|
{'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', 'supports_transcoding': True},
|
{'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}
|
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 characters (unchanged)
|
||||||
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def split_audio(input_file, output_directory, tracks, args):
|
|||||||
print(f"Output container: {output_format}")
|
print(f"Output container: {output_format}")
|
||||||
|
|
||||||
validate_format_compatibility(output_format, stream_info,
|
validate_format_compatibility(output_format, stream_info,
|
||||||
args.drop_video, args.drop_subs)
|
args.drop_video, args.drop_subs, input_file)
|
||||||
|
|
||||||
# Determine file extension.
|
# Determine file extension.
|
||||||
extension_info = FORMAT_INFO.get(output_format, {})
|
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])
|
cmd.extend(['-y', output_path])
|
||||||
return cmd
|
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 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]:
|
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
|
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,
|
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.
|
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)
|
info = FORMAT_INFO.get(format_name)
|
||||||
if not info:
|
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. "
|
f"Format '{format_name}' does not support subtitle streams. "
|
||||||
"Please use --drop-subs or choose a container that supports subtitles."
|
"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