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)