Initial commit
This commit is contained in:
342
skills/media-processing/scripts/batch_resize.py
Normal file
342
skills/media-processing/scripts/batch_resize.py
Normal 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()
|
||||
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()
|
||||
24
skills/media-processing/scripts/requirements.txt
Normal file
24
skills/media-processing/scripts/requirements.txt
Normal 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
|
||||
2
skills/media-processing/scripts/tests/requirements.txt
Normal file
2
skills/media-processing/scripts/tests/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
372
skills/media-processing/scripts/tests/test_batch_resize.py
Normal file
372
skills/media-processing/scripts/tests/test_batch_resize.py
Normal 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"])
|
||||
259
skills/media-processing/scripts/tests/test_media_convert.py
Normal file
259
skills/media-processing/scripts/tests/test_media_convert.py
Normal 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"])
|
||||
397
skills/media-processing/scripts/tests/test_video_optimize.py
Normal file
397
skills/media-processing/scripts/tests/test_video_optimize.py
Normal 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"])
|
||||
414
skills/media-processing/scripts/video_optimize.py
Normal file
414
skills/media-processing/scripts/video_optimize.py
Normal 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()
|
||||
Reference in New Issue
Block a user