1use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use indexmap::IndexMap;
10use serde_json::Value;
11use xil_core::fsutil::basename;
12use xil_core::script::hints::{format_hint_attr, HINT_ATTRS};
13use xil_core::script::speakers::{load_speakers, Speakers};
14use xil_core::workspace::{derive_paths, resolve_slug};
15use xil_core::{banner, log};
16
17const SCRIPT_NAME: &str = "XILP009_script_regenerator.py";
18
19#[derive(Parser)]
20#[command(
21 name = "xil-regen",
22 about = "Regenerate a production script markdown from parsed JSON."
23)]
24struct Args {
25 #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
27 episode: Option<String>,
28 #[arg(long)]
30 tag: Option<String>,
31 #[arg(long)]
33 parsed: Option<PathBuf>,
34 #[arg(long)]
36 cast: Option<PathBuf>,
37 #[arg(long)]
39 sfx: Option<PathBuf>,
40 #[arg(long)]
42 show: Option<String>,
43 #[arg(long)]
45 output: Option<PathBuf>,
46 #[arg(long)]
48 speakers: Option<PathBuf>,
49}
50
51pub struct Reverse {
57 speaker: IndexMap<String, String>,
58}
59
60pub fn build_reverse(speakers: &Speakers) -> Reverse {
62 let mut speaker: IndexMap<String, String> = IndexMap::new();
63 for (display, key) in &speakers.keys {
64 speaker
65 .entry(key.clone())
66 .or_insert_with(|| display.clone());
67 }
68 Reverse { speaker }
69}
70
71impl Reverse {
72 pub fn speaker_display(&self, key: &str) -> String {
73 self.speaker
74 .get(key)
75 .cloned()
76 .unwrap_or_else(|| key.to_uppercase())
77 }
78}
79
80pub fn build_sfx_lookup(sfx_config: &Value) -> IndexMap<String, String> {
85 let mut lookup = IndexMap::new();
86 let effects = sfx_config
87 .get("effects")
88 .and_then(Value::as_object)
89 .cloned()
90 .unwrap_or_default();
91 for (key, effect) in effects {
92 let mut parts: Vec<String> = Vec::new();
93 if let Some(src) = effect
94 .get("source")
95 .and_then(Value::as_str)
96 .filter(|s| !s.is_empty())
97 {
98 parts.push(basename(Path::new(src)));
99 }
100 for (_, field) in HINT_ATTRS {
101 if let Some(v) = effect.get(field).and_then(Value::as_f64) {
102 parts.push(format_hint_attr(field, v));
103 }
104 }
105 if !parts.is_empty() {
106 lookup.insert(key, parts.join(" | "));
107 }
108 }
109 lookup
110}
111
112pub fn regenerate_script(
114 parsed: &Value,
115 cast: Option<&Value>,
116 sfx_config: Option<&Value>,
117 rev: &Reverse,
118) -> String {
119 let sfx_lookup = sfx_config.map(build_sfx_lookup).unwrap_or_default();
120
121 let show = parsed
122 .get("show")
123 .and_then(Value::as_str)
124 .unwrap_or("Unknown Show");
125 let season = parsed.get("season").and_then(Value::as_i64);
126 let episode = parsed.get("episode").and_then(Value::as_i64).unwrap_or(1);
127 let title = parsed.get("title").and_then(Value::as_str).unwrap_or("");
128 let season_title = parsed
129 .get("season_title")
130 .and_then(Value::as_str)
131 .unwrap_or("");
132
133 let mut lines: Vec<String> = Vec::new();
134
135 let mut header = show.to_string();
136 if let Some(s) = season {
137 header.push_str(&format!(" Season {s}:"));
138 }
139 header.push_str(&format!(" Episode {episode}:"));
140 if !title.is_empty() {
141 header.push_str(&format!(" \"{title}\""));
142 }
143 if !season_title.is_empty() {
144 header.push_str(&format!(" Arc: \"{season_title}\""));
145 }
146 lines.push(header);
147 lines.push(String::new());
148
149 if let Some(characters) = cast.and_then(|c| c.get("cast")).and_then(Value::as_object) {
150 if !characters.is_empty() {
151 lines.push("CAST:".into());
152 for (key, char) in characters {
153 let display = char
154 .get("full_name")
155 .and_then(Value::as_str)
156 .filter(|s| !s.is_empty())
157 .map(str::to_string)
158 .unwrap_or_else(|| key.to_uppercase());
159 let role = char.get("role").and_then(Value::as_str).unwrap_or("");
160 let role = role.split('\n').next().unwrap_or("");
161 lines.push(format!("* {display} — {role}"));
162 }
163 lines.push(String::new());
164 }
165 }
166
167 let entries = parsed
168 .get("entries")
169 .and_then(Value::as_array)
170 .cloned()
171 .unwrap_or_default();
172 for entry in entries {
173 if entry.get("seq").and_then(Value::as_i64).unwrap_or(0) < 1 {
175 continue;
176 }
177 let kind = entry.get("type").and_then(Value::as_str).unwrap_or("");
178 let text = entry.get("text").and_then(Value::as_str).unwrap_or("");
179
180 match kind {
181 "section_header" => {
182 lines.push("===".into());
183 lines.push(String::new());
184 lines.push(text.to_string());
185 lines.push(String::new());
186 }
187 "scene_header" => {
188 lines.push(text.to_string());
189 lines.push(String::new());
190 }
191 "direction" => {
192 let suffix = sfx_lookup
193 .get(text)
194 .map(|h| format!(" | {h}"))
195 .unwrap_or_default();
196 lines.push(format!("[{text}{suffix}]"));
197 lines.push(String::new());
198 }
199 "dialogue" => {
200 let display = match entry
201 .get("speaker")
202 .and_then(Value::as_str)
203 .filter(|s| !s.is_empty())
204 {
205 Some(k) => rev.speaker_display(k),
206 None => "UNKNOWN".to_string(),
207 };
208 match entry
209 .get("direction")
210 .and_then(Value::as_str)
211 .filter(|d| !d.is_empty())
212 {
213 Some(d) => lines.push(format!("{display} ({d})")),
214 None => lines.push(display),
215 }
216 lines.push(text.to_string());
217 lines.push(String::new());
218 }
219 _ => {}
220 }
221 }
222
223 lines.push("END OF EPISODE".into());
224 lines.push(String::new());
225 lines.join("\n")
226}
227
228fn read_json(path: &Path) -> Option<Value> {
229 serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
230}
231
232fn execute(a: &Args) -> anyhow::Result<i32> {
233 let tag = a
234 .episode
235 .clone()
236 .or_else(|| a.tag.clone())
237 .unwrap_or_default();
238 let speakers = load_speakers(a.speakers.as_deref(), &[]);
239 let rev = build_reverse(&speakers);
240
241 let slug = resolve_slug(a.show.as_deref(), "project.json");
242 let p = derive_paths(&slug, &tag);
243 let parsed_path = a.parsed.clone().unwrap_or_else(|| p["parsed"].clone());
244 let cast_path = a.cast.clone().unwrap_or_else(|| p["cast"].clone());
245 let sfx_path = a.sfx.clone().unwrap_or_else(|| p["sfx"].clone());
246 let output_path = a
247 .output
248 .clone()
249 .unwrap_or_else(|| p["revised_script"].clone());
250
251 if !parsed_path.exists() {
252 log::error(&format!("Parsed JSON not found: {}", parsed_path.display()));
253 return Ok(1);
254 }
255 let parsed: Value = serde_json::from_str(&fs::read_to_string(&parsed_path)?)?;
256 let cast = cast_path.exists().then(|| read_json(&cast_path)).flatten();
257
258 let sfx_config = if sfx_path.exists() {
259 log::info(&format!(" SFX config loaded: {}", sfx_path.display()));
260 read_json(&sfx_path)
261 } else {
262 log::debug(&format!(
263 " No SFX config found at {} — pipe-hints disabled",
264 sfx_path.display()
265 ));
266 None
267 };
268
269 let script_text = regenerate_script(&parsed, cast.as_ref(), sfx_config.as_ref(), &rev);
270 if let Some(d) = output_path.parent().filter(|d| !d.as_os_str().is_empty()) {
271 fs::create_dir_all(d)?;
272 }
273 fs::write(&output_path, script_text)?;
274
275 let entry_count = parsed
276 .get("entries")
277 .and_then(Value::as_array)
278 .map(Vec::len)
279 .unwrap_or(0);
280 let dialogue_count = parsed
281 .get("stats")
282 .and_then(|s| s.get("dialogue_lines"))
283 .and_then(Value::as_i64)
284 .unwrap_or(0);
285 log::info(&format!(
286 " Regenerated script from {entry_count} entries ({dialogue_count} dialogue)"
287 ));
288 log::info(&format!(" Written to: {}", output_path.display()));
289 Ok(0)
290}
291
292pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
293 log::init("regen");
294 let a: Args = match super::parse_or_exit("xil-regen", args) {
295 Ok(a) => a,
296 Err(code) => return Ok(code),
297 };
298 let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
300 execute(&a)
301}
302
303pub fn command() -> clap::Command {
305 <Args as clap::CommandFactory>::command()
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use serde_json::json;
312
313 fn rev() -> Reverse {
314 build_reverse(&load_speakers(Some(Path::new("/nonexistent")), &[]))
315 }
316
317 #[test]
318 fn speaker_keys_map_back_to_the_first_display() {
319 let r = rev();
320 assert_eq!(r.speaker_display("adam"), "ADAM");
321 assert_eq!(
322 r.speaker_display("film_audio"),
323 "FILM AUDIO (MARGARET'S VOICE)"
324 );
325 assert_eq!(r.speaker_display("nobody"), "NOBODY");
326 }
327
328 #[test]
329 fn sfx_lookup_joins_source_and_attributes() {
330 let cfg = json!({"effects": {
331 "OUTRO MUSIC": {"source": "SFX/the413/sundy3M4_v3.mp3", "volume_percentage": 20.0},
332 "SFX: PLAIN": {"prompt": "p"},
333 "BEAT": {"type": "silence"},
334 "MUSIC: TRIMMED": {"source": "SFX/a.mp3", "play_duration": 35.0},
335 "SFX: VOL ONLY": {"prompt": "p", "volume_percentage": 15.0}
336 }});
337 let lookup = build_sfx_lookup(&cfg);
338 assert_eq!(
339 lookup["OUTRO MUSIC"],
340 "sundy3M4_v3.mp3 | play_volume_pct=20%"
341 );
342 assert_eq!(lookup["MUSIC: TRIMMED"], "a.mp3 | play_duration_pct=35%");
343 assert_eq!(lookup["SFX: VOL ONLY"], "play_volume_pct=15%");
344 assert!(
345 !lookup.contains_key("SFX: PLAIN"),
346 "a bare prompt yields no hint"
347 );
348 assert!(!lookup.contains_key("BEAT"));
349 }
350
351 #[test]
352 fn regenerated_script_round_trips_the_shapes() {
353 let parsed = json!({
354 "show": "THE 413", "season": 2, "episode": 5, "title": "T", "season_title": "Arc",
355 "entries": [
356 {"seq": 0, "type": "dialogue", "speaker": "tina", "text": "synthetic", "direction": null},
357 {"seq": 1, "type": "section_header", "text": "COLD OPEN"},
358 {"seq": 2, "type": "scene_header", "text": "SCENE 1: ROOM"},
359 {"seq": 3, "type": "direction", "text": "SFX: DOOR"},
360 {"seq": 4, "type": "direction", "text": "OUTRO MUSIC"},
361 {"seq": 5, "type": "dialogue", "speaker": "adam", "direction": "quietly", "text": "Hi."},
362 {"seq": 6, "type": "dialogue", "speaker": null, "direction": null, "text": "Who?"}
363 ]
364 });
365 let cast =
366 json!({"cast": {"adam": {"full_name": "Adam Santos", "role": "Host\nsecond line"}}});
367 let sfx = json!({"effects": {"OUTRO MUSIC": {"source": "SFX/out.mp3"}}});
368 let out = regenerate_script(&parsed, Some(&cast), Some(&sfx), &rev());
369 assert_eq!(
370 out,
371 "THE 413 Season 2: Episode 5: \"T\" Arc: \"Arc\"\n\n\
372 CAST:\n* Adam Santos — Host\n\n\
373 ===\n\nCOLD OPEN\n\n\
374 SCENE 1: ROOM\n\n\
375 [SFX: DOOR]\n\n\
376 [OUTRO MUSIC | out.mp3]\n\n\
377 ADAM (quietly)\nHi.\n\n\
378 UNKNOWN\nWho?\n\n\
379 END OF EPISODE\n"
380 );
381 }
382
383 #[test]
384 fn a_seasonless_untitled_script_keeps_a_minimal_header() {
385 let parsed = json!({"show": "S", "season": null, "episode": 3, "title": "", "season_title": null, "entries": []});
386 let out = regenerate_script(&parsed, None, None, &rev());
387 assert_eq!(out, "S Episode 3:\n\nEND OF EPISODE\n");
388 }
389}