File size: 8,962 Bytes
39f0786 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """
playwright.py — Script generation and director/character rewrite logic via Qwen on OpenRouter.
"""
import json
import requests
from voice_pool import VOICE_POOL
_MODEL = "google/gemma-4-26b-a4b-it"
_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# Keys the playwright may assign
_VOICE_LABELS = list(VOICE_POOL.keys())
_SYSTEM_PROMPT_GENERATE = f"""You are a master theater playwright. Your task is to write a complete, high-quality theatrical script in strict JSON format.
VOICE LABELS available (you must assign one per character, chosen to fit personality):
{json.dumps(_VOICE_LABELS, indent=2)}
Output ONLY a single valid JSON object matching this schema exactly:
{{
"title": "string",
"genre": "string",
"characters": [
{{
"name": "string",
"personality": "string (2-3 sentences describing the character)",
"voice_label": "string (must be one of the voice labels listed above)"
}}
],
"scenes": [
{{
"scene_number": 1,
"theme": "string",
"image_prompt": "string (vivid, specific, stageable, widescreen theater scene)",
"image_path": "string (relative path like generated_images/scene_1.png)",
"dialogues": [
{{
"character": "string (must match a character name exactly)",
"action": "string (stage direction, e.g. 'raising his hand slowly')",
"line": "string (the spoken line, natural and theatrical)"
}}
]
}}
]
}}
Rules:
- Generate exactly 4 scenes minimum
- Each scene must have at least 4 dialogue entries
- Make dialogues feel natural, witty, and theatrical
- Image prompts must be vivid, specific, and visually stageable
- Assign voice labels thoughtfully based on character personality
- Return ONLY valid JSON — no markdown fences, no preamble, no explanation. Do not output anything outside of the JSON block.
"""
_SYSTEM_PROMPT_REWRITE = """You are a master theater playwright assisting with a live script rewrite.
Return ONLY a valid JSON array of scene objects — the same schema as before, no markdown, no preamble.
Each scene object: {{ "scene_number": int, "theme": str, "image_prompt": str, "image_path": str, "dialogues": [{{"character": str, "action": str, "line": str}}] }}
The dialogues array MUST contain objects with 'character', 'action', and 'line' keys. DO NOT output strings for dialogues.
"""
def _call_model(api_key: str, system: str, user: str) -> str:
"""Call the LLM via OpenRouter and return the raw response text."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": _MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": 8192,
"temperature": 0.8,
"provider": {
"order": ["Cloudflare"]
}
}
response = requests.post(_OPENROUTER_URL, headers=headers, json=payload, timeout=120)
if response.status_code != 200:
raise RuntimeError(
f"OpenRouter API error {response.status_code}: {response.text[:300]}"
)
return response.json()["choices"][0]["message"]["content"].strip()
def _strip_fences(raw: str) -> str:
"""Strip markdown code fences if the model wrapped its JSON in them."""
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
raw = raw.strip()
if raw.endswith("```"):
raw = raw[: raw.rfind("```")].strip()
return raw
def _parse_json_with_retry(api_key: str, system: str, user: str) -> dict | list:
"""Call the model and parse JSON; retry once with stricter instruction on failure."""
raw = _strip_fences(_call_model(api_key, system, user))
try:
return json.loads(raw)
except json.JSONDecodeError:
retry_user = (
user + "\n\nIMPORTANT: Your previous response was not valid JSON. "
"Return ONLY valid JSON, no text before or after."
)
raw2 = _strip_fences(_call_model(api_key, system, retry_user))
try:
return json.loads(raw2)
except json.JSONDecodeError as e:
raise ValueError(
f"Playwright returned malformed JSON after retry: {e}\n\nRaw output:\n{raw2[:500]}"
)
def generate_script(topic: str, api_key: str) -> dict:
"""
Generate a complete theater script JSON dict for the given topic.
Args:
topic: The user-provided scenario description.
api_key: OpenRouter API key.
Returns:
Parsed script dict matching the schema.
"""
user_prompt = (
f"Write a complete theatrical script about the following topic:\n\n{topic}\n\n"
"Remember: return ONLY valid JSON, no preamble."
)
result = _parse_json_with_retry(api_key, _SYSTEM_PROMPT_GENERATE, user_prompt)
if not isinstance(result, dict):
raise ValueError("Playwright response was not a JSON object.")
return result
def rewrite_from_director(script: dict, current_scene_index: int, event: str, api_key: str) -> dict:
"""
Rewrite all scenes from current_scene_index onward based on a director event.
Args:
script: The full current script dict.
current_scene_index: Index (0-based) of the scene currently playing.
event: The director's injected event description.
api_key: OpenRouter API key.
Returns:
Updated full script dict with rewritten scenes merged in.
"""
completed = script["scenes"][:current_scene_index]
completed_summary = json.dumps(
[{"scene_number": s["scene_number"], "theme": s["theme"]} for s in completed],
indent=2,
)
user_prompt = f"""You are rewriting a theatrical script mid-performance.
TITLE: {script['title']}
GENRE: {script['genre']}
CHARACTERS: {json.dumps(script['characters'], indent=2)}
COMPLETED SCENES (do not rewrite these):
{completed_summary}
DIRECTOR EVENT (a sudden in-world occurrence that must be integrated):
"{event}"
Rewrite ALL remaining scenes (starting from scene number {current_scene_index + 1}) to naturally incorporate and follow from this director event.
Keep the same characters, title, and genre.
Return ONLY a JSON array of the rewritten scene objects (from scene {current_scene_index + 1} onward).
Each scene: {{"scene_number": int, "theme": str, "image_prompt": str, "image_path": str, "dialogues": [{{"character": "string", "action": "string", "line": "string"}}]}}
No preamble, no markdown.
"""
rewritten = _parse_json_with_retry(api_key, _SYSTEM_PROMPT_REWRITE, user_prompt)
if not isinstance(rewritten, list):
raise ValueError("Rewrite response was not a JSON array.")
updated_script = dict(script)
updated_script["scenes"] = completed + rewritten
return updated_script
def rewrite_from_character(script: dict, current_scene_index: int, character_input: str, api_key: str) -> dict:
"""
Rewrite scenes from current_scene_index onward based on a character action or new character entry.
Args:
script: The full current script dict.
current_scene_index: Index (0-based) of the scene currently playing.
character_input: Description of what the character does or says unexpectedly.
api_key: OpenRouter API key.
Returns:
Updated full script dict with rewritten scenes merged in.
"""
completed = script["scenes"][:current_scene_index]
completed_summary = json.dumps(
[{"scene_number": s["scene_number"], "theme": s["theme"]} for s in completed],
indent=2,
)
user_prompt = f"""You are rewriting a theatrical script mid-performance.
TITLE: {script['title']}
GENRE: {script['genre']}
CHARACTERS: {json.dumps(script['characters'], indent=2)}
COMPLETED SCENES (do not rewrite these):
{completed_summary}
CHARACTER DISRUPTION (a character does something unexpected, or a new character enters):
"{character_input}"
Rewrite ALL remaining scenes (starting from scene number {current_scene_index + 1}) to naturally integrate this character disruption.
If a new character is introduced, add them to the ensemble and give them a fitting voice_label from: {json.dumps(_VOICE_LABELS)}.
Keep the same title and genre.
Return ONLY a JSON array of the rewritten scene objects (from scene {current_scene_index + 1} onward).
Each scene: {{"scene_number": int, "theme": str, "image_prompt": str, "image_path": str, "dialogues": [{{"character": "string", "action": "string", "line": "string"}}]}}
No preamble, no markdown.
"""
rewritten = _parse_json_with_retry(api_key, _SYSTEM_PROMPT_REWRITE, user_prompt)
if not isinstance(rewritten, list):
raise ValueError("Rewrite response was not a JSON array.")
updated_script = dict(script)
updated_script["scenes"] = completed + rewritten
return updated_script
|