118 lines
2.8 KiB
Python
118 lines
2.8 KiB
Python
"""Clip extraction service using FFmpeg."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def extract_clip(
|
|
video_path: str,
|
|
start_time: float,
|
|
end_time: float,
|
|
output_path: str,
|
|
) -> str:
|
|
"""Extract a clip from video using FFmpeg stream copy (instant, no re-encode).
|
|
|
|
Args:
|
|
video_path: path to source video
|
|
start_time: clip start in seconds
|
|
end_time: clip end in seconds
|
|
output_path: where to write the clip
|
|
|
|
Returns:
|
|
output_path
|
|
"""
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
|
duration = end_time - start_time
|
|
|
|
# Use stream copy for speed - seek before input for accuracy
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-ss", str(start_time),
|
|
"-i", video_path,
|
|
"-t", str(duration),
|
|
"-c", "copy",
|
|
"-avoid_negative_ts", "make_zero",
|
|
"-y",
|
|
output_path,
|
|
]
|
|
|
|
logger.info(
|
|
f"Extracting clip: {start_time:.1f}s - {end_time:.1f}s -> {output_path}"
|
|
)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"FFmpeg clip extraction failed: {stderr.decode()}")
|
|
|
|
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
|
logger.info(f"Extracted clip: {output_path} ({size_mb:.1f} MB)")
|
|
return output_path
|
|
|
|
|
|
async def extract_thumbnail(
|
|
video_path: str,
|
|
timestamp: float,
|
|
output_path: str,
|
|
) -> str:
|
|
"""Extract a single frame as thumbnail."""
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-ss", str(timestamp),
|
|
"-i", video_path,
|
|
"-vframes", "1",
|
|
"-q:v", "2",
|
|
"-y",
|
|
output_path,
|
|
]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"FFmpeg thumbnail extraction failed: {stderr.decode()}")
|
|
|
|
return output_path
|
|
|
|
|
|
async def get_video_duration(video_path: str) -> float:
|
|
"""Get video duration in seconds using ffprobe."""
|
|
cmd = [
|
|
"ffprobe",
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
video_path,
|
|
]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
return 0.0
|
|
|
|
import json
|
|
data = json.loads(stdout.decode())
|
|
return float(data.get("format", {}).get("duration", 0))
|