Files
video-render/video_render/pipeline.py
2025-10-29 23:58:06 -03:00

258 lines
9.1 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from video_render.config import Settings
from video_render.llm import GeminiHighlighter, OpenRouterCopywriter
from video_render.media import MediaPreparer, VideoWorkspace
from video_render.transcription import TranscriptionResult, TranscriptionService
from video_render.utils import remove_paths, sanitize_filename
from video_render.rendering import VideoRenderer
logger = logging.getLogger(__name__)
@dataclass
class JobMessage:
filename: str
url: Optional[str]
video_id: Optional[str]
extras: Dict[str, Any] = field(default_factory=dict)
@dataclass
class HighlightWindow:
start: float
end: float
summary: str
title: Optional[str] = None
@dataclass
class RenderedClip:
path: Path
start: float
end: float
title: str
summary: str
index: int
@dataclass
class PipelineContext:
job: JobMessage
workspace: Optional[VideoWorkspace] = None
transcription: Optional[TranscriptionResult] = None
highlight_windows: List[HighlightWindow] = field(default_factory=list)
rendered_clips: List[RenderedClip] = field(default_factory=list)
class VideoPipeline:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.media_preparer = MediaPreparer(settings)
self.transcriber = TranscriptionService(settings)
self.highlighter = GeminiHighlighter(settings)
self.copywriter = OpenRouterCopywriter(settings)
self.renderer = VideoRenderer(settings)
def process_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
context = PipelineContext(job=self._parse_job(message))
try:
self._prepare_workspace(context)
self._generate_transcription(context)
self._determine_highlights(context)
self._generate_titles(context)
self._render_clips(context)
return self._build_success_payload(context)
except Exception as exc:
logger.exception("Falha ao processar vídeo %s", context.job.filename)
# return self._handle_failure(context, exc)
def _parse_job(self, message: Dict[str, Any]) -> JobMessage:
filename = message.get("filename")
if not filename:
raise ValueError("Mensagem inválida: 'filename' é obrigatório")
url = message.get("url")
video_id = message.get("videoId") or message.get("video_id")
extras = {
key: value
for key, value in message.items()
if key not in {"filename", "url", "videoId", "video_id"}
}
return JobMessage(filename=filename, url=url, video_id=video_id, extras=extras)
def _prepare_workspace(self, context: PipelineContext) -> None:
context.workspace = self.media_preparer.prepare(context.job.filename)
def _generate_transcription(self, context: PipelineContext) -> None:
if not context.workspace:
raise RuntimeError("Workspace não preparado")
existing = TranscriptionService.load(context.workspace.workspace_dir)
if existing:
logger.info(
"Transcricao existente encontrada em %s; reutilizando resultado",
context.workspace.workspace_dir,
)
context.transcription = existing
return
transcription = self.transcriber.transcribe(context.workspace.audio_path)
TranscriptionService.persist(transcription, context.workspace.workspace_dir)
context.transcription = transcription
def _determine_highlights(self, context: PipelineContext) -> None:
if not context.transcription:
raise RuntimeError("Transcricao nao disponivel")
try:
highlights_raw = self.highlighter.generate_highlights(context.transcription)
except Exception:
logger.exception(
"Falha ao gerar destaques com Gemini; aplicando fallback padrao."
)
context.highlight_windows = [self._build_fallback_highlight(context)]
return
windows: List[HighlightWindow] = []
for item in highlights_raw:
try:
start = float(item.get("start", 0)) # type: ignore[arg-type]
end = float(item.get("end", start)) # type: ignore[arg-type]
except (TypeError, ValueError):
logger.warning("Highlight invalido ignorado: %s", item)
continue
summary = str(item.get("summary", "")).strip()
if end <= start:
logger.debug("Highlight com intervalo invalido ignorado: %s", item)
continue
windows.append(HighlightWindow(start=start, end=end, summary=summary))
if not windows:
windows.append(self._build_fallback_highlight(context))
context.highlight_windows = windows
def _generate_titles(self, context: PipelineContext) -> None:
if not context.highlight_windows:
return
highlight_dicts = [
{"start": window.start, "end": window.end, "summary": window.summary}
for window in context.highlight_windows
]
titles = self.copywriter.generate_titles(highlight_dicts)
for window, title in zip(context.highlight_windows, titles):
window.title = title.strip()
def _build_fallback_highlight(self, context: PipelineContext) -> HighlightWindow:
if not context.transcription:
raise RuntimeError("Transcricao nao disponivel para criar fallback")
last_end = (
context.transcription.segments[-1].end
if context.transcription.segments
else 0.0
)
return HighlightWindow(
start=0.0,
end=max(last_end, 10.0),
summary="Sem destaque identificado; fallback automatico.",
)
def _render_clips(self, context: PipelineContext) -> None:
if not context.workspace or not context.highlight_windows or not context.transcription:
return
titles = [
window.title or window.summary for window in context.highlight_windows
]
render_results = self.renderer.render(
workspace_path=str(context.workspace.working_video_path),
highlight_windows=context.highlight_windows,
transcription=context.transcription,
titles=titles,
output_dir=context.workspace.output_dir,
)
context.rendered_clips = [
RenderedClip(
path=Path(path),
start=start,
end=end,
title=title,
summary=summary,
index=index,
)
for path, start, end, title, summary, index in render_results
]
def _build_success_payload(self, context: PipelineContext) -> Dict[str, Any]:
return {
"hasError": False,
"videosProcessedQuantity": len(context.rendered_clips),
"filename": context.job.filename,
"videoId": context.job.video_id,
"url": context.job.url,
"workspaceFolder": context.workspace.sanitized_name if context.workspace else None,
"outputDirectory": self._relative_path(context.workspace.output_dir) if context.workspace else None,
"processedFiles": [
{
"path": self._relative_path(clip.path),
"start": clip.start,
"end": clip.end,
"title": clip.title,
"summary": clip.summary,
"clipIndex": clip.index,
}
for clip in context.rendered_clips
],
}
def _handle_failure(self, context: PipelineContext, exc: Exception) -> Dict[str, Any]:
logger.error("Erro na pipeline: %s", exc)
cleanup_targets: List[Path] = []
if context.workspace:
cleanup_targets.append(context.workspace.workspace_dir)
cleanup_targets.append(context.workspace.output_dir)
original_path = context.workspace.source_path
if original_path.exists():
cleanup_targets.append(original_path)
else:
sanitized = sanitize_filename(Path(context.job.filename).stem)
job_output_dir = self.settings.outputs_dir / sanitized
if job_output_dir.exists():
cleanup_targets.append(job_output_dir)
original_path = self.settings.videos_dir / context.job.filename
if original_path.exists():
cleanup_targets.append(original_path)
remove_paths(cleanup_targets)
return {
"hasError": True,
"error": str(exc),
"filename": context.job.filename,
"videoId": context.job.video_id,
"url": context.job.url,
"processedFiles": [],
}
def _relative_path(self, path: Path) -> str:
base = self.settings.videos_dir.parent
try:
return str(path.relative_to(base))
except ValueError:
return str(path)