| """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 |
|
|