forked from Rockachopa/Timmy-time-dashboard
166 lines
4.8 KiB
Python
166 lines
4.8 KiB
Python
"""FFmpeg-based frame-accurate clip extraction from recorded stream segments.
|
|
|
|
Each highlight dict must have:
|
|
source_path : str — path to the source video file
|
|
start_time : float — clip start in seconds
|
|
end_time : float — clip end in seconds
|
|
highlight_id: str — unique identifier (used for output filename)
|
|
|
|
Clips are written to ``settings.content_clips_dir``.
|
|
FFmpeg is treated as an optional runtime dependency — if the binary is not
|
|
found, :func:`extract_clip` returns a failure result instead of crashing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ClipResult:
|
|
"""Result of a single clip extraction operation."""
|
|
|
|
highlight_id: str
|
|
success: bool
|
|
output_path: str | None = None
|
|
error: str | None = None
|
|
duration: float = 0.0
|
|
|
|
|
|
def _ffmpeg_available() -> bool:
|
|
"""Return True if the ffmpeg binary is on PATH."""
|
|
return shutil.which("ffmpeg") is not None
|
|
|
|
|
|
def _build_ffmpeg_cmd(
|
|
source: str,
|
|
start: float,
|
|
end: float,
|
|
output: str,
|
|
) -> list[str]:
|
|
"""Build an ffmpeg command for frame-accurate clip extraction.
|
|
|
|
Uses ``-ss`` before ``-i`` for fast seek, then re-seeks with ``-ss``
|
|
after ``-i`` for frame accuracy. ``-avoid_negative_ts make_zero``
|
|
ensures timestamps begin at 0 in the output.
|
|
"""
|
|
duration = end - start
|
|
return [
|
|
"ffmpeg",
|
|
"-y", # overwrite output
|
|
"-ss", str(start),
|
|
"-i", source,
|
|
"-t", str(duration),
|
|
"-avoid_negative_ts", "make_zero",
|
|
"-c:v", settings.default_video_codec,
|
|
"-c:a", "aac",
|
|
"-movflags", "+faststart",
|
|
output,
|
|
]
|
|
|
|
|
|
async def extract_clip(
|
|
highlight: dict,
|
|
output_dir: str | None = None,
|
|
) -> ClipResult:
|
|
"""Extract a single clip from a source video using FFmpeg.
|
|
|
|
Parameters
|
|
----------
|
|
highlight:
|
|
Dict with keys ``source_path``, ``start_time``, ``end_time``,
|
|
and ``highlight_id``.
|
|
output_dir:
|
|
Directory to write the clip. Defaults to
|
|
``settings.content_clips_dir``.
|
|
|
|
Returns
|
|
-------
|
|
ClipResult
|
|
Always returns a result; never raises.
|
|
"""
|
|
hid = highlight.get("highlight_id", "unknown")
|
|
|
|
if not _ffmpeg_available():
|
|
logger.warning("ffmpeg not found — clip extraction disabled")
|
|
return ClipResult(highlight_id=hid, success=False, error="ffmpeg not found")
|
|
|
|
source = highlight.get("source_path", "")
|
|
if not source or not Path(source).exists():
|
|
return ClipResult(
|
|
highlight_id=hid,
|
|
success=False,
|
|
error=f"source_path not found: {source!r}",
|
|
)
|
|
|
|
start = float(highlight.get("start_time", 0))
|
|
end = float(highlight.get("end_time", 0))
|
|
if end <= start:
|
|
return ClipResult(
|
|
highlight_id=hid,
|
|
success=False,
|
|
error=f"invalid time range: start={start} end={end}",
|
|
)
|
|
|
|
dest_dir = Path(output_dir or settings.content_clips_dir)
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = dest_dir / f"{hid}.mp4"
|
|
|
|
cmd = _build_ffmpeg_cmd(source, start, end, str(output_path))
|
|
logger.debug("Running: %s", " ".join(cmd))
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
|
if proc.returncode != 0:
|
|
err = stderr.decode(errors="replace")[-500:]
|
|
logger.warning("ffmpeg failed for %s: %s", hid, err)
|
|
return ClipResult(highlight_id=hid, success=False, error=err)
|
|
|
|
duration = end - start
|
|
return ClipResult(
|
|
highlight_id=hid,
|
|
success=True,
|
|
output_path=str(output_path),
|
|
duration=duration,
|
|
)
|
|
except TimeoutError:
|
|
return ClipResult(highlight_id=hid, success=False, error="ffmpeg timed out")
|
|
except Exception as exc:
|
|
logger.warning("Clip extraction error for %s: %s", hid, exc)
|
|
return ClipResult(highlight_id=hid, success=False, error=str(exc))
|
|
|
|
|
|
async def extract_clips(
|
|
highlights: list[dict],
|
|
output_dir: str | None = None,
|
|
) -> list[ClipResult]:
|
|
"""Extract multiple clips concurrently.
|
|
|
|
Parameters
|
|
----------
|
|
highlights:
|
|
List of highlight dicts (see :func:`extract_clip`).
|
|
output_dir:
|
|
Shared output directory for all clips.
|
|
|
|
Returns
|
|
-------
|
|
list[ClipResult]
|
|
One result per highlight in the same order.
|
|
"""
|
|
tasks = [extract_clip(h, output_dir) for h in highlights]
|
|
return list(await asyncio.gather(*tasks))
|