from pathlib import Path from typing import Dict, Iterable, List, Optional from backend.types import RenderArtifact, RenderJob, VoiceConfig class RenderPipeline: def __init__(self, session_root: Path, synthesizer=None) -> None: self.session_root = Path(session_root) self.session_root.mkdir(parents=True, exist_ok=True) self.synthesizer = synthesizer self._jobs: Dict[str, RenderJob] = {} self._cancelled: set[str] = set() self._paused: set[str] = set() def get_job(self, session_id: str) -> RenderJob: return self._jobs.setdefault(session_id, RenderJob(session_id=session_id)) def cancel(self, session_id: str) -> None: self._cancelled.add(session_id) self.get_job(session_id).status = "cancelled" def pause(self, session_id: str) -> Dict[str, object]: self._paused.add(session_id) self.get_job(session_id).status = "paused" return {"type": "paused", "session_id": session_id} def resume(self, session_id: str) -> Dict[str, object]: self._paused.discard(session_id) self.get_job(session_id).status = "running" return {"type": "resumed", "session_id": session_id} def render( self, *, session_id: str, book: Dict[str, object], chapters: List[Dict[str, object]], voice_config: Dict[str, object], diffusion_steps: int, speed: float, synthesizer=None, ) -> Iterable[Dict[str, object]]: selected = [chapter for chapter in chapters if chapter.get("included", True)] job = self.get_job(session_id) job.status = "running" job.outputs.clear() synth = synthesizer or self.synthesizer if synth is None: raise ValueError("No synthesizer provided for render pipeline") yield { "type": "started", "session_id": session_id, "total_chapters": len(selected), "book_title": book.get("title"), "backend": "local", "model": VoiceConfig.from_dict(voice_config).model, } voice = VoiceConfig.from_dict(voice_config) render_dir = self.session_root / session_id / "renders" render_dir.mkdir(parents=True, exist_ok=True) for index, chapter in enumerate(selected): if session_id in self._cancelled: yield {"type": "cancelled", "session_id": session_id, "backend": "local", "model": voice.model} self._cancelled.discard(session_id) job.status = "cancelled" return chapter_id = str(chapter["id"]) job.current_chapter_id = chapter_id yield { "type": "chapter_started", "session_id": session_id, "chapter_id": chapter_id, "chapter_title": chapter["title"], "chapter_index": index, "overall_progress": index / max(1, len(selected)), "backend": "local", "model": voice.model, } output_path = render_dir / f"{index + 1:03d}-{chapter_id}.wav" result = synth.synthesize( text=str(chapter["text"]), output_path=output_path, voice_config=voice, diffusion_steps=diffusion_steps, speed=speed, ) yield { "type": "chapter_progress", "session_id": session_id, "chapter_id": chapter_id, "percent": 100, "backend": result.get("backend", "local"), "model": result.get("model", voice.model), } artifact = RenderArtifact( path=output_path, duration_seconds=int(result.get("duration_seconds", 0)), chapter_id=chapter_id, ) job.outputs.append(artifact) yield { "type": "chapter_done", "session_id": session_id, "chapter_id": chapter_id, "duration_seconds": artifact.duration_seconds, "overall_progress": (index + 1) / max(1, len(selected)), "output_path": str(output_path), "backend": result.get("backend", "local"), "model": result.get("model", voice.model), } job.status = "completed" yield { "type": "completed", "session_id": session_id, "outputs": [str(artifact.path) for artifact in job.outputs], "backend": "local", "model": voice.model, }