"""Shared script schema for writers, renderers, mixers, and UI.""" from __future__ import annotations from dataclasses import dataclass, field from enum import StrEnum from typing import Any class Genre(StrEnum): NOIR = "noir" WEIRD = "weird" WESTERN = "western" ROMANCE = "romance" COMEDY = "comedy" HINDI_MELODRAMA = "hindi_melodrama" class Delivery(StrEnum): NEUTRAL = "neutral" SLOW = "slow" URGENT = "urgent" WHISPER = "whisper" BOOMING = "booming" DEADPAN = "deadpan" AGITATED = "agitated" class VoiceID(StrEnum): """Locked Kokoro voice roster verified against hexgrad/Kokoro-82M.""" ANNOUNCER = "am_michael" DETECTIVE = "am_onyx" INGENUE = "af_bella" COMIC = "am_puck" VILLAIN = "am_fenrir" ELDER = "bm_george" HINDI_FEMALE = "hf_alpha" HINDI_MALE = "hm_omega" @dataclass(frozen=True) class CastMember: id: str name: str voice: VoiceID description: str @dataclass(frozen=True) class Line: cast: str text: str delivery: Delivery = Delivery.NEUTRAL @dataclass(frozen=True) class Scene: title: str sfx: list[str] lines: list[Line] @dataclass(frozen=True) class MusicPlan: genre: Genre opening_sting: str bed: str closing_sting: str @dataclass(frozen=True) class Script: title: str logline: str genre: Genre cast: list[CastMember] scenes: list[Scene] music: MusicPlan estimated_seconds: int = field(default=75) def validate(self) -> None: if not self.title.strip(): raise ValueError("script title is required") if not self.logline.strip(): raise ValueError("script logline is required") if not 30 <= self.estimated_seconds <= 120: raise ValueError("estimated_seconds must be between 30 and 120") if len(self.cast) < 2: raise ValueError("script must define at least two cast members") cast_ids = {member.id for member in self.cast} if len(cast_ids) != len(self.cast): raise ValueError("cast member ids must be unique") if not self.scenes: raise ValueError("script must contain at least one scene") for scene in self.scenes: if not scene.lines: raise ValueError(f"scene '{scene.title}' must contain at least one line") for line in scene.lines: if line.cast not in cast_ids: raise ValueError(f"line references unknown cast id '{line.cast}'") if not line.text.strip(): raise ValueError("line text cannot be empty") if self.music.genre != self.genre: raise ValueError("music genre must match script genre") def validate_script(script: Script) -> Script: script.validate() return script def script_from_json_dict(payload: dict[str, Any]) -> Script: """Build and validate a Script from a JSON-compatible dict.""" script = Script( title=str(payload["title"]), logline=str(payload["logline"]), genre=Genre(payload["genre"]), cast=[ CastMember( id=str(member["id"]), name=str(member["name"]), voice=VoiceID(member["voice"]), description=str(member.get("description", "")), ) for member in payload["cast"] ], scenes=[ Scene( title=str(scene["title"]), sfx=[str(item) for item in scene.get("sfx", [])], lines=[ Line( cast=str(line["cast"]), text=str(line["text"]), delivery=Delivery(line.get("delivery", Delivery.NEUTRAL)), ) for line in scene["lines"] ], ) for scene in payload["scenes"] ], music=MusicPlan( genre=Genre(payload["music"]["genre"]), opening_sting=str(payload["music"]["opening_sting"]), bed=str(payload["music"]["bed"]), closing_sting=str(payload["music"]["closing_sting"]), ), estimated_seconds=int(payload.get("estimated_seconds", 75)), ) script.validate() return script