diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index da040dd..b49765f 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -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'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' diff --git a/audio_splitter/core.py b/audio_splitter/core.py index de5b51e..c3b4b94 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -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, {}) diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index e6570ca..fab1d67 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -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 diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 84b2d56..87ff45e 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -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