Skip to main content

xil/cmd/
regen.rs

1//! `xil regen` — parsed JSON back to a production script. Port of
2//! `XILP009_script_regenerator.py`.
3
4use 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    /// Episode tag (e.g. S02E03)
26    #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
27    episode: Option<String>,
28    /// Raw tag for non-episodic content (e.g. V01C03, D01)
29    #[arg(long)]
30    tag: Option<String>,
31    /// Override parsed JSON path
32    #[arg(long)]
33    parsed: Option<PathBuf>,
34    /// Override cast config path
35    #[arg(long)]
36    cast: Option<PathBuf>,
37    /// Override SFX config path (sfx_<TAG>.json); when supplied, direction entries are emitted with a pipe-hint suffix carrying the source filename and any play_volume_pct / play_duration_pct override
38    #[arg(long)]
39    sfx: Option<PathBuf>,
40    /// Show name override (default: from project.json)
41    #[arg(long)]
42    show: Option<String>,
43    /// Output markdown path (default: scripts/revised_<slug>_{TAG}.md)
44    #[arg(long)]
45    output: Option<PathBuf>,
46    /// Path to speakers.json (default: auto-detect from CWD, then built-in)
47    #[arg(long)]
48    speakers: Option<PathBuf>,
49}
50
51/// Speaker key → display name, for turning parsed fields back into prose.
52///
53/// The Python also builds a section slug → header map, but its
54/// `regenerate_script` emits `section_header` text verbatim and never
55/// consults it, so that half is deliberately not ported.
56pub struct Reverse {
57    speaker: IndexMap<String, String>,
58}
59
60/// The first display name registered for a key wins.
61pub 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
80/// Direction text → pipe-hint suffix, from an SFX config.
81///
82/// The suffix carries the source basename, any attribute hints, or both.
83/// A cue with neither produces no hint, so it regenerates as plain `[TEXT]`.
84pub 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
112/// Rebuild the markdown script.
113pub 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        // Skip the synthetic seq-0 preamble stems of the old XILP002 format.
174        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    // The banner opens after parsing here: Python reads the tag first.
299    let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
300    execute(&a)
301}
302
303/// The clap definition behind `--help`, for man pages.
304pub 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}