Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:48:52 +08:00
commit 6ec3196ecc
434 changed files with 125248 additions and 0 deletions

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Batch image resizing with multiple strategies.
Supports aspect ratio maintenance, smart cropping, thumbnail generation,
watermarks, format conversion, and parallel processing.
"""
import argparse
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import List, Optional, Tuple
class ImageResizer:
"""Handle image resizing operations using ImageMagick."""
def __init__(self, verbose: bool = False, dry_run: bool = False):
self.verbose = verbose
self.dry_run = dry_run
def check_imagemagick(self) -> bool:
"""Check if ImageMagick is available."""
try:
subprocess.run(
['magick', '-version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def build_resize_command(
self,
input_path: Path,
output_path: Path,
width: Optional[int],
height: Optional[int],
strategy: str,
quality: int,
watermark: Optional[Path] = None
) -> List[str]:
"""Build ImageMagick resize command based on strategy."""
cmd = ['magick', str(input_path)]
# Apply resize strategy
if strategy == 'fit':
# Fit within dimensions, maintain aspect ratio
geometry = f"{width or ''}x{height or ''}"
cmd.extend(['-resize', geometry])
elif strategy == 'fill':
# Fill dimensions, crop excess
if not width or not height:
raise ValueError("Both width and height required for 'fill' strategy")
cmd.extend([
'-resize', f'{width}x{height}^',
'-gravity', 'center',
'-extent', f'{width}x{height}'
])
elif strategy == 'cover':
# Cover dimensions, may exceed
if not width or not height:
raise ValueError("Both width and height required for 'cover' strategy")
cmd.extend(['-resize', f'{width}x{height}^'])
elif strategy == 'exact':
# Force exact dimensions, ignore aspect ratio
if not width or not height:
raise ValueError("Both width and height required for 'exact' strategy")
cmd.extend(['-resize', f'{width}x{height}!'])
elif strategy == 'thumbnail':
# Create square thumbnail
size = width or height or 200
cmd.extend([
'-resize', f'{size}x{size}^',
'-gravity', 'center',
'-extent', f'{size}x{size}'
])
# Add watermark if specified
if watermark:
cmd.extend([
str(watermark),
'-gravity', 'southeast',
'-geometry', '+10+10',
'-composite'
])
# Output settings
cmd.extend([
'-quality', str(quality),
'-strip',
str(output_path)
])
return cmd
def resize_image(
self,
input_path: Path,
output_path: Path,
width: Optional[int],
height: Optional[int],
strategy: str = 'fit',
quality: int = 85,
watermark: Optional[Path] = None
) -> bool:
"""Resize a single image."""
try:
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
cmd = self.build_resize_command(
input_path, output_path, width, height,
strategy, quality, watermark
)
if self.verbose or self.dry_run:
print(f"Command: {' '.join(cmd)}")
if self.dry_run:
return True
subprocess.run(
cmd,
stdout=subprocess.PIPE if not self.verbose else None,
stderr=subprocess.PIPE if not self.verbose else None,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"Error resizing {input_path}: {e}", file=sys.stderr)
if not self.verbose and e.stderr:
print(e.stderr.decode(), file=sys.stderr)
return False
except Exception as e:
print(f"Error processing {input_path}: {e}", file=sys.stderr)
return False
def batch_resize(
self,
input_paths: List[Path],
output_dir: Path,
width: Optional[int],
height: Optional[int],
strategy: str = 'fit',
quality: int = 85,
format_ext: Optional[str] = None,
watermark: Optional[Path] = None,
parallel: int = 1
) -> Tuple[int, int]:
"""Resize multiple images."""
success_count = 0
fail_count = 0
def process_image(input_path: Path) -> Tuple[Path, bool]:
"""Process single image for parallel execution."""
if not input_path.exists() or not input_path.is_file():
return input_path, False
# Determine output path
output_name = input_path.stem
if format_ext:
output_path = output_dir / f"{output_name}.{format_ext.lstrip('.')}"
else:
output_path = output_dir / input_path.name
if not self.dry_run:
print(f"Processing {input_path.name} -> {output_path.name}")
success = self.resize_image(
input_path, output_path, width, height,
strategy, quality, watermark
)
return input_path, success
# Process images
if parallel > 1:
with ThreadPoolExecutor(max_workers=parallel) as executor:
futures = [executor.submit(process_image, path) for path in input_paths]
for future in as_completed(futures):
_, success = future.result()
if success:
success_count += 1
else:
fail_count += 1
else:
for input_path in input_paths:
_, success = process_image(input_path)
if success:
success_count += 1
else:
fail_count += 1
return success_count, fail_count
def collect_images(paths: List[Path], recursive: bool = False) -> List[Path]:
"""Collect image files from paths."""
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'}
images = []
for path in paths:
if path.is_file() and path.suffix.lower() in image_exts:
images.append(path)
elif path.is_dir():
pattern = '**/*' if recursive else '*'
for img_path in path.glob(pattern):
if img_path.is_file() and img_path.suffix.lower() in image_exts:
images.append(img_path)
return images
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description='Batch image resizing with multiple strategies.'
)
parser.add_argument(
'inputs',
nargs='+',
type=Path,
help='Input image(s) or directory'
)
parser.add_argument(
'-o', '--output',
type=Path,
required=True,
help='Output directory'
)
parser.add_argument(
'-w', '--width',
type=int,
help='Target width in pixels'
)
parser.add_argument(
'-h', '--height',
type=int,
dest='img_height',
help='Target height in pixels'
)
parser.add_argument(
'-s', '--strategy',
choices=['fit', 'fill', 'cover', 'exact', 'thumbnail'],
default='fit',
help='Resize strategy (default: fit)'
)
parser.add_argument(
'-q', '--quality',
type=int,
default=85,
help='Output quality 0-100 (default: 85)'
)
parser.add_argument(
'-f', '--format',
help='Output format (e.g., jpg, png, webp)'
)
parser.add_argument(
'-wm', '--watermark',
type=Path,
help='Watermark image to overlay'
)
parser.add_argument(
'-p', '--parallel',
type=int,
default=1,
help='Number of parallel processes (default: 1)'
)
parser.add_argument(
'-r', '--recursive',
action='store_true',
help='Process directories recursively'
)
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()
# Validate dimensions
if not args.width and not args.img_height:
print("Error: At least one of --width or --height required", file=sys.stderr)
sys.exit(1)
# Initialize resizer
resizer = ImageResizer(verbose=args.verbose, dry_run=args.dry_run)
# Check dependencies
if not resizer.check_imagemagick():
print("Error: ImageMagick not found", file=sys.stderr)
sys.exit(1)
# Collect input images
images = collect_images(args.inputs, args.recursive)
if not images:
print("Error: No images found", file=sys.stderr)
sys.exit(1)
print(f"Found {len(images)} image(s) to process")
# Create output directory
if not args.dry_run:
args.output.mkdir(parents=True, exist_ok=True)
# Process images
success, fail = resizer.batch_resize(
images,
args.output,
args.width,
args.img_height,
args.strategy,
args.quality,
args.format,
args.watermark,
args.parallel
)
print(f"\nResults: {success} succeeded, {fail} failed")
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
main()

View 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()

View File

@@ -0,0 +1,24 @@
# Media Processing Skill Dependencies
# Python 3.10+ required
# No Python package dependencies - uses system binaries
# Required system tools (install separately):
# - FFmpeg (video/audio processing)
# - ImageMagick (image processing)
# Testing dependencies (dev)
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
# Installation instructions:
#
# Ubuntu/Debian:
# sudo apt-get install ffmpeg imagemagick
#
# macOS (Homebrew):
# brew install ffmpeg imagemagick
#
# Windows:
# choco install ffmpeg imagemagick
# or download from official websites

View File

@@ -0,0 +1,2 @@
pytest>=7.4.0
pytest-cov>=4.1.0

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Tests for batch_resize.py"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from batch_resize import ImageResizer, collect_images
class TestImageResizer:
"""Test ImageResizer class."""
def setup_method(self):
"""Set up test fixtures."""
self.resizer = ImageResizer(verbose=False, dry_run=False)
@patch("subprocess.run")
def test_check_imagemagick_available(self, mock_run):
"""Test ImageMagick availability check."""
mock_run.return_value = MagicMock(returncode=0)
assert self.resizer.check_imagemagick() is True
@patch("subprocess.run")
def test_check_imagemagick_unavailable(self, mock_run):
"""Test when ImageMagick is not available."""
mock_run.side_effect = FileNotFoundError()
assert self.resizer.check_imagemagick() is False
def test_build_resize_command_fit_strategy(self):
"""Test command building for 'fit' strategy."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="fit",
quality=85
)
assert "magick" in cmd
assert str(Path("input.jpg")) in cmd
assert "-resize" in cmd
assert "800x600" in cmd
assert "-quality" in cmd
assert "85" in cmd
assert "-strip" in cmd
def test_build_resize_command_fill_strategy(self):
"""Test command building for 'fill' strategy."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="fill",
quality=85
)
assert "-resize" in cmd
assert "800x600^" in cmd
assert "-gravity" in cmd
assert "center" in cmd
assert "-extent" in cmd
def test_build_resize_command_thumbnail_strategy(self):
"""Test command building for 'thumbnail' strategy."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=200,
height=None,
strategy="thumbnail",
quality=85
)
assert "200x200^" in cmd
assert "-gravity" in cmd
assert "center" in cmd
def test_build_resize_command_with_watermark(self):
"""Test command building with watermark."""
watermark = Path("watermark.png")
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=None,
strategy="fit",
quality=85,
watermark=watermark
)
assert str(watermark) in cmd
assert "-gravity" in cmd
assert "southeast" in cmd
assert "-composite" in cmd
def test_build_resize_command_exact_strategy(self):
"""Test command building for 'exact' strategy."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="exact",
quality=85
)
assert "800x600!" in cmd
def test_build_resize_command_fill_requires_dimensions(self):
"""Test that 'fill' strategy requires both dimensions."""
with pytest.raises(ValueError):
self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=None,
strategy="fill",
quality=85
)
@patch("subprocess.run")
def test_resize_image_success(self, mock_run):
"""Test successful image resize."""
mock_run.return_value = MagicMock(returncode=0)
result = self.resizer.resize_image(
Path("input.jpg"),
Path("output/output.jpg"),
width=800,
height=None,
strategy="fit",
quality=85
)
assert result is True
mock_run.assert_called_once()
@patch("subprocess.run")
def test_resize_image_dry_run(self, mock_run):
"""Test resize in dry-run mode."""
resizer = ImageResizer(dry_run=True)
result = resizer.resize_image(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=None
)
assert result is True
mock_run.assert_not_called()
@patch("subprocess.run")
def test_resize_image_failure(self, mock_run):
"""Test resize failure handling."""
mock_run.side_effect = Exception("Resize failed")
result = self.resizer.resize_image(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=None
)
assert result is False
class TestCollectImages:
"""Test image collection functionality."""
def test_collect_images_from_file(self, tmp_path):
"""Test collecting a single image file."""
img_file = tmp_path / "test.jpg"
img_file.touch()
images = collect_images([img_file])
assert len(images) == 1
assert images[0] == img_file
def test_collect_images_from_directory(self, tmp_path):
"""Test collecting images from directory."""
(tmp_path / "image1.jpg").touch()
(tmp_path / "image2.png").touch()
(tmp_path / "text.txt").touch()
images = collect_images([tmp_path])
assert len(images) == 2
assert all(img.suffix.lower() in {'.jpg', '.png'} for img in images)
def test_collect_images_recursive(self, tmp_path):
"""Test recursive image collection."""
subdir = tmp_path / "subdir"
subdir.mkdir()
(tmp_path / "image1.jpg").touch()
(subdir / "image2.jpg").touch()
images = collect_images([tmp_path], recursive=True)
assert len(images) == 2
images_non_recursive = collect_images([tmp_path], recursive=False)
assert len(images_non_recursive) == 1
def test_collect_images_filters_extensions(self, tmp_path):
"""Test that only image files are collected."""
(tmp_path / "image.jpg").touch()
(tmp_path / "doc.pdf").touch()
(tmp_path / "text.txt").touch()
images = collect_images([tmp_path])
assert len(images) == 1
assert images[0].suffix.lower() == '.jpg'
def test_collect_images_multiple_paths(self, tmp_path):
"""Test collecting from multiple paths."""
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
dir1.mkdir()
dir2.mkdir()
(dir1 / "image1.jpg").touch()
(dir2 / "image2.png").touch()
images = collect_images([dir1, dir2])
assert len(images) == 2
class TestBatchResize:
"""Test batch resize functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.resizer = ImageResizer(verbose=False, dry_run=False)
@patch.object(ImageResizer, "resize_image")
def test_batch_resize_success(self, mock_resize, tmp_path):
"""Test successful batch resize."""
mock_resize.return_value = True
input_images = [
tmp_path / "image1.jpg",
tmp_path / "image2.jpg"
]
for img in input_images:
img.touch()
output_dir = tmp_path / "output"
success, fail = self.resizer.batch_resize(
input_images,
output_dir,
width=800,
height=None,
strategy="fit"
)
assert success == 2
assert fail == 0
assert mock_resize.call_count == 2
@patch.object(ImageResizer, "resize_image")
def test_batch_resize_with_failures(self, mock_resize, tmp_path):
"""Test batch resize with some failures."""
mock_resize.side_effect = [True, False, True]
input_images = [
tmp_path / "image1.jpg",
tmp_path / "image2.jpg",
tmp_path / "image3.jpg"
]
for img in input_images:
img.touch()
output_dir = tmp_path / "output"
success, fail = self.resizer.batch_resize(
input_images,
output_dir,
width=800,
height=None
)
assert success == 2
assert fail == 1
@patch.object(ImageResizer, "resize_image")
def test_batch_resize_format_conversion(self, mock_resize, tmp_path):
"""Test batch resize with format conversion."""
mock_resize.return_value = True
input_images = [tmp_path / "image.png"]
input_images[0].touch()
output_dir = tmp_path / "output"
self.resizer.batch_resize(
input_images,
output_dir,
width=800,
height=None,
format_ext="jpg"
)
# Check that resize_image was called with .jpg extension
call_args = mock_resize.call_args[0]
assert call_args[1].suffix == ".jpg"
class TestResizeStrategies:
"""Test different resize strategies."""
def setup_method(self):
"""Set up test fixtures."""
self.resizer = ImageResizer()
def test_fit_strategy_maintains_aspect(self):
"""Test that 'fit' strategy maintains aspect ratio."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="fit",
quality=85
)
# Should have resize without ^ or !
resize_idx = cmd.index("-resize")
geometry = cmd[resize_idx + 1]
assert "^" not in geometry
assert "!" not in geometry
def test_cover_strategy_fills_dimensions(self):
"""Test that 'cover' strategy fills dimensions."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="cover",
quality=85
)
resize_idx = cmd.index("-resize")
geometry = cmd[resize_idx + 1]
assert "^" in geometry
def test_exact_strategy_ignores_aspect(self):
"""Test that 'exact' strategy ignores aspect ratio."""
cmd = self.resizer.build_resize_command(
Path("input.jpg"),
Path("output.jpg"),
width=800,
height=600,
strategy="exact",
quality=85
)
resize_idx = cmd.index("-resize")
geometry = cmd[resize_idx + 1]
assert "!" in geometry
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Tests for media_convert.py"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from media_convert import (
build_audio_command,
build_image_command,
build_video_command,
check_dependencies,
convert_file,
detect_media_type,
)
class TestMediaTypeDetection:
"""Test media type detection."""
def test_detect_video_formats(self):
"""Test video format detection."""
assert detect_media_type(Path("test.mp4")) == "video"
assert detect_media_type(Path("test.mkv")) == "video"
assert detect_media_type(Path("test.avi")) == "video"
assert detect_media_type(Path("test.mov")) == "video"
def test_detect_audio_formats(self):
"""Test audio format detection."""
assert detect_media_type(Path("test.mp3")) == "audio"
assert detect_media_type(Path("test.aac")) == "audio"
assert detect_media_type(Path("test.flac")) == "audio"
assert detect_media_type(Path("test.wav")) == "audio"
def test_detect_image_formats(self):
"""Test image format detection."""
assert detect_media_type(Path("test.jpg")) == "image"
assert detect_media_type(Path("test.png")) == "image"
assert detect_media_type(Path("test.gif")) == "image"
assert detect_media_type(Path("test.webp")) == "image"
def test_detect_unknown_format(self):
"""Test unknown format detection."""
assert detect_media_type(Path("test.txt")) == "unknown"
assert detect_media_type(Path("test.doc")) == "unknown"
def test_case_insensitive(self):
"""Test case-insensitive detection."""
assert detect_media_type(Path("TEST.MP4")) == "video"
assert detect_media_type(Path("TEST.JPG")) == "image"
class TestCommandBuilding:
"""Test command building functions."""
def test_build_video_command_web_preset(self):
"""Test video command with web preset."""
cmd = build_video_command(
Path("input.mp4"),
Path("output.mp4"),
preset="web"
)
assert "ffmpeg" in cmd
assert "-i" in cmd
assert str(Path("input.mp4")) in cmd
assert "-c:v" in cmd
assert "libx264" in cmd
assert "-crf" in cmd
assert "23" in cmd
assert "-preset" in cmd
assert "medium" in cmd
assert str(Path("output.mp4")) in cmd
def test_build_video_command_archive_preset(self):
"""Test video command with archive preset."""
cmd = build_video_command(
Path("input.mp4"),
Path("output.mp4"),
preset="archive"
)
assert "18" in cmd # CRF for archive
assert "slow" in cmd # Preset for archive
def test_build_audio_command_mp3(self):
"""Test audio command for MP3 output."""
cmd = build_audio_command(
Path("input.wav"),
Path("output.mp3"),
preset="web"
)
assert "ffmpeg" in cmd
assert "-c:a" in cmd
assert "libmp3lame" in cmd
assert "-b:a" in cmd
def test_build_audio_command_flac(self):
"""Test audio command for FLAC (lossless)."""
cmd = build_audio_command(
Path("input.wav"),
Path("output.flac"),
preset="web"
)
assert "flac" in cmd
assert "-b:a" not in cmd # No bitrate for lossless
def test_build_image_command(self):
"""Test image command building."""
cmd = build_image_command(
Path("input.png"),
Path("output.jpg"),
preset="web"
)
assert "magick" in cmd
assert str(Path("input.png")) in cmd
assert "-quality" in cmd
assert "85" in cmd
assert "-strip" in cmd
assert str(Path("output.jpg")) in cmd
class TestDependencyCheck:
"""Test dependency checking."""
@patch("subprocess.run")
def test_check_dependencies_both_available(self, mock_run):
"""Test when both tools are available."""
mock_run.return_value = MagicMock(returncode=0)
ffmpeg_ok, magick_ok = check_dependencies()
assert ffmpeg_ok is True
assert magick_ok is True
@patch("subprocess.run")
def test_check_dependencies_ffmpeg_only(self, mock_run):
"""Test when only FFmpeg is available."""
def side_effect(*args, **kwargs):
if "ffmpeg" in args[0]:
return MagicMock(returncode=0)
return MagicMock(returncode=1)
mock_run.side_effect = side_effect
ffmpeg_ok, magick_ok = check_dependencies()
assert ffmpeg_ok is True
assert magick_ok is False
class TestFileConversion:
"""Test file conversion functionality."""
@patch("subprocess.run")
@patch("media_convert.detect_media_type")
def test_convert_video_file_dry_run(self, mock_detect, mock_run):
"""Test video conversion in dry-run mode."""
mock_detect.return_value = "video"
result = convert_file(
Path("input.mp4"),
Path("output.mp4"),
preset="web",
dry_run=True
)
assert result is True
mock_run.assert_not_called()
@patch("subprocess.run")
@patch("media_convert.detect_media_type")
def test_convert_image_file_success(self, mock_detect, mock_run):
"""Test successful image conversion."""
mock_detect.return_value = "image"
mock_run.return_value = MagicMock(returncode=0)
result = convert_file(
Path("input.png"),
Path("output.jpg"),
preset="web"
)
assert result is True
mock_run.assert_called_once()
@patch("subprocess.run")
@patch("media_convert.detect_media_type")
def test_convert_file_error(self, mock_detect, mock_run):
"""Test conversion error handling."""
mock_detect.return_value = "video"
mock_run.side_effect = Exception("Conversion failed")
result = convert_file(
Path("input.mp4"),
Path("output.mp4")
)
assert result is False
@patch("media_convert.detect_media_type")
def test_convert_unknown_format(self, mock_detect):
"""Test conversion with unknown format."""
mock_detect.return_value = "unknown"
result = convert_file(
Path("input.txt"),
Path("output.txt")
)
assert result is False
class TestQualityPresets:
"""Test quality preset functionality."""
def test_web_preset_settings(self):
"""Test web preset values."""
cmd = build_video_command(
Path("input.mp4"),
Path("output.mp4"),
preset="web"
)
cmd_str = " ".join(cmd)
assert "23" in cmd_str # CRF
assert "128k" in cmd_str # Audio bitrate
def test_archive_preset_settings(self):
"""Test archive preset values."""
cmd = build_video_command(
Path("input.mp4"),
Path("output.mp4"),
preset="archive"
)
cmd_str = " ".join(cmd)
assert "18" in cmd_str # Higher quality CRF
assert "192k" in cmd_str # Higher audio bitrate
def test_mobile_preset_settings(self):
"""Test mobile preset values."""
cmd = build_video_command(
Path("input.mp4"),
Path("output.mp4"),
preset="mobile"
)
cmd_str = " ".join(cmd)
assert "26" in cmd_str # Lower quality CRF
assert "96k" in cmd_str # Lower audio bitrate
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Tests for video_optimize.py"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from video_optimize import VideoInfo, VideoOptimizer
class TestVideoOptimizer:
"""Test VideoOptimizer class."""
def setup_method(self):
"""Set up test fixtures."""
self.optimizer = VideoOptimizer(verbose=False, dry_run=False)
@patch("subprocess.run")
def test_check_ffmpeg_available(self, mock_run):
"""Test FFmpeg availability check."""
mock_run.return_value = MagicMock(returncode=0)
assert self.optimizer.check_ffmpeg() is True
@patch("subprocess.run")
def test_check_ffmpeg_unavailable(self, mock_run):
"""Test when FFmpeg is not available."""
mock_run.side_effect = FileNotFoundError()
assert self.optimizer.check_ffmpeg() is False
@patch("subprocess.run")
def test_get_video_info_success(self, mock_run):
"""Test successful video info extraction."""
mock_data = {
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"r_frame_rate": "30/1"
},
{
"codec_type": "audio",
"codec_name": "aac",
"bit_rate": "128000"
}
],
"format": {
"duration": "120.5",
"bit_rate": "5000000",
"size": "75000000"
}
}
mock_run.return_value = MagicMock(
stdout=json.dumps(mock_data).encode(),
returncode=0
)
info = self.optimizer.get_video_info(Path("test.mp4"))
assert info is not None
assert info.width == 1920
assert info.height == 1080
assert info.fps == 30.0
assert info.codec == "h264"
assert info.audio_codec == "aac"
@patch("subprocess.run")
def test_get_video_info_failure(self, mock_run):
"""Test video info extraction failure."""
mock_run.side_effect = Exception("ffprobe failed")
info = self.optimizer.get_video_info(Path("test.mp4"))
assert info is None
def test_calculate_target_resolution_no_constraints(self):
"""Test resolution calculation without constraints."""
width, height = self.optimizer.calculate_target_resolution(
1920, 1080, None, None
)
assert width == 1920
assert height == 1080
def test_calculate_target_resolution_width_constraint(self):
"""Test resolution calculation with width constraint."""
width, height = self.optimizer.calculate_target_resolution(
1920, 1080, 1280, None
)
assert width == 1280
assert height == 720
def test_calculate_target_resolution_height_constraint(self):
"""Test resolution calculation with height constraint."""
width, height = self.optimizer.calculate_target_resolution(
1920, 1080, None, 720
)
assert width == 1280
assert height == 720
def test_calculate_target_resolution_both_constraints(self):
"""Test resolution calculation with both constraints."""
width, height = self.optimizer.calculate_target_resolution(
1920, 1080, 1280, 720
)
assert width == 1280
assert height == 720
def test_calculate_target_resolution_even_dimensions(self):
"""Test that dimensions are always even."""
width, height = self.optimizer.calculate_target_resolution(
1920, 1080, 1279, None # Odd width
)
assert width % 2 == 0
assert height % 2 == 0
def test_calculate_target_resolution_no_upscale(self):
"""Test that small videos are not upscaled."""
width, height = self.optimizer.calculate_target_resolution(
640, 480, 1920, 1080
)
assert width == 640
assert height == 480
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_dry_run(self, mock_get_info, mock_run):
"""Test video optimization in dry-run mode."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
optimizer = VideoOptimizer(dry_run=True)
result = optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4"),
max_width=1280
)
assert result is True
mock_run.assert_not_called()
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_resolution_reduction(self, mock_get_info, mock_run):
"""Test video optimization with resolution reduction."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
mock_run.return_value = MagicMock(returncode=0)
result = self.optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4"),
max_width=1280,
max_height=720
)
assert result is True
mock_run.assert_called_once()
# Check that scale filter is applied
cmd = mock_run.call_args[0][0]
assert "-vf" in cmd
filter_idx = cmd.index("-vf")
assert "scale=1280:720" in cmd[filter_idx + 1]
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_fps_reduction(self, mock_get_info, mock_run):
"""Test video optimization with FPS reduction."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=60.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
mock_run.return_value = MagicMock(returncode=0)
result = self.optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4"),
target_fps=30.0
)
assert result is True
# Check that FPS filter is applied
cmd = mock_run.call_args[0][0]
assert "-r" in cmd
fps_idx = cmd.index("-r")
assert "30.0" in cmd[fps_idx + 1]
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_two_pass(self, mock_get_info, mock_run):
"""Test two-pass encoding."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
mock_run.return_value = MagicMock(returncode=0)
result = self.optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4"),
two_pass=True
)
assert result is True
# Should be called twice (pass 1 and pass 2)
assert mock_run.call_count == 2
# Check pass 1 command
pass1_cmd = mock_run.call_args_list[0][0][0]
assert "-pass" in pass1_cmd
assert "1" in pass1_cmd
# Check pass 2 command
pass2_cmd = mock_run.call_args_list[1][0][0]
assert "-pass" in pass2_cmd
assert "2" in pass2_cmd
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_crf_encoding(self, mock_get_info, mock_run):
"""Test CRF-based encoding (single pass)."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
mock_run.return_value = MagicMock(returncode=0)
result = self.optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4"),
crf=23,
two_pass=False
)
assert result is True
mock_run.assert_called_once()
# Check CRF parameter
cmd = mock_run.call_args[0][0]
assert "-crf" in cmd
crf_idx = cmd.index("-crf")
assert "23" in cmd[crf_idx + 1]
@patch("subprocess.run")
@patch.object(VideoOptimizer, "get_video_info")
def test_optimize_video_failure(self, mock_get_info, mock_run):
"""Test optimization failure handling."""
mock_info = VideoInfo(
path=Path("input.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.return_value = mock_info
mock_run.side_effect = Exception("FFmpeg failed")
result = self.optimizer.optimize_video(
Path("input.mp4"),
Path("output.mp4")
)
assert result is False
class TestVideoInfo:
"""Test VideoInfo dataclass."""
def test_video_info_creation(self):
"""Test creating VideoInfo object."""
info = VideoInfo(
path=Path("test.mp4"),
duration=120.5,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
assert info.width == 1920
assert info.height == 1080
assert info.fps == 30.0
assert info.codec == "h264"
class TestCompareVideos:
"""Test video comparison functionality."""
@patch.object(VideoOptimizer, "get_video_info")
def test_compare_videos_success(self, mock_get_info, capsys):
"""Test video comparison output."""
orig_info = VideoInfo(
path=Path("original.mp4"),
duration=120.0,
width=1920,
height=1080,
bitrate=5000000,
fps=30.0,
size=75000000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
opt_info = VideoInfo(
path=Path("optimized.mp4"),
duration=120.0,
width=1280,
height=720,
bitrate=2500000,
fps=30.0,
size=37500000,
codec="h264",
audio_codec="aac",
audio_bitrate=128000
)
mock_get_info.side_effect = [orig_info, opt_info]
optimizer = VideoOptimizer()
optimizer.compare_videos(Path("original.mp4"), Path("optimized.mp4"))
captured = capsys.readouterr()
assert "Resolution" in captured.out
assert "1920x1080" in captured.out
assert "1280x720" in captured.out
assert "50.0%" in captured.out # Size reduction
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""
Video size optimization with quality/size balance.
Supports resolution reduction, frame rate adjustment, audio bitrate optimization,
multi-pass encoding, and comparison metrics.
"""
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
@dataclass
class VideoInfo:
"""Video file information."""
path: Path
duration: float
width: int
height: int
bitrate: int
fps: float
size: int
codec: str
audio_codec: str
audio_bitrate: int
class VideoOptimizer:
"""Handle video optimization operations using FFmpeg."""
def __init__(self, verbose: bool = False, dry_run: bool = False):
self.verbose = verbose
self.dry_run = dry_run
def check_ffmpeg(self) -> bool:
"""Check if FFmpeg is available."""
try:
subprocess.run(
['ffmpeg', '-version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def get_video_info(self, input_path: Path) -> Optional[VideoInfo]:
"""Extract video information using ffprobe."""
try:
cmd = [
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
str(input_path)
]
result = subprocess.run(cmd, capture_output=True, check=True)
data = json.loads(result.stdout)
# Find video and audio streams
video_stream = None
audio_stream = None
for stream in data['streams']:
if stream['codec_type'] == 'video' and not video_stream:
video_stream = stream
elif stream['codec_type'] == 'audio' and not audio_stream:
audio_stream = stream
if not video_stream:
return None
# Parse frame rate
fps_parts = video_stream.get('r_frame_rate', '0/1').split('/')
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else 0
return VideoInfo(
path=input_path,
duration=float(data['format'].get('duration', 0)),
width=int(video_stream.get('width', 0)),
height=int(video_stream.get('height', 0)),
bitrate=int(data['format'].get('bit_rate', 0)),
fps=fps,
size=int(data['format'].get('size', 0)),
codec=video_stream.get('codec_name', 'unknown'),
audio_codec=audio_stream.get('codec_name', 'none') if audio_stream else 'none',
audio_bitrate=int(audio_stream.get('bit_rate', 0)) if audio_stream else 0
)
except Exception as e:
print(f"Error getting video info: {e}", file=sys.stderr)
return None
def calculate_target_resolution(
self,
width: int,
height: int,
max_width: Optional[int],
max_height: Optional[int]
) -> Tuple[int, int]:
"""Calculate target resolution maintaining aspect ratio."""
if not max_width and not max_height:
return width, height
aspect_ratio = width / height
if max_width and max_height:
# Fit within both constraints
if width > max_width or height > max_height:
if width / max_width > height / max_height:
new_width = max_width
new_height = int(max_width / aspect_ratio)
else:
new_height = max_height
new_width = int(max_height * aspect_ratio)
else:
new_width, new_height = width, height
elif max_width:
new_width = min(width, max_width)
new_height = int(new_width / aspect_ratio)
else:
new_height = min(height, max_height)
new_width = int(new_height * aspect_ratio)
# Ensure dimensions are even (required by some codecs)
new_width = new_width - (new_width % 2)
new_height = new_height - (new_height % 2)
return new_width, new_height
def optimize_video(
self,
input_path: Path,
output_path: Path,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
target_fps: Optional[float] = None,
crf: int = 23,
audio_bitrate: str = '128k',
preset: str = 'medium',
two_pass: bool = False
) -> bool:
"""Optimize a video file."""
# Get input video info
info = self.get_video_info(input_path)
if not info:
print(f"Error: Could not read video info for {input_path}", file=sys.stderr)
return False
if self.verbose:
print(f"\nInput video info:")
print(f" Resolution: {info.width}x{info.height}")
print(f" FPS: {info.fps:.2f}")
print(f" Bitrate: {info.bitrate // 1000} kbps")
print(f" Size: {info.size / (1024*1024):.2f} MB")
# Calculate target resolution
target_width, target_height = self.calculate_target_resolution(
info.width, info.height, max_width, max_height
)
# Build FFmpeg command
cmd = ['ffmpeg', '-i', str(input_path)]
# Video filters
filters = []
if target_width != info.width or target_height != info.height:
filters.append(f'scale={target_width}:{target_height}')
if filters:
cmd.extend(['-vf', ','.join(filters)])
# Frame rate adjustment
if target_fps and target_fps < info.fps:
cmd.extend(['-r', str(target_fps)])
# Video encoding
if two_pass:
# Two-pass encoding for better quality
target_bitrate = int(info.bitrate * 0.7) # 30% reduction
# Pass 1
pass1_cmd = cmd + [
'-c:v', 'libx264',
'-preset', preset,
'-b:v', str(target_bitrate),
'-pass', '1',
'-an',
'-f', 'null',
'/dev/null' if sys.platform != 'win32' else 'NUL'
]
if self.verbose or self.dry_run:
print(f"Pass 1: {' '.join(pass1_cmd)}")
if not self.dry_run:
try:
subprocess.run(pass1_cmd, check=True, capture_output=not self.verbose)
except subprocess.CalledProcessError as e:
print(f"Error in pass 1: {e}", file=sys.stderr)
return False
# Pass 2
cmd.extend([
'-c:v', 'libx264',
'-preset', preset,
'-b:v', str(target_bitrate),
'-pass', '2'
])
else:
# Single-pass CRF encoding
cmd.extend([
'-c:v', 'libx264',
'-preset', preset,
'-crf', str(crf)
])
# Audio encoding
cmd.extend([
'-c:a', 'aac',
'-b:a', audio_bitrate
])
# Output
cmd.extend(['-movflags', '+faststart', '-y', str(output_path)])
if self.verbose or self.dry_run:
print(f"Command: {' '.join(cmd)}")
if self.dry_run:
return True
# Execute
try:
subprocess.run(cmd, check=True, capture_output=not self.verbose)
# Get output info
output_info = self.get_video_info(output_path)
if output_info and self.verbose:
print(f"\nOutput video info:")
print(f" Resolution: {output_info.width}x{output_info.height}")
print(f" FPS: {output_info.fps:.2f}")
print(f" Bitrate: {output_info.bitrate // 1000} kbps")
print(f" Size: {output_info.size / (1024*1024):.2f} MB")
reduction = (1 - output_info.size / info.size) * 100
print(f" Size reduction: {reduction:.1f}%")
return True
except subprocess.CalledProcessError as e:
print(f"Error optimizing video: {e}", file=sys.stderr)
return False
except Exception as e:
print(f"Error optimizing video: {e}", file=sys.stderr)
return False
finally:
# Clean up two-pass log files
if two_pass and not self.dry_run:
for log_file in Path('.').glob('ffmpeg2pass-*.log*'):
log_file.unlink(missing_ok=True)
def compare_videos(self, original: Path, optimized: Path) -> None:
"""Compare original and optimized videos."""
orig_info = self.get_video_info(original)
opt_info = self.get_video_info(optimized)
if not orig_info or not opt_info:
print("Error: Could not compare videos", file=sys.stderr)
return
print(f"\n{'Metric':<20} {'Original':<20} {'Optimized':<20} {'Change':<15}")
print("-" * 75)
# Resolution
orig_res = f"{orig_info.width}x{orig_info.height}"
opt_res = f"{opt_info.width}x{opt_info.height}"
print(f"{'Resolution':<20} {orig_res:<20} {opt_res:<20}")
# FPS
fps_change = opt_info.fps - orig_info.fps
print(f"{'FPS':<20} {orig_info.fps:<20.2f} {opt_info.fps:<20.2f} {fps_change:+.2f}")
# Bitrate
orig_br = f"{orig_info.bitrate // 1000} kbps"
opt_br = f"{opt_info.bitrate // 1000} kbps"
br_change = ((opt_info.bitrate / orig_info.bitrate) - 1) * 100
print(f"{'Bitrate':<20} {orig_br:<20} {opt_br:<20} {br_change:+.1f}%")
# Size
orig_size = f"{orig_info.size / (1024*1024):.2f} MB"
opt_size = f"{opt_info.size / (1024*1024):.2f} MB"
size_reduction = (1 - opt_info.size / orig_info.size) * 100
print(f"{'Size':<20} {orig_size:<20} {opt_size:<20} {-size_reduction:.1f}%")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description='Video size optimization with quality/size balance.'
)
parser.add_argument(
'input',
type=Path,
help='Input video file'
)
parser.add_argument(
'-o', '--output',
type=Path,
required=True,
help='Output video file'
)
parser.add_argument(
'-w', '--max-width',
type=int,
help='Maximum width in pixels'
)
parser.add_argument(
'-H', '--max-height',
type=int,
help='Maximum height in pixels'
)
parser.add_argument(
'--fps',
type=float,
help='Target frame rate'
)
parser.add_argument(
'--crf',
type=int,
default=23,
help='CRF quality (18-28, lower=better, default: 23)'
)
parser.add_argument(
'--audio-bitrate',
default='128k',
help='Audio bitrate (default: 128k)'
)
parser.add_argument(
'--preset',
choices=['ultrafast', 'superfast', 'veryfast', 'faster', 'fast',
'medium', 'slow', 'slower', 'veryslow'],
default='medium',
help='Encoding preset (default: medium)'
)
parser.add_argument(
'--two-pass',
action='store_true',
help='Use two-pass encoding (better quality)'
)
parser.add_argument(
'--compare',
action='store_true',
help='Compare original and optimized videos'
)
parser.add_argument(
'-n', '--dry-run',
action='store_true',
help='Show command without executing'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Verbose output'
)
args = parser.parse_args()
# Validate input
if not args.input.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
# Initialize optimizer
optimizer = VideoOptimizer(verbose=args.verbose, dry_run=args.dry_run)
# Check dependencies
if not optimizer.check_ffmpeg():
print("Error: FFmpeg not found", file=sys.stderr)
sys.exit(1)
# Optimize video
print(f"Optimizing {args.input.name}...")
success = optimizer.optimize_video(
args.input,
args.output,
args.max_width,
args.max_height,
args.fps,
args.crf,
args.audio_bitrate,
args.preset,
args.two_pass
)
if not success:
sys.exit(1)
# Compare if requested
if args.compare and not args.dry_run:
optimizer.compare_videos(args.input, args.output)
print(f"\nOptimized video saved to: {args.output}")
if __name__ == '__main__':
main()