fix (core): main fixes of codec/container/file_extension inconsistency #11

Merged
max merged 8 commits from major_fix into dev 2026-09-01 15:11:40 +04:00
4 changed files with 209 additions and 91 deletions
Showing only changes of commit 262d458ce4 - Show all commits
+108 -37
View File
@@ -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'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+1 -1
View File
@@ -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, {})
+21
View File
@@ -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
View File
@@ -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: Userrequested 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