- "backend/pipeline/shorts_generator.py" - "backend/pipeline/stages.py" GSD-Task: S03/T02
132 lines
3.7 KiB
Python
132 lines
3.7 KiB
Python
"""FFmpeg clip extraction with format presets for shorts generation.
|
|
|
|
Pure functions — no DB access, no Celery dependency. Tested independently.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from models import FormatPreset
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FFMPEG_TIMEOUT_SECS = 300
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PresetSpec:
|
|
"""Resolution and ffmpeg video filter for a format preset."""
|
|
width: int
|
|
height: int
|
|
vf_filter: str
|
|
|
|
|
|
PRESETS: dict[FormatPreset, PresetSpec] = {
|
|
FormatPreset.vertical: PresetSpec(
|
|
width=1080,
|
|
height=1920,
|
|
vf_filter="scale=1080:-2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
|
|
),
|
|
FormatPreset.square: PresetSpec(
|
|
width=1080,
|
|
height=1080,
|
|
vf_filter="crop=min(iw\\,ih):min(iw\\,ih),scale=1080:1080",
|
|
),
|
|
FormatPreset.horizontal: PresetSpec(
|
|
width=1920,
|
|
height=1080,
|
|
vf_filter="scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black",
|
|
),
|
|
}
|
|
|
|
|
|
def resolve_video_path(video_source_root: str, file_path: str) -> Path:
|
|
"""Join root + relative path and validate the file exists.
|
|
|
|
Args:
|
|
video_source_root: Base directory for video files (e.g. /videos).
|
|
file_path: Relative path stored in SourceVideo.file_path.
|
|
|
|
Returns:
|
|
Resolved absolute Path.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the resolved path doesn't exist or isn't a file.
|
|
"""
|
|
resolved = Path(video_source_root) / file_path
|
|
if not resolved.is_file():
|
|
raise FileNotFoundError(
|
|
f"Video file not found: {resolved} "
|
|
f"(root={video_source_root!r}, relative={file_path!r})"
|
|
)
|
|
return resolved
|
|
|
|
|
|
def extract_clip(
|
|
input_path: Path | str,
|
|
output_path: Path | str,
|
|
start_secs: float,
|
|
end_secs: float,
|
|
vf_filter: str,
|
|
) -> None:
|
|
"""Extract a clip from a video file using ffmpeg.
|
|
|
|
Seeks to *start_secs*, encodes until *end_secs*, and applies *vf_filter*.
|
|
Uses ``-c:v libx264 -preset fast -crf 23`` for reasonable quality/speed.
|
|
|
|
Args:
|
|
input_path: Source video file.
|
|
output_path: Destination mp4 file (parent dir must exist).
|
|
start_secs: Start time in seconds.
|
|
end_secs: End time in seconds.
|
|
vf_filter: ffmpeg ``-vf`` filter string.
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If ffmpeg exits non-zero.
|
|
subprocess.TimeoutExpired: If ffmpeg exceeds the timeout.
|
|
ValueError: If start >= end.
|
|
"""
|
|
duration = end_secs - start_secs
|
|
if duration <= 0:
|
|
raise ValueError(
|
|
f"Invalid clip range: start={start_secs}s end={end_secs}s "
|
|
f"(duration={duration}s)"
|
|
)
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y", # overwrite output
|
|
"-ss", str(start_secs), # seek before input (fast)
|
|
"-i", str(input_path),
|
|
"-t", str(duration),
|
|
"-vf", vf_filter,
|
|
"-c:v", "libx264",
|
|
"-preset", "fast",
|
|
"-crf", "23",
|
|
"-c:a", "aac",
|
|
"-b:a", "128k",
|
|
"-movflags", "+faststart", # web-friendly mp4
|
|
str(output_path),
|
|
]
|
|
|
|
logger.info(
|
|
"ffmpeg: extracting %.1fs clip from %s → %s",
|
|
duration, input_path, output_path,
|
|
)
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
timeout=FFMPEG_TIMEOUT_SECS,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
stderr_text = result.stderr.decode("utf-8", errors="replace")[-2000:]
|
|
logger.error("ffmpeg failed (rc=%d): %s", result.returncode, stderr_text)
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode, cmd, output=result.stdout, stderr=result.stderr,
|
|
)
|