
Media Processing
- 131 installs
- 20 repo stars
- Updated March 21, 2026
- siviter-xyz/dot-agent
Use media-processing for development tasks
About
media-processing: A skill for development. This provides functionality for development workflows.
- media-processing
Media Processing by the numbers
- 131 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,723 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/siviter-xyz/dot-agent --skill media-processingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 131 |
|---|---|
| repo stars | ★ 20 |
| Last updated | March 21, 2026 |
| Repository | siviter-xyz/dot-agent ↗ |
What it does
Use media-processing for development tasks
Files
Media Processing
Tools and workflows for working with images, audio, and video in a repeatable, scriptable way using standard CLI tools (FFmpeg, ImageMagick) and Python helpers.
When to Use
- Working with image batches (thumbnails, resizing, format conversion)
- Converting media between formats (video ↔ audio ↔ image)
- Optimizing video size while maintaining acceptable quality
- Preparing assets for web, mobile, or archival use
- Designing or refining CLI workflows around FFmpeg/ImageMagick
Key Principles
- CLI-first workflows: Prefer command-line tools (FFmpeg, ImageMagick) and scripts that can be automated in CI or local tooling.
- Deterministic scripts: Scripts should be safe to run repeatedly with predictable output paths and options.
- Non-destructive defaults: Default to writing outputs to new files/directories rather than overwriting originals.
- Cross-platform friendly: Keep examples and scripts usable on Linux, macOS, and Windows where possible.
- Agent-agnostic: Guidance should work with any coding agent (Cursor, Claude, Copilot, etc.), not one specific environment.
Capabilities
- Image workflows
- Batch resize and thumbnail generation
- Aspect-ratio–aware resizing (fit, fill, cover, exact)
- Optional watermarking
- Format conversion (e.g., PNG → WebP, JPEG)
- Media conversion
- Detects media type (video, audio, image) from extension
- Uses FFmpeg for video/audio, ImageMagick for images
- Quality presets for
web,archive, andmobileuse cases - Batch conversion with dry-run and verbose modes
- Video optimization
- Resolution and frame-rate adjustments
- Single-pass (CRF) or two-pass encoding
- Audio bitrate tuning
- Basic before/after comparison (size, bitrate, resolution, FPS)
Scripts
Scripts live in scripts/ and are intended to be run directly from a shell:
batch_resize.py- Batch image resizing with multiple strategies (
fit,fill,cover,exact,thumbnail) - Optional watermark overlay
- Supports parallel processing and dry-run mode
media_convert.py- Unified conversion tool for video, audio, and images
- Automatically picks FFmpeg or ImageMagick
- Uses quality presets (
web,archive,mobile) - Supports batch conversion and format changes (e.g.,
.mov→.mp4,.wav→.mp3)
video_optimize.py- Focused on video size/quality trade-offs
- Resolution caps, FPS reduction, CRF tuning, optional two-pass encoding
- Optional comparison summary between original and optimized outputs
See scripts/requirements.txt for environment expectations (Python 3.10+, FFmpeg, ImageMagick) and system installation hints.
Usage Guidelines
- Check dependencies first
- Ensure
ffmpegandffprobeare installed and onPATHfor video/audio tasks. - Ensure
magick(ImageMagick) is installed and onPATHfor image tasks.
- Prefer dry-runs when exploring
- Use
--dry-runand/or--verboseflags on scripts to inspect generated commands before running them.
- Keep originals
- Point outputs to a separate directory on first runs (e.g.,
--output ./out/) to avoid accidental overwrites.
- Document workflows
- When you find good command lines or script invocations, promote them into project scripts (e.g.,
just,npm scripts, or CI jobs) so they’re repeatable.
References
For deeper tool-specific notes (placeholders for now, extend as needed), see:
references/ffmpeg-encoding.md– FFmpeg encoding patterns and flagsreferences/ffmpeg-filters.md– Common filter graphs (scale, crop, audio filters)references/ffmpeg-streaming.md– Streaming-friendly settings and HLS/DASH tipsreferences/format-compatibility.md– Container/codec compatibility notes (web, mobile, desktop)references/imagemagick-batch.md– Batch image processing patterns with ImageMagickreferences/imagemagick-editing.md– Image editing operations (crop, resize, composite, text, etc.)
FFmpeg Encoding Cheatsheet
Practical patterns for encoding and re-encoding media with FFmpeg.
General Tips
- Always inspect inputs first:
ffprobe -hide_banner -i input.mp4- Make commands idempotent and explicit:
- Avoid relying on container defaults
- Specify codecs, bitrates/CRF, and key flags
- Prefer CRF-based video quality for most workflows instead of fixed bitrates.
Common Video Encodes
H.264 for Web (MP4)
ffmpeg -i input.mov \
-c:v libx264 -preset medium -crf 23 \
-c:a aac -b:a 128k \
-movflags +faststart \
output.mp4- Lower
-crf= better quality, larger file (typical range: 18–28) -presettrades CPU for size (fast ↔ slow)-movflags +faststartoptimizes for web streaming.
H.265 / HEVC (Smaller, Slower)
ffmpeg -i input.mp4 \
-c:v libx265 -preset slow -crf 26 \
-c:a aac -b:a 128k \
output-hevc.mp4- Better compression than H.264 but slower and less universally supported.
VP9 / WebM
ffmpeg -i input.mp4 \
-c:v libvpx-vp9 -b:v 0 -crf 30 \
-c:a libopus -b:a 128k \
output.webm-b:v 0+-crfis the recommended VP9 quality mode.
Audio Encoding
MP3
ffmpeg -i input.wav \
-c:a libmp3lame -b:a 192k \
output.mp3AAC
ffmpeg -i input.wav \
-c:a aac -b:a 128k \
output.m4aLossless FLAC
ffmpeg -i input.wav \
-c:a flac \
output.flacTwo-Pass Encoding (Bitrate-Targeted)
Useful when you need a predictable average bitrate (e.g., constrained bandwidth).
ffmpeg -y -i input.mp4 \
-c:v libx264 -b:v 2500k -pass 1 -an \
-f mp4 /dev/null
ffmpeg -i input.mp4 \
-c:v libx264 -b:v 2500k -pass 2 \
-c:a aac -b:a 128k \
output-2pass.mp4Upscaling / Downscaling with Encoding
ffmpeg -i input.mp4 \
-vf "scale=1280:-2" \
-c:v libx264 -crf 23 -preset medium \
-c:a copy \
output-720p.mp4-2lets FFmpeg pick the nearest even value for height to preserve aspect ratio.
FFmpeg Filter Basics
FFmpeg filters let you transform audio and video streams (scale, crop, overlay, mix, etc.).
Syntax
ffmpeg -i input.mp4 -vf "filter1=params,filter2=params" -af "afilter1,afilter2" output.mp4-vf– video filter chain-af– audio filter chain- Multiple filters are comma-separated and run left → right.
Common Video Filters
Scale
# Scale width to 1280, keep aspect ratio
ffmpeg -i input.mp4 -vf "scale=1280:-2" output.mp4# Scale to fit within 1280x720, preserving aspect
ffmpeg -i input.mp4 -vf "scale='min(1280,iw)':'min(720,ih)'" output.mp4Crop
# Crop to 1920x800 centered
ffmpeg -i input.mp4 -vf "crop=1920:800" output.mp4# Crop from top-left region
ffmpeg -i input.mp4 -vf "crop=640:360:0:0" output.mp4Draw Text (Requires libfreetype)
ffmpeg -i input.mp4 -vf "drawtext=text='Sample':x=10:y=H-th-10:fontcolor=white:shadowx=2:shadowy=2" \
-c:a copy output.mp4Overlay (Watermark)
ffmpeg -i input.mp4 -i watermark.png \
-filter_complex "overlay=W-w-10:H-h-10" \
-c:a copy output.mp4W,H– main video width/heightw,h– overlay width/height.
Common Audio Filters
Volume
ffmpeg -i input.mp3 -af "volume=1.5" louder.mp3Fade In / Fade Out
ffmpeg -i input.mp3 -af "afade=t=in:ss=0:d=3,afade=t=out:st=27:d=3" faded.mp3d– duration,ss– start time for fade-in,st– start time for fade-out.
Low-Pass / High-Pass
ffmpeg -i input.wav -af "lowpass=f=3000" output.wav
ffmpeg -i input.wav -af "highpass=f=200" output.wavFiltergraph Tips
- Use
-filter_complexwhen: - Multiple inputs (e.g., overlay, picture-in-picture)
- Shared intermediate results
- Quote complex graphs to avoid shell interpretation issues.
- Start simple and build up; many FFmpeg errors are filtergraph typos.
FFmpeg for Streaming & HTTP Playback
Patterns for creating streaming-friendly outputs (HLS, progressive MP4).
Progressive MP4 for Web
Use -movflags +faststart so players can start quickly:
ffmpeg -i input.mp4 \
-c:v libx264 -preset medium -crf 23 \
-c:a aac -b:a 128k \
-movflags +faststart \
output-web.mp4Serve over HTTPS with correct Content-Type: video/mp4.
HTTP Live Streaming (HLS)
Basic HLS
ffmpeg -i input.mp4 \
-codec:V libx264 -codec:a aac \
-start_number 0 \
-hls_time 6 \
-hls_list_size 0 \
-f hls playlist.m3u8-hls_time– segment duration (seconds)-hls_list_size 0– keep all segments listed (VOD).
Multi-Bitrate HLS (Ladder)
ffmpeg -i input.mp4 \
-filter:v:0 scale=w=1920:h=1080:force_original_aspect_ratio=decrease -c:v:0 libx264 -b:v:0 5000k \
-filter:v:1 scale=w=1280:h=720:force_original_aspect_ratio=decrease -c:v:1 libx264 -b:v:1 3000k \
-filter:v:2 scale=w=854:h=480:force_original_aspect_ratio=decrease -c:v:2 libx264 -b:v:2 1500k \
-map v:0 -map a:0 \
-map v:1 -map a:0 \
-map v:2 -map a:0 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
-master_pl_name master.m3u8 \
-f hls -hls_time 6 -hls_list_size 0 \
-hls_segment_filename "v%v/seg_%03d.ts" "v%v/playlist.m3u8"This is verbose—start from FFmpeg docs or simpler examples and adapt.
DASH (High-Level)
For MPEG-DASH, use -f dash, but HLS is usually enough unless you have specific platform needs.
ffmpeg -i input.mp4 \
-map 0:v -map 0:a \
-c:v libx264 -c:a aac \
-f dash manifest.mpdLive Ingest (RTMP Example)
ffmpeg -re -i input.mp4 \
-c:v libx264 -preset veryfast -maxrate 3000k -bufsize 6000k \
-c:a aac -b:a 128k \
-f flv rtmp://live.example.com/app/stream-key-re– read input at native rate (emulates live).
Practical Notes
- Use constant frame rate outputs for better player compatibility.
- Keep audio in common formats (AAC, Opus).
- Test with
ffplay, browser video elements, and your target players/CDN.
Media Format Compatibility Notes
High-level guidance on choosing containers/codecs for web, mobile, and desktop playback.
Video Containers & Codecs
- MP4 (H.264 + AAC)
- Safest choice for web and mobile
- Plays in essentially all modern browsers and devices
- Good default:
libx264+aac
- WebM (VP9/VP8 + Opus/Vorbis)
- Great for modern browsers (Chrome/Firefox/Edge)
- Not universally supported in some legacy environments
- Good secondary format when pushing for smaller sizes with VP9.
- MKV
- Flexible, good for archival and tooling
- Not ideal as a primary web delivery container.
Recommended Defaults
- General web playback
- Container:
mp4 - Video:
libx264, CRF 20–24, presetmedium - Audio:
aac128–192 kbps
- Modern browsers / higher efficiency
- Container:
webm - Video:
libvpx-vp9, CRF 28–32 - Audio:
libopus96–160 kbps
- Archival / Source Masters
- Keep original camera/source format where practical
- Or use high-bitrate
libx264/libx265with lower CRF (e.g., 16–18).
Image Formats
- JPEG (`.jpg`, `.jpeg`)
- Photographic images, good compression
- No transparency support.
- PNG
- Lossless, supports transparency
- Larger files; good for graphics, UI, logos.
- WebP
- Modern alternative, supports lossy and lossless + transparency
- Good for web if client support is acceptable.
- TIFF
- Often used in print/pro workflows; large but flexible.
Audio Formats
- MP3
- Ubiquitous, lossy
- Good general-purpose delivery format.
- AAC / M4A
- Better efficiency than MP3 at lower bitrates
- Very common in video containers.
- Opus
- Excellent efficiency, ideal for streaming/VoIP
- Best used in WebM/OGG containers.
- FLAC / WAV
- Lossless; FLAC is compressed, WAV is uncompressed PCM.
Choosing Formats
- If you need maximum compatibility, prefer:
- Video: H.264 in MP4
- Audio: AAC or MP3
- Images: JPEG/PNG
- If you target modern web only, consider:
- Video: H.264 MP4 + VP9 WebM variants
- Images: WebP where supported, with PNG/JPEG fallbacks if needed.
ImageMagick Batch Processing Patterns
ImageMagick (magick CLI) is ideal for scripted, repeatable image workflows.
Basic Structure
magick input.png -resize 800x600 output.pngIn batch scenarios, you typically:
- Use shell loops (bash, PowerShell) or
- Use higher-level scripts (like
batch_resize.py) to orchestrate many calls.
Common Batch Operations
Resize All Images in a Folder
mkdir -p out
for f in *.jpg; do
magick "$f" -resize 1280x720 "out/$f"
doneConvert Format (JPEG → WebP)
mkdir -p webp
for f in *.jpg; do
magick "$f" -quality 85 "webp/${f%.*}.webp"
doneThumbnails (Square, Center-Cropped)
mkdir -p thumbs
for f in *.jpg; do
magick "$f" \
-resize 256x256^ \
-gravity center -extent 256x256 \
"thumbs/$f"
doneUsing batch_resize.py
The batch_resize.py script wraps patterns like these in a cross-platform Python CLI:
uv run skills/media-processing/scripts/batch_resize.py \
images/ \
--output out/ \
--width 1280 \
--strategy fit \
--format webp \
--parallel 4Key options:
--strategy:fit,fill,cover,exact,thumbnail--format: target format (e.g.,jpg,webp)--watermark: overlay image (e.g., logo)--parallel: parallelism for large batches
Start with --dry-run to inspect commands:
uv run skills/media-processing/scripts/batch_resize.py images/ -o out --width 800 --dry-runImageMagick Editing Cheatsheet
Quick reference for common single-image operations using magick.
Basic Resize
magick input.jpg -resize 1280x720 output.jpg1280x720– max width/height, preserves aspect ratio- Use
1280x720!to force exact size (may distort).
Crop
# Width x Height +X +Y
magick input.jpg -crop 800x600+10+10 cropped.jpgCenter crop to 1:1:
magick input.jpg -resize 800x800^ -gravity center -extent 800x800 square.jpgRotate & Flip
magick input.jpg -rotate 90 rotated.jpg
magick input.jpg -flip flipped-vertically.jpg
magick input.jpg -flop flipped-horizontally.jpgAdjust Brightness / Contrast
magick input.jpg -brightness-contrast 10x5 adjusted.jpgOverlay / Watermark
magick input.jpg watermark.png \
-gravity southeast -geometry +10+10 -composite \
watermarked.jpgAdd Text
magick input.jpg \
-gravity south \
-pointsize 32 -fill white -stroke black -strokewidth 2 \
-annotate +0+20 "Sample Caption" \
captioned.jpgOptimize for Web
magick input.png \
-strip \
-resize 1280x1280\> \
-quality 85 \
output.jpg-stripremoves metadata\>only resizes if image is larger than target.
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
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()
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
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'
if ext in AUDIO_FORMATS:
return 'audio'
if ext in IMAGE_FORMATS:
return 'image'
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:
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 exc:
print(f"Error converting {input_path}: {exc}", file=sys.stderr)
return False
except Exception as exc: # pylint: disable=broad-except
print(f"Error converting {input_path}: {exc}", 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() -> None:
"""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)
# Batch conversion
output_dir = args.output if args.output else None
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()
# 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
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
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 exc: # pylint: disable=broad-except
print(f'Error getting video info: {exc}', 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 % 2
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('\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 exc:
print(f'Error in pass 1: {exc}', 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('\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 exc:
print(f'Error optimizing video: {exc}', file=sys.stderr)
return False
except Exception as exc: # pylint: disable=broad-except
print(f'Error optimizing video: {exc}', 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} '
f'{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} '
f'{-size_reduction:.1f}%'
)
def main() -> None:
"""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()