e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""Frame extraction utilities for visual quality review."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
from pathlib import Path
|
|
from typing import Awaitable, Callable
|
|
|
|
from deeptutor.core.context import Attachment
|
|
from deeptutor.services.path_service import get_path_service
|
|
|
|
from .models import RenderResult
|
|
from .renderer import ManimRenderError
|
|
|
|
|
|
class VisualReviewService:
|
|
def __init__(
|
|
self,
|
|
turn_id: str,
|
|
progress_callback: Callable[[str, bool], Awaitable[None]] | None = None,
|
|
) -> None:
|
|
self.turn_id = turn_id
|
|
self.progress_callback = progress_callback
|
|
path_service = get_path_service()
|
|
self.base_dir = path_service.get_agent_dir("math_animator") / turn_id
|
|
self.artifacts_dir = self.base_dir / "artifacts"
|
|
self.review_dir = self.base_dir / "review"
|
|
self.review_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
async def build_attachments(self, render_result: RenderResult) -> list[Attachment]:
|
|
if render_result.output_mode == "image":
|
|
await self._emit_progress(
|
|
f"Preparing {len(render_result.artifacts)} rendered image(s) for visual review."
|
|
)
|
|
return [
|
|
self._path_to_attachment(self.artifacts_dir / artifact.filename)
|
|
for artifact in render_result.artifacts
|
|
]
|
|
|
|
video_artifact = next(
|
|
(artifact for artifact in render_result.artifacts if artifact.type == "video"), None
|
|
)
|
|
if video_artifact is None:
|
|
return []
|
|
|
|
video_path = self.artifacts_dir / video_artifact.filename
|
|
frame_paths = await self._extract_video_frames(video_path)
|
|
await self._emit_progress(
|
|
f"Prepared {len(frame_paths)} review frame(s) from rendered video."
|
|
)
|
|
return [self._path_to_attachment(path) for path in frame_paths]
|
|
|
|
async def _extract_video_frames(self, video_path: Path) -> list[Path]:
|
|
if not video_path.exists():
|
|
raise ManimRenderError("Rendered video artifact missing for visual review.")
|
|
|
|
duration = await self._probe_duration(video_path)
|
|
timestamps = self._choose_timestamps(duration)
|
|
frame_paths: list[Path] = []
|
|
for idx, timestamp in enumerate(timestamps, start=1):
|
|
frame_path = self.review_dir / f"review_frame_{idx:02d}.png"
|
|
command = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-ss",
|
|
f"{timestamp:.3f}",
|
|
"-i",
|
|
str(video_path),
|
|
"-frames:v",
|
|
"1",
|
|
str(frame_path),
|
|
]
|
|
await self._emit_progress(
|
|
f"Extracting review frame {idx}/{len(timestamps)} at {timestamp:.1f}s.",
|
|
raw=True,
|
|
)
|
|
process = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_stdout, stderr = await process.communicate()
|
|
if process.returncode != 0 or not frame_path.exists():
|
|
raise ManimRenderError(
|
|
"Failed to extract review frames: " + stderr.decode(errors="ignore").strip()
|
|
)
|
|
frame_paths.append(frame_path)
|
|
return frame_paths
|
|
|
|
async def _probe_duration(self, video_path: Path) -> float:
|
|
command = [
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
str(video_path),
|
|
]
|
|
process = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
if process.returncode != 0:
|
|
raise ManimRenderError(
|
|
"Failed to inspect rendered video duration: "
|
|
+ stderr.decode(errors="ignore").strip()
|
|
)
|
|
try:
|
|
return max(float(stdout.decode().strip() or "0"), 0.0)
|
|
except ValueError as exc:
|
|
raise ManimRenderError(
|
|
"Rendered video duration could not be parsed for visual review."
|
|
) from exc
|
|
|
|
@staticmethod
|
|
def _choose_timestamps(duration: float) -> list[float]:
|
|
if duration <= 0:
|
|
return [0.0]
|
|
if duration < 2:
|
|
return [0.0, min(duration / 2, duration), max(duration - 0.05, 0.0)]
|
|
return [
|
|
max(duration * 0.15, 0.0),
|
|
min(duration * 0.5, duration),
|
|
max(duration * 0.85, 0.0),
|
|
]
|
|
|
|
@staticmethod
|
|
def _path_to_attachment(path: Path) -> Attachment:
|
|
suffix = path.suffix.lower()
|
|
mime_type = "image/jpeg" if suffix in {".jpg", ".jpeg"} else "image/png"
|
|
return Attachment(
|
|
type="image",
|
|
filename=path.name,
|
|
mime_type=mime_type,
|
|
base64=base64.b64encode(path.read_bytes()).decode("utf-8"),
|
|
)
|
|
|
|
async def _emit_progress(self, message: str, raw: bool = False) -> None:
|
|
if self.progress_callback is None:
|
|
return
|
|
await self.progress_callback(message, raw)
|
|
|
|
|
|
__all__ = ["VisualReviewService"]
|