Initial commit
This commit is contained in:
311
skills/media-processing/scripts/media_convert.py
Normal file
311
skills/media-processing/scripts/media_convert.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified media conversion tool for video, audio, and images.
|
||||
|
||||
Auto-detects format and applies appropriate tool (FFmpeg or ImageMagick).
|
||||
Supports quality presets, batch processing, and dry-run mode.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
# Format mappings
|
||||
VIDEO_FORMATS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv', '.wmv', '.m4v'}
|
||||
AUDIO_FORMATS = {'.mp3', '.aac', '.m4a', '.opus', '.flac', '.wav', '.ogg'}
|
||||
IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'}
|
||||
|
||||
# Quality presets
|
||||
QUALITY_PRESETS = {
|
||||
'web': {
|
||||
'video_crf': 23,
|
||||
'video_preset': 'medium',
|
||||
'audio_bitrate': '128k',
|
||||
'image_quality': 85
|
||||
},
|
||||
'archive': {
|
||||
'video_crf': 18,
|
||||
'video_preset': 'slow',
|
||||
'audio_bitrate': '192k',
|
||||
'image_quality': 95
|
||||
},
|
||||
'mobile': {
|
||||
'video_crf': 26,
|
||||
'video_preset': 'fast',
|
||||
'audio_bitrate': '96k',
|
||||
'image_quality': 80
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def check_dependencies() -> Tuple[bool, bool]:
|
||||
"""Check if ffmpeg and imagemagick are available."""
|
||||
ffmpeg_available = subprocess.run(
|
||||
['ffmpeg', '-version'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
).returncode == 0
|
||||
|
||||
magick_available = subprocess.run(
|
||||
['magick', '-version'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
).returncode == 0
|
||||
|
||||
return ffmpeg_available, magick_available
|
||||
|
||||
|
||||
def detect_media_type(file_path: Path) -> str:
|
||||
"""Detect media type from file extension."""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext in VIDEO_FORMATS:
|
||||
return 'video'
|
||||
elif ext in AUDIO_FORMATS:
|
||||
return 'audio'
|
||||
elif ext in IMAGE_FORMATS:
|
||||
return 'image'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def build_video_command(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
preset: str = 'web'
|
||||
) -> List[str]:
|
||||
"""Build FFmpeg command for video conversion."""
|
||||
quality = QUALITY_PRESETS[preset]
|
||||
|
||||
return [
|
||||
'ffmpeg', '-i', str(input_path),
|
||||
'-c:v', 'libx264',
|
||||
'-preset', quality['video_preset'],
|
||||
'-crf', str(quality['video_crf']),
|
||||
'-c:a', 'aac',
|
||||
'-b:a', quality['audio_bitrate'],
|
||||
'-movflags', '+faststart',
|
||||
'-y',
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
|
||||
def build_audio_command(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
preset: str = 'web'
|
||||
) -> List[str]:
|
||||
"""Build FFmpeg command for audio conversion."""
|
||||
quality = QUALITY_PRESETS[preset]
|
||||
output_ext = output_path.suffix.lower()
|
||||
|
||||
codec_map = {
|
||||
'.mp3': 'libmp3lame',
|
||||
'.aac': 'aac',
|
||||
'.m4a': 'aac',
|
||||
'.opus': 'libopus',
|
||||
'.flac': 'flac',
|
||||
'.wav': 'pcm_s16le',
|
||||
'.ogg': 'libvorbis'
|
||||
}
|
||||
|
||||
codec = codec_map.get(output_ext, 'aac')
|
||||
|
||||
cmd = ['ffmpeg', '-i', str(input_path), '-c:a', codec]
|
||||
|
||||
# Add bitrate for lossy codecs
|
||||
if codec not in ['flac', 'pcm_s16le']:
|
||||
cmd.extend(['-b:a', quality['audio_bitrate']])
|
||||
|
||||
cmd.extend(['-y', str(output_path)])
|
||||
return cmd
|
||||
|
||||
|
||||
def build_image_command(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
preset: str = 'web'
|
||||
) -> List[str]:
|
||||
"""Build ImageMagick command for image conversion."""
|
||||
quality = QUALITY_PRESETS[preset]
|
||||
|
||||
return [
|
||||
'magick', str(input_path),
|
||||
'-quality', str(quality['image_quality']),
|
||||
'-strip',
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
|
||||
def convert_file(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
preset: str = 'web',
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
"""Convert a single media file."""
|
||||
media_type = detect_media_type(input_path)
|
||||
|
||||
if media_type == 'unknown':
|
||||
print(f"Error: Unsupported format for {input_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build command based on media type
|
||||
if media_type == 'video':
|
||||
cmd = build_video_command(input_path, output_path, preset)
|
||||
elif media_type == 'audio':
|
||||
cmd = build_audio_command(input_path, output_path, preset)
|
||||
else: # image
|
||||
cmd = build_image_command(input_path, output_path, preset)
|
||||
|
||||
if verbose or dry_run:
|
||||
print(f"Command: {' '.join(cmd)}")
|
||||
|
||||
if dry_run:
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE if not verbose else None,
|
||||
stderr=subprocess.PIPE if not verbose else None,
|
||||
check=True
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error converting {input_path}: {e}", file=sys.stderr)
|
||||
if not verbose and e.stderr:
|
||||
print(e.stderr.decode(), file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error converting {input_path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def batch_convert(
|
||||
input_paths: List[Path],
|
||||
output_dir: Optional[Path] = None,
|
||||
output_format: Optional[str] = None,
|
||||
preset: str = 'web',
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False
|
||||
) -> Tuple[int, int]:
|
||||
"""Convert multiple files."""
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for input_path in input_paths:
|
||||
if not input_path.exists():
|
||||
print(f"Error: {input_path} not found", file=sys.stderr)
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
# Determine output path
|
||||
if output_dir:
|
||||
output_name = input_path.stem
|
||||
if output_format:
|
||||
output_path = output_dir / f"{output_name}.{output_format.lstrip('.')}"
|
||||
else:
|
||||
output_path = output_dir / input_path.name
|
||||
else:
|
||||
if output_format:
|
||||
output_path = input_path.with_suffix(f".{output_format.lstrip('.')}")
|
||||
else:
|
||||
print(f"Error: No output format specified for {input_path}", file=sys.stderr)
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
print(f"Converting {input_path.name} -> {output_path.name}")
|
||||
|
||||
if convert_file(input_path, output_path, preset, dry_run, verbose):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
return success_count, fail_count
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Unified media conversion tool for video, audio, and images.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'inputs',
|
||||
nargs='+',
|
||||
type=Path,
|
||||
help='Input file(s) to convert'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-o', '--output',
|
||||
type=Path,
|
||||
help='Output file or directory for batch conversion'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f', '--format',
|
||||
help='Output format (e.g., mp4, jpg, mp3)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-p', '--preset',
|
||||
choices=['web', 'archive', 'mobile'],
|
||||
default='web',
|
||||
help='Quality preset (default: web)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n', '--dry-run',
|
||||
action='store_true',
|
||||
help='Show commands without executing'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help='Verbose output'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check dependencies
|
||||
ffmpeg_ok, magick_ok = check_dependencies()
|
||||
if not ffmpeg_ok and not magick_ok:
|
||||
print("Error: Neither ffmpeg nor imagemagick found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Handle single file vs batch conversion
|
||||
if len(args.inputs) == 1 and args.output and not args.output.is_dir():
|
||||
# Single file conversion
|
||||
success = convert_file(
|
||||
args.inputs[0],
|
||||
args.output,
|
||||
args.preset,
|
||||
args.dry_run,
|
||||
args.verbose
|
||||
)
|
||||
sys.exit(0 if success else 1)
|
||||
else:
|
||||
# Batch conversion
|
||||
output_dir = args.output if args.output else Path.cwd()
|
||||
if not args.output:
|
||||
output_dir = None # Will convert in place with new format
|
||||
|
||||
success, fail = batch_convert(
|
||||
args.inputs,
|
||||
output_dir,
|
||||
args.format,
|
||||
args.preset,
|
||||
args.dry_run,
|
||||
args.verbose
|
||||
)
|
||||
|
||||
print(f"\nResults: {success} succeeded, {fail} failed")
|
||||
sys.exit(0 if fail == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user