Files
mediaproc/core/ffmpeg/transcode.py
2026-02-03 12:20:40 -03:00

226 lines
6.3 KiB
Python

"""
FFmpeg transcode module - Transcode media files using ffmpeg-python.
"""
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import ffmpeg
@dataclass
class TranscodeConfig:
"""Configuration for a transcode operation."""
input_path: str
output_path: str
# Video
video_codec: str = "libx264"
video_bitrate: Optional[str] = None
video_crf: Optional[int] = None
video_preset: Optional[str] = None
resolution: Optional[str] = None
framerate: Optional[float] = None
# Audio
audio_codec: str = "aac"
audio_bitrate: Optional[str] = None
audio_channels: Optional[int] = None
audio_samplerate: Optional[int] = None
# Trimming
trim_start: Optional[float] = None
trim_end: Optional[float] = None
# Container
container: str = "mp4"
# Extra args (key-value pairs)
extra_args: List[str] = field(default_factory=list)
@property
def is_copy(self) -> bool:
"""Check if this is a stream copy (no transcoding)."""
return self.video_codec == "copy" and self.audio_codec == "copy"
def build_stream(config: TranscodeConfig):
"""
Build an ffmpeg-python stream from config.
Returns the stream object ready to run.
"""
# Input options
input_kwargs = {}
if config.trim_start is not None:
input_kwargs["ss"] = config.trim_start
stream = ffmpeg.input(config.input_path, **input_kwargs)
# Output options
output_kwargs = {
"vcodec": config.video_codec,
"acodec": config.audio_codec,
}
# Trimming duration
if config.trim_end is not None:
if config.trim_start is not None:
output_kwargs["t"] = config.trim_end - config.trim_start
else:
output_kwargs["t"] = config.trim_end
# Video options (skip if copy)
if config.video_codec != "copy":
if config.video_crf is not None:
output_kwargs["crf"] = config.video_crf
elif config.video_bitrate:
output_kwargs["video_bitrate"] = config.video_bitrate
if config.video_preset:
output_kwargs["preset"] = config.video_preset
if config.resolution:
output_kwargs["s"] = config.resolution
if config.framerate:
output_kwargs["r"] = config.framerate
# Audio options (skip if copy)
if config.audio_codec != "copy":
if config.audio_bitrate:
output_kwargs["audio_bitrate"] = config.audio_bitrate
if config.audio_channels:
output_kwargs["ac"] = config.audio_channels
if config.audio_samplerate:
output_kwargs["ar"] = config.audio_samplerate
# Parse extra args into kwargs
extra_kwargs = parse_extra_args(config.extra_args)
output_kwargs.update(extra_kwargs)
stream = ffmpeg.output(stream, config.output_path, **output_kwargs)
stream = ffmpeg.overwrite_output(stream)
return stream
def parse_extra_args(extra_args: List[str]) -> Dict[str, Any]:
"""
Parse extra args list into kwargs dict.
["-vtag", "xvid", "-pix_fmt", "yuv420p"] -> {"vtag": "xvid", "pix_fmt": "yuv420p"}
"""
kwargs = {}
i = 0
while i < len(extra_args):
key = extra_args[i].lstrip("-")
if i + 1 < len(extra_args) and not extra_args[i + 1].startswith("-"):
kwargs[key] = extra_args[i + 1]
i += 2
else:
# Flag without value
kwargs[key] = None
i += 1
return kwargs
def transcode(
config: TranscodeConfig,
duration: Optional[float] = None,
progress_callback: Optional[Callable[[float, Dict[str, Any]], None]] = None,
) -> bool:
"""
Transcode a media file.
Args:
config: Transcode configuration
duration: Total duration in seconds (for progress calculation, optional)
progress_callback: Called with (percent, details_dict) - requires duration
Returns:
True if successful
Raises:
ffmpeg.Error: If transcoding fails
"""
# Ensure output directory exists
Path(config.output_path).parent.mkdir(parents=True, exist_ok=True)
stream = build_stream(config)
if progress_callback and duration:
# Run with progress tracking using run_async
return _run_with_progress(stream, config, duration, progress_callback)
else:
# Run synchronously
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return True
def _run_with_progress(
stream,
config: TranscodeConfig,
duration: float,
progress_callback: Callable[[float, Dict[str, Any]], None],
) -> bool:
"""Run FFmpeg with progress tracking using run_async and stderr parsing."""
import re
# Calculate effective duration
effective_duration = duration
if config.trim_start and config.trim_end:
effective_duration = config.trim_end - config.trim_start
elif config.trim_end:
effective_duration = config.trim_end
elif config.trim_start:
effective_duration = duration - config.trim_start
# Run async to get process handle
process = ffmpeg.run_async(stream, pipe_stdout=True, pipe_stderr=True)
# Parse stderr for progress (time=HH:MM:SS.ms pattern)
time_pattern = re.compile(r"time=(\d+):(\d+):(\d+)\.(\d+)")
while True:
line = process.stderr.readline()
if not line:
break
line = line.decode("utf-8", errors="ignore")
match = time_pattern.search(line)
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
seconds = int(match.group(3))
ms = int(match.group(4))
current_time = hours * 3600 + minutes * 60 + seconds + ms / 100
percent = min(100.0, (current_time / effective_duration) * 100)
progress_callback(
percent,
{
"time": current_time,
"percent": percent,
},
)
# Wait for completion
process.wait()
if process.returncode != 0:
raise ffmpeg.Error(
"ffmpeg", stdout=process.stdout.read(), stderr=process.stderr.read()
)
# Final callback
progress_callback(
100.0, {"time": effective_duration, "percent": 100.0, "done": True}
)
return True