1use std::ffi::OsString;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::LazyLock;
9
10use clap::Parser;
11use indexmap::IndexMap;
12use regex::Regex;
13use serde_json::{Map, Value};
14use xil_core::fsutil::basename;
15use xil_core::journal::{append_sfx_edit, replay_sfx_edits, sfx_edits_path};
16use xil_core::pycsv;
17use xil_core::pyjson::{dumps, Style};
18use xil_core::script::hints::filter_sfx_overrides;
19use xil_core::script::speakers::{key_to_display, load_speakers, load_speakers_registry};
20use xil_core::script::{parse_script, Entry, ParseOpts, Parsed};
21use xil_core::workspace::{
22 derive_paths, episode_tag, resolve_project_type, resolve_season, resolve_season_title,
23 resolve_slug, show_slug, workspace_root,
24};
25use xil_core::{banner, log};
26
27static BEAT_SECONDS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+SECOND").unwrap());
28
29const DEBUG_TRUNCATE: usize = 200;
30
31#[derive(Parser)]
32#[command(
33 name = "xil-parse",
34 about = "Parse production script markdown into structured JSON"
35)]
36struct Args {
37 script: PathBuf,
39 #[arg(long, conflicts_with = "tag")]
41 episode: Option<String>,
42 #[arg(long)]
44 tag: Option<String>,
45 #[arg(long)]
47 show: Option<String>,
48 #[arg(long, short = 'o')]
50 output: Option<PathBuf>,
51 #[arg(long)]
53 preview: Option<usize>,
54 #[arg(long)]
56 quiet: bool,
57 #[arg(long)]
59 debug: bool,
60 #[arg(long)]
62 stats: bool,
63 #[arg(long)]
65 speakers: Option<PathBuf>,
66}
67
68fn run_parse(path: &Path, speakers: Option<&Path>) -> anyhow::Result<Parsed> {
70 let raw = fs::read_to_string(path)?;
71 let opts = ParseOpts {
72 project_type: &resolve_project_type("project.json"),
73 speakers_path: speakers,
74 season_fallback: resolve_season(None, "project.json"),
75 season_title_fallback: resolve_season_title(None, "project.json"),
76 };
77 Ok(parse_script(&raw, &basename(path), &opts, &mut |m| {
78 log::warning(&m)
79 }))
80}
81
82fn generate_cast_config(
86 parsed: &Value,
87 cast_path: &Path,
88 tag_override: Option<&str>,
89 registry: &IndexMap<String, Map<String, Value>>,
90 key_display: &IndexMap<String, String>,
91) -> anyhow::Result<()> {
92 let speakers: Vec<String> = parsed["stats"]["speakers"]
93 .as_array()
94 .cloned()
95 .unwrap_or_default()
96 .iter()
97 .filter_map(|v| v.as_str().map(str::to_string))
98 .collect();
99
100 let mut cast = Map::new();
101 for key in &speakers {
102 let empty = Map::new();
103 let reg = registry.get(key).unwrap_or(&empty);
104 let display = key_display.get(key).cloned().unwrap_or_else(|| key.clone());
105 let default_name = title_case(&display.replace('_', " "));
106
107 let mut member = Map::new();
108 member.insert(
109 "full_name".into(),
110 or_default(reg.get("full_name"), Value::String(default_name)),
111 );
112 member.insert(
113 "voice_id".into(),
114 or_default(reg.get("voice_id"), Value::String("TBD".into())),
115 );
116 member.insert(
117 "pan".into(),
118 reg.get("pan").cloned().unwrap_or(Value::from(0.0)),
119 );
120 member.insert(
121 "filter".into(),
122 reg.get("filter").cloned().unwrap_or(Value::Bool(false)),
123 );
124 member.insert(
125 "role".into(),
126 or_default(reg.get("role"), Value::String("TBD".into())),
127 );
128 for field in [
129 "stability",
130 "similarity_boost",
131 "style",
132 "use_speaker_boost",
133 "language_code",
134 ] {
135 if let Some(v) = reg.get(field).filter(|v| !v.is_null()) {
136 member.insert(field.into(), v.clone());
137 }
138 }
139 cast.insert(key.clone(), Value::Object(member));
140 }
141
142 let mut config = Map::new();
143 config.insert(
144 "show".into(),
145 parsed
146 .get("show")
147 .cloned()
148 .unwrap_or(Value::String("Unknown Show".into())),
149 );
150 config.insert(
151 "season".into(),
152 if tag_override.is_some() {
153 Value::Null
154 } else {
155 parsed["season"].clone()
156 },
157 );
158 config.insert(
159 "episode".into(),
160 if tag_override.is_some() {
161 Value::Null
162 } else {
163 parsed["episode"].clone()
164 },
165 );
166 config.insert(
167 "title".into(),
168 parsed
169 .get("title")
170 .cloned()
171 .unwrap_or(Value::String(String::new())),
172 );
173 config.insert(
174 "season_title".into(),
175 parsed.get("season_title").cloned().unwrap_or(Value::Null),
176 );
177 config.insert("cast".into(), Value::Object(cast));
178 if let Some(t) = tag_override {
179 config.insert("tag_override".into(), Value::String(t.to_string()));
180 }
181
182 write_json_no_newline(cast_path, &Value::Object(config))?;
183 log::info(&format!(
184 "Created {} with {} speakers (voice_id=TBD — run XILU001 to assign)",
185 cast_path.display(),
186 speakers.len()
187 ));
188 Ok(())
189}
190
191fn or_default(v: Option<&Value>, default: Value) -> Value {
193 match v {
194 Some(Value::Null) | None => default,
195 Some(Value::String(s)) if s.is_empty() => default,
196 Some(Value::Bool(false)) => default,
197 Some(other) => other.clone(),
198 }
199}
200
201pub(crate) fn title_case(s: &str) -> String {
203 let mut out = String::with_capacity(s.len());
204 let mut prev_alpha = false;
205 for c in s.chars() {
206 if c.is_alphabetic() {
207 if prev_alpha {
208 out.extend(c.to_lowercase());
209 } else {
210 out.extend(c.to_uppercase());
211 }
212 prev_alpha = true;
213 } else {
214 out.push(c);
215 prev_alpha = false;
216 }
217 }
218 out
219}
220
221fn apply_sfx_overrides(
225 key: &str,
226 entry: &mut Map<String, Value>,
227 overrides: &IndexMap<String, f64>,
228) -> usize {
229 let is_silence = entry.get("type").and_then(Value::as_str) == Some("silence");
230 let applied = filter_sfx_overrides(key, is_silence, overrides, Some(&mut |m| log::warning(&m)));
231 if applied.is_empty() {
232 return 0;
233 }
234 for (k, v) in &applied {
235 entry.insert(k.clone(), json_f64(*v));
236 }
237 if applied.contains_key("play_duration") && entry.get("source").is_some_and(|s| !s.is_null()) {
238 entry.shift_remove("duration_seconds");
239 }
240 applied.len()
241}
242
243fn json_f64(v: f64) -> Value {
244 serde_json::Number::from_f64(v)
245 .map(Value::Number)
246 .unwrap_or(Value::Null)
247}
248
249fn entry_overrides(e: &Value) -> IndexMap<String, f64> {
251 e.get("sfx_overrides")
252 .and_then(Value::as_object)
253 .map(|m| {
254 m.iter()
255 .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
256 .collect()
257 })
258 .unwrap_or_default()
259}
260
261fn default_duration(text: &str) -> f64 {
263 if text.starts_with("AMBIENCE:") {
264 30.0
265 } else if text.starts_with("MUSIC:") {
266 15.0
267 } else {
268 5.0
269 }
270}
271
272fn generate_sfx_config(
274 parsed: &Value,
275 sfx_path: &Path,
276 tag_override: Option<&str>,
277) -> anyhow::Result<()> {
278 let mut effects: Map<String, Value> = Map::new();
279 let (mut silence_count, mut sfx_count) = (0usize, 0usize);
280
281 for e in parsed["entries"].as_array().cloned().unwrap_or_default() {
282 if e.get("type").and_then(Value::as_str) != Some("direction") {
283 continue;
284 }
285 let text = e
286 .get("text")
287 .and_then(Value::as_str)
288 .unwrap_or("")
289 .to_string();
290 if effects.contains_key(&text) {
291 continue;
292 }
293 let source = e
294 .get("sfx_source")
295 .and_then(Value::as_str)
296 .map(str::to_string);
297 let overrides = entry_overrides(&e);
298
299 let mut entry = Map::new();
300 if text == "BEAT" {
301 entry.insert("type".into(), Value::String("silence".into()));
302 entry.insert("duration_seconds".into(), json_f64(1.0));
303 silence_count += 1;
304 } else if text == "LONG BEAT" {
305 entry.insert("type".into(), Value::String("silence".into()));
306 entry.insert("duration_seconds".into(), json_f64(2.0));
307 silence_count += 1;
308 } else if text.starts_with("BEAT") {
309 let dur = BEAT_SECONDS
311 .captures(&text)
312 .and_then(|c| c[1].parse().ok())
313 .unwrap_or(1.0);
314 entry.insert("type".into(), Value::String("silence".into()));
315 entry.insert("duration_seconds".into(), json_f64(dur));
316 silence_count += 1;
317 } else if text == "AMBIENCE: STOP" || text.ends_with("FADES OUT") {
318 entry.insert("type".into(), Value::String("silence".into()));
319 entry.insert("duration_seconds".into(), json_f64(0.0));
320 silence_count += 1;
321 } else if text.starts_with("FILM AUDIO")
322 || text.starts_with("SPEAKERPHONE")
323 || text.starts_with("PHONE FILTER")
324 {
325 entry.insert("type".into(), Value::String("silence".into()));
330 entry.insert("duration_seconds".into(), json_f64(0.0));
331 silence_count += 1;
332 } else if let Some(src) = &source {
333 entry.insert("source".into(), Value::String(src.clone()));
334 entry.insert("duration_seconds".into(), json_f64(default_duration(&text)));
335 if text.starts_with("AMBIENCE:") {
336 entry.insert("loop".into(), Value::Bool(true));
337 }
338 sfx_count += 1;
339 } else if text.starts_with("AMBIENCE:") {
340 entry.insert("prompt".into(), Value::String(text.clone()));
341 entry.insert("duration_seconds".into(), json_f64(30.0));
342 entry.insert("loop".into(), Value::Bool(true));
343 sfx_count += 1;
344 } else if text.starts_with("MUSIC:") {
345 entry.insert("prompt".into(), Value::String(text.clone()));
346 entry.insert("duration_seconds".into(), json_f64(15.0));
347 sfx_count += 1;
348 } else {
349 entry.insert("prompt".into(), Value::String(text.clone()));
350 entry.insert("duration_seconds".into(), json_f64(5.0));
351 sfx_count += 1;
352 }
353
354 apply_sfx_overrides(&text, &mut entry, &overrides);
355 effects.insert(text, Value::Object(entry));
356 }
357
358 let mut config = Map::new();
359 config.insert("_docs".into(), docs_block());
360 config.insert(
361 "show".into(),
362 parsed
363 .get("show")
364 .cloned()
365 .unwrap_or(Value::String("Unknown Show".into())),
366 );
367 config.insert(
368 "season".into(),
369 if tag_override.is_some() {
370 Value::Null
371 } else {
372 parsed["season"].clone()
373 },
374 );
375 config.insert(
376 "episode".into(),
377 if tag_override.is_some() {
378 Value::Null
379 } else {
380 parsed["episode"].clone()
381 },
382 );
383 config.insert("defaults".into(), defaults_block());
384 config.insert("effects".into(), Value::Object(effects));
385 if let Some(t) = tag_override {
386 config.insert("tag_override".into(), Value::String(t.to_string()));
387 }
388
389 write_json_no_newline(sfx_path, &Value::Object(config))?;
390 let total = silence_count + sfx_count;
391 log::info(&format!(
392 "Created {} with {total} effects ({silence_count} silence, {sfx_count} sfx — review prompts before generation)",
393 sfx_path.display()
394 ));
395
396 let replay = replay_sfx_edits(sfx_path, false, &mut |m| log::warning(&m))?;
398 if replay.applied > 0 {
399 let note = if replay.orphans.is_empty() {
400 String::new()
401 } else {
402 format!(" ({} orphaned key(s))", replay.orphans.len())
403 };
404 log::info(&format!(
405 "Reapplied {} timeline sound edit(s) from {}{note}",
406 replay.applied,
407 sfx_edits_path(sfx_path).display()
408 ));
409 }
410 Ok(())
411}
412
413fn docs_block() -> Value {
414 let mut d = Map::new();
415 d.insert(
416 "defaults".into(),
417 Value::String(
418 "Episode-wide fallbacks. Category prefixes: ambience_, music_, sfx_, vintage_filter_. \
419 E.g. 'ambience_volume_percentage': 30. Global fallbacks: volume_percentage, ramp_in_seconds, \
420 ramp_out_seconds."
421 .into(),
422 ),
423 );
424 d.insert(
425 "per_effect_override".into(),
426 Value::String(
427 "Use plain 'volume_percentage' (no prefix) inside an effect entry to override the category default \
428 for that one cue only."
429 .into(),
430 ),
431 );
432 d.insert(
433 "source".into(),
434 Value::String(
435 "Relative path from workspace root, e.g. 'SFX/filename.mp3'. Explicit source always wins over the \
436 shared pool cache."
437 .into(),
438 ),
439 );
440 d.insert(
441 "play_duration".into(),
442 Value::String(
443 "Percentage of clip to play (0–100, e.g. 50 = half). Not applicable to AMBIENCE."
444 .into(),
445 ),
446 );
447 d.insert(
448 "prompt".into(),
449 Value::String(
450 "ElevenLabs SFX API natural-language description for generated effects.".into(),
451 ),
452 );
453 d.insert(
454 "type".into(),
455 Value::String("'sfx' for API-generated audio, 'silence' for local silence padding.".into()),
456 );
457 Value::Object(d)
458}
459
460fn defaults_block() -> Value {
461 let mut d = Map::new();
462 d.insert("prompt_influence".into(), json_f64(0.3));
463 d.insert("volume_percentage".into(), Value::from(20));
464 d.insert("ramp_in_seconds".into(), json_f64(1.0));
465 d.insert("ramp_out_seconds".into(), json_f64(1.0));
466 d.insert("ambience_volume_percentage".into(), Value::from(30));
467 d.insert("ambience_ramp_in_seconds".into(), json_f64(1.0));
468 d.insert("ambience_ramp_out_seconds".into(), json_f64(1.0));
469 Value::Object(d)
470}
471
472fn write_json_no_newline(path: &Path, v: &Value) -> anyhow::Result<()> {
474 if let Some(d) = path.parent() {
475 if !d.as_os_str().is_empty() {
476 fs::create_dir_all(d)?;
477 }
478 }
479 fs::write(path, dumps(v, Style::INDENT2_UTF8))?;
480 Ok(())
481}
482
483pub(crate) fn hint_target_exists(source: &str) -> bool {
486 let p = Path::new(source);
487 if p.is_absolute() {
488 p.exists()
489 } else {
490 workspace_root().join(source).exists()
491 }
492}
493
494pub(crate) fn backfill_sfx_sources(
500 parsed: &Value,
501 sfx_path: &Path,
502 force: bool,
503) -> anyhow::Result<()> {
504 let text = fs::read_to_string(sfx_path)?;
505 let mut data: Value = serde_json::from_str(&text)?;
506 let obj = data
507 .as_object_mut()
508 .ok_or_else(|| anyhow::anyhow!("sfx config is not an object"))?;
509 obj.entry("effects")
510 .or_insert_with(|| Value::Object(Map::new()));
511
512 let mut stale_key_map: Vec<(String, String)> = Vec::new();
514 for existing in obj["effects"]
515 .as_object()
516 .cloned()
517 .unwrap_or_default()
518 .keys()
519 {
520 let h = xil_core::script::hints::parse_direction_hint(existing, "", &mut |_| {});
521 if h.source.is_some() && &h.clean != existing {
522 stale_key_map.push((existing.clone(), h.clean));
523 }
524 }
525
526 let mut updated = 0usize;
527 let mut journaled: Vec<(String, String)> = Vec::new();
528 let mut seen_clean: Vec<String> = Vec::new();
529
530 for e in parsed["entries"].as_array().cloned().unwrap_or_default() {
531 if e.get("type").and_then(Value::as_str) != Some("direction") {
532 continue;
533 }
534 let source = e
535 .get("sfx_source")
536 .and_then(Value::as_str)
537 .map(str::to_string);
538 let overrides = entry_overrides(&e);
539 if source.is_none() && overrides.is_empty() {
540 continue;
541 }
542 let text = e
543 .get("text")
544 .and_then(Value::as_str)
545 .unwrap_or("")
546 .to_string();
547 if seen_clean.contains(&text) {
548 continue;
549 }
550 seen_clean.push(text.clone());
551
552 let effects = obj["effects"].as_object_mut().expect("effects");
553 if effects.contains_key(&text) {
554 let current = effects[&text]
555 .get("source")
556 .and_then(Value::as_str)
557 .map(str::to_string);
558 let entry = effects[&text].as_object_mut().expect("effect object");
559 if let (Some(src), None) = (&source, ¤t) {
560 entry.insert("source".into(), Value::String(src.clone()));
561 if entry.get("prompt").and_then(Value::as_str) == Some(text.as_str()) {
562 entry.shift_remove("prompt");
563 }
564 journaled.push((text.clone(), src.clone()));
565 updated += 1;
566 } else if force && source.is_some() && current.as_deref() != source.as_deref() {
567 let src = source.clone().expect("checked");
568 if hint_target_exists(&src) {
569 entry.insert("source".into(), Value::String(src.clone()));
570 entry.shift_remove("prompt");
571 journaled.push((text.clone(), src));
572 updated += 1;
573 } else {
574 log::warning(&format!(
575 " Keeping '{text}' source {} — hint target {src} not found on disk",
576 current.as_deref().unwrap_or("None")
577 ));
578 }
579 }
580 } else if let Some(stale) = stale_key_map
581 .iter()
582 .find(|(_, clean)| *clean == text)
583 .map(|(k, _)| k.clone())
584 {
585 if let Some(mut old) = effects.shift_remove(&stale) {
587 if let (Some(src), Some(o)) = (&source, old.as_object_mut()) {
588 o.insert("source".into(), Value::String(src.clone()));
589 o.shift_remove("prompt");
590 journaled.push((text.clone(), src.clone()));
591 }
592 effects.insert(text.clone(), old);
593 updated += 1;
594 }
595 } else {
596 let mut new_entry = Map::new();
598 new_entry.insert("duration_seconds".into(), json_f64(default_duration(&text)));
599 match &source {
600 Some(src) => new_entry.insert("source".into(), Value::String(src.clone())),
601 None => new_entry.insert("prompt".into(), Value::String(text.clone())),
603 };
604 if text.starts_with("AMBIENCE:") {
605 new_entry.insert("loop".into(), Value::Bool(true));
606 }
607 effects.insert(text.clone(), Value::Object(new_entry));
608 if let Some(src) = &source {
609 journaled.push((text.clone(), src.clone()));
610 }
611 updated += 1;
612 }
613
614 let effects = obj["effects"].as_object_mut().expect("effects");
616 let entry = effects[&text].as_object_mut().expect("effect object");
617 let changed: IndexMap<String, f64> = overrides
618 .into_iter()
619 .filter(|(k, v)| entry.get(k).and_then(Value::as_f64) != Some(*v))
620 .collect();
621 if apply_sfx_overrides(&text, entry, &changed) > 0 {
622 updated += 1;
623 }
624 }
625
626 if updated > 0 {
627 write_json_no_newline(sfx_path, &data)?;
628 for (key, src) in journaled {
631 let mut fields = Map::new();
632 fields.insert("source".into(), Value::String(src));
633 append_sfx_edit(sfx_path, &key, &fields)?;
634 }
635 log::info(&format!(
636 "Backfilled {updated} script hint(s) in {}",
637 sfx_path.display()
638 ));
639 }
640 Ok(())
641}
642
643fn write_debug_csv(path: &Path, parsed: &Parsed) -> anyhow::Result<()> {
646 let mut rows = Vec::new();
647 for (line_num, raw_line, idx) in &parsed.debug_line_map {
648 let e: &Entry = &parsed.entries[*idx];
649 let mut row = Map::new();
650 row.insert("md_line_num".into(), Value::from(*line_num));
651 row.insert("md_raw".into(), Value::String(truncate(raw_line)));
652 row.insert("seq".into(), Value::from(e.seq));
653 row.insert("type".into(), Value::from(e.kind));
654 row.insert(
655 "section".into(),
656 Value::String(e.section.clone().unwrap_or_default()),
657 );
658 row.insert(
659 "scene".into(),
660 Value::String(e.scene.clone().unwrap_or_default()),
661 );
662 row.insert(
663 "speaker".into(),
664 Value::String(e.speaker.clone().unwrap_or_default()),
665 );
666 row.insert(
667 "direction".into(),
668 Value::String(e.direction.clone().unwrap_or_default()),
669 );
670 row.insert(
671 "direction_type".into(),
672 Value::String(e.direction_type.unwrap_or("").to_string()),
673 );
674 row.insert("text".into(), Value::String(truncate(&e.text)));
675 rows.push(row);
676 }
677 let cols = [
678 "md_line_num",
679 "md_raw",
680 "seq",
681 "type",
682 "section",
683 "scene",
684 "speaker",
685 "direction",
686 "direction_type",
687 "text",
688 ];
689 let mut f = fs::File::create(path)?;
690 pycsv::write_dicts(&mut f, &cols, &rows)?;
691 Ok(())
692}
693
694fn truncate(s: &str) -> String {
695 s.chars().take(DEBUG_TRUNCATE).collect()
696}
697
698fn commas(n: usize) -> String {
700 let s = n.to_string();
701 let mut out = String::new();
702 for (i, c) in s.chars().enumerate() {
703 if i > 0 && (s.len() - i) % 3 == 0 {
704 out.push(',');
705 }
706 out.push(c);
707 }
708 out
709}
710
711fn print_speaker_stats(parsed: &Parsed) {
712 let rows = xil_core::script::compute_speaker_stats(&parsed.entries);
713 if rows.is_empty() {
714 log::info(" No dialogue entries found.");
715 return;
716 }
717 let (tl, tw, tc): (usize, usize, usize) = rows.iter().fold((0, 0, 0), |a, r| {
718 (a.0 + r.lines, a.1 + r.words, a.2 + r.chars)
719 });
720
721 log::info(&format!(
722 "\n{:<15} {:>6} {:>6} {:>7} {:>6} {:>8} {:>6}",
723 "Speaker", "Lines", "%", "Words", "%", "Chars", "%"
724 ));
725 let d = |n: usize| "-".repeat(n);
726 log::info(&format!(
727 "{} {} {} {} {} {} {}",
728 d(15),
729 d(6),
730 d(6),
731 d(7),
732 d(6),
733 d(8),
734 d(6)
735 ));
736 for r in &rows {
737 log::info(&format!(
738 "{:<15} {:>6} {:>5.1}% {:>7} {:>5.1}% {:>8} {:>5.1}%",
739 r.speaker,
740 r.lines,
741 r.pct_lines,
742 commas(r.words),
743 r.pct_words,
744 commas(r.chars),
745 r.pct_chars
746 ));
747 }
748 log::info(&format!(
749 "{} {} {} {} {} {} {}",
750 d(15),
751 d(6),
752 d(6),
753 d(7),
754 d(6),
755 d(8),
756 d(6)
757 ));
758 log::info(&format!(
759 "{:<15} {:>6} {:>7} {:>8}",
760 "TOTAL",
761 tl,
762 commas(tw),
763 commas(tc)
764 ));
765 log::info("");
766}
767
768fn print_summary(parsed: &Parsed, json: &Value) {
769 let stats = &json["stats"];
770 let tag = episode_tag(parsed.season, parsed.episode);
771 let bar = "=".repeat(60);
772 log::info(&format!("\n{bar}"));
773 log::info(&format!("PARSED: {} {tag} — {}", parsed.show, parsed.title));
774 log::info(&format!("Source: {}", parsed.source_file));
775 log::info(&bar);
776 log::info(&format!(" Total entries: {}", stats["total_entries"]));
777 log::info(&format!(
778 " Dialogue lines: {}",
779 stats["dialogue_lines"]
780 ));
781 log::info(&format!(
782 " Stage directions: {}",
783 stats["direction_lines"]
784 ));
785 log::info(&format!(
786 " TTS characters: {}",
787 commas(stats["characters_for_tts"].as_u64().unwrap_or(0) as usize)
788 ));
789 log::info(&format!(
790 " Speakers: {}",
791 join_strs(&stats["speakers"])
792 ));
793 log::info(&format!(
794 " Sections: {}",
795 join_strs(&stats["sections"])
796 ));
797 log::info(&bar);
798 print_speaker_stats(parsed);
799}
800
801fn join_strs(v: &Value) -> String {
802 v.as_array()
803 .map(|a| {
804 a.iter()
805 .filter_map(Value::as_str)
806 .collect::<Vec<_>>()
807 .join(", ")
808 })
809 .unwrap_or_default()
810}
811
812fn print_dialogue_preview(parsed: &Parsed, limit: Option<usize>) {
813 let all: Vec<&Entry> = parsed
814 .entries
815 .iter()
816 .filter(|e| e.kind == "dialogue")
817 .collect();
818 let shown = match limit.filter(|l| *l > 0) {
819 Some(l) => &all[..l.min(all.len())],
820 None => &all[..],
821 };
822 log::info(&format!(
823 "\n--- Dialogue Preview ({} lines) ---\n",
824 shown.len()
825 ));
826 for e in shown {
827 let scene_label = e
828 .scene
829 .clone()
830 .or_else(|| e.section.clone())
831 .unwrap_or_else(|| "?".to_string());
832 let direction_label = e
833 .direction
834 .as_ref()
835 .map(|d| format!(" ({d})"))
836 .unwrap_or_default();
837 let text_preview = if e.text.chars().count() > 80 {
838 format!("{}...", e.text.chars().take(80).collect::<String>())
839 } else {
840 e.text.clone()
841 };
842 log::info(&format!(
843 " {:03} | {scene_label:<16} | {:<14}{direction_label}",
844 e.seq,
845 e.speaker.clone().unwrap_or_default()
846 ));
847 log::info(&format!(" {text_preview}"));
848 log::info("");
849 }
850}
851
852fn execute(a: &Args) -> anyhow::Result<i32> {
855 let mut parsed = run_parse(&a.script, a.speakers.as_deref())?;
856
857 let tag = match &a.tag {
858 Some(t) => t.clone(),
859 None => {
860 let derived = episode_tag(parsed.season, parsed.episode);
861 if let Some(ep) = &a.episode {
862 if ep != &derived {
863 log::error(&format!(
864 "Script header indicates {derived} but --episode {ep} was specified"
865 ));
866 return Ok(1);
867 }
868 }
869 derived
870 }
871 };
872
873 if let Some(s) = &a.show {
874 parsed.show = s.clone();
875 }
876 let slug = {
877 let from_header = show_slug(&parsed.show);
878 if from_header.is_empty() {
879 resolve_slug(a.show.as_deref(), "project.json")
880 } else {
881 from_header
882 }
883 };
884 let paths = derive_paths(&slug, &tag);
885 let output = a.output.clone().unwrap_or_else(|| paths["parsed"].clone());
886 if let Some(d) = output.parent() {
887 if !d.as_os_str().is_empty() {
888 fs::create_dir_all(d)?;
889 }
890 }
891
892 if a.debug {
893 let csv_path = with_extension_csv(&output);
896 parsed = run_parse(&a.script, a.speakers.as_deref())?;
897 if let Some(s) = &a.show {
898 parsed.show = s.clone();
899 }
900 write_debug_csv(&csv_path, &parsed)?;
901 }
902
903 let json = parsed.to_json();
904 let new_content = dumps(&json, Style::INDENT2_UTF8);
907 let existing = fs::read_to_string(&output).ok();
908 if existing.as_deref() == Some(new_content.as_str()) {
909 log::info("Parsed output unchanged — skipping write (mtime preserved)");
910 } else {
911 let mut f = fs::File::create(&output)?;
912 f.write_all(new_content.as_bytes())?;
913 }
914
915 if !a.quiet {
916 print_summary(&parsed, &json);
917 print_dialogue_preview(&parsed, a.preview);
918 log::info(&format!("JSON written to: {}", output.display()));
919 if a.debug {
920 log::info(&format!(
921 "Debug CSV written to: {}",
922 with_extension_csv(&output).display()
923 ));
924 }
925 }
926 if a.stats && a.quiet {
927 print_speaker_stats(&parsed);
928 }
929
930 let trigger = a.tag.as_ref().or(a.episode.as_ref());
931 if trigger.is_none() {
932 log::warning(&format!(
933 "No --episode tag given — cast and SFX skeleton configs were NOT created. \
934 Re-run with --episode {tag} to generate them."
935 ));
936 return Ok(0);
937 }
938
939 let cast_path = &paths["cast"];
940 let sfx_path = &paths["sfx"];
941 if !cast_path.exists() {
942 let registry = load_speakers_registry(a.speakers.as_deref());
943 let module_speakers = load_speakers(a.speakers.as_deref(), &[]);
946 generate_cast_config(
947 &json,
948 cast_path,
949 a.tag.as_deref(),
950 ®istry,
951 &key_to_display(&module_speakers),
952 )?;
953 }
954 if !sfx_path.exists() {
955 generate_sfx_config(&json, sfx_path, a.tag.as_deref())?;
956 } else {
957 backfill_sfx_sources(&json, sfx_path, false)?;
958 }
959 Ok(0)
960}
961
962fn with_extension_csv(p: &Path) -> PathBuf {
964 let s = p.to_string_lossy();
965 let stem = match (s.rfind('.'), s.rfind('/')) {
966 (Some(dot), Some(slash)) if dot > slash + 1 => &s[..dot],
967 (Some(dot), None) if dot > 0 => &s[..dot],
968 _ => &s[..],
969 };
970 PathBuf::from(format!("{stem}.csv"))
971}
972
973pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
974 log::init("parse");
975 let _banner = banner::begin(super::prog(), &super::argv_line(args));
976 let a: Args = match super::parse_or_exit("xil-parse", args) {
977 Ok(a) => a,
978 Err(code) => return Ok(code),
979 };
980 execute(&a)
981}
982
983pub fn command() -> clap::Command {
985 <Args as clap::CommandFactory>::command()
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn title_case_matches_python() {
994 assert_eq!(title_case("mr patterson"), "Mr Patterson");
995 assert_eq!(title_case("NORA WALSH"), "Nora Walsh");
996 assert_eq!(title_case("t-bone"), "T-Bone");
997 assert_eq!(title_case("o'brien"), "O'Brien");
998 }
999
1000 #[test]
1001 fn thousands_separators() {
1002 assert_eq!(commas(0), "0");
1003 assert_eq!(commas(999), "999");
1004 assert_eq!(commas(1000), "1,000");
1005 assert_eq!(commas(1234567), "1,234,567");
1006 }
1007
1008 #[test]
1009 fn csv_path_swaps_the_extension() {
1010 assert_eq!(
1011 with_extension_csv(Path::new("parsed/s/parsed_S01E01.json")),
1012 Path::new("parsed/s/parsed_S01E01.csv")
1013 );
1014 assert_eq!(
1015 with_extension_csv(Path::new("noext")),
1016 Path::new("noext.csv")
1017 );
1018 }
1019
1020 #[test]
1021 fn or_default_treats_falsy_as_absent() {
1022 assert_eq!(or_default(None, Value::from("d")), Value::from("d"));
1023 assert_eq!(
1024 or_default(Some(&Value::Null), Value::from("d")),
1025 Value::from("d")
1026 );
1027 assert_eq!(
1028 or_default(Some(&Value::from("")), Value::from("d")),
1029 Value::from("d")
1030 );
1031 assert_eq!(
1032 or_default(Some(&Value::from("x")), Value::from("d")),
1033 Value::from("x")
1034 );
1035 }
1036
1037 #[test]
1038 fn beat_durations_come_from_the_text() {
1039 assert_eq!(
1040 BEAT_SECONDS.captures("BEAT — 3 SECONDS").unwrap()[1]
1041 .parse::<f64>()
1042 .unwrap(),
1043 3.0
1044 );
1045 assert_eq!(
1046 BEAT_SECONDS.captures("BEAT — LONG, 5 SECONDS").unwrap()[1]
1047 .parse::<f64>()
1048 .unwrap(),
1049 5.0
1050 );
1051 assert!(BEAT_SECONDS.captures("BEAT — LONG").is_none());
1052 }
1053
1054 #[test]
1055 fn play_duration_hint_clears_duration_on_source_cues_only() {
1056 let mut o = IndexMap::new();
1057 o.insert("play_duration".to_string(), 35.0);
1058
1059 let mut sourced = Map::new();
1060 sourced.insert("source".into(), Value::from("SFX/a.mp3"));
1061 sourced.insert("duration_seconds".into(), json_f64(5.0));
1062 apply_sfx_overrides("MUSIC: X", &mut sourced, &o);
1063 assert!(!sourced.contains_key("duration_seconds"));
1064 assert_eq!(sourced["play_duration"], 35.0);
1065
1066 let mut generated = Map::new();
1067 generated.insert("prompt".into(), Value::from("MUSIC: X"));
1068 generated.insert("duration_seconds".into(), json_f64(15.0));
1069 apply_sfx_overrides("MUSIC: X", &mut generated, &o);
1070 assert_eq!(
1071 generated["duration_seconds"], 15.0,
1072 "generation length is not a trim"
1073 );
1074 }
1075}