1use std::ffi::OsString;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use serde_json::{Map, Value};
11use xil_core::fsutil::{basename, pathlib_glob, sort_py};
12use xil_core::pycsv;
13use xil_core::pyfmt::{fixed, head, html_escape, pad_left, pad_right, round_to};
14use xil_core::pyjson::py_float;
15use xil_core::workspace::{resolve_slug, workspace_root};
16use xil_core::{banner, log};
17
18const SCRIPT_NAME: &str = "XILU021_sfx_impact";
19
20const NOCHANGE_S: f64 = 0.1;
22const MINOR_S: f64 = 3.0;
23
24pub const TIERS: [&str; 5] = ["1-nochange", "2-minor", "3-review", "EXCLUDED", "MISSING"];
25
26const CSV_COLUMNS: [&str; 15] = [
27 "show",
28 "episode",
29 "cue",
30 "source_file",
31 "duration_seconds",
32 "play_duration",
33 "loop",
34 "natural_s",
35 "plays_now_s",
36 "delta_s",
37 "lost_pct",
38 "tier",
39 "placement",
40 "remediation",
41 "note",
42];
43
44#[derive(Parser)]
45#[command(
46 name = "xil-sfx-impact",
47 about = "Report which source-backed SFX cues are clipped by duration_seconds, how much audio each loses, and the config change that would restore it. Read-only — no config is ever modified."
48)]
49struct Args {
50 #[arg(long)]
52 show: Option<String>,
53 #[arg(long, alias = "tag")]
55 episode: Option<String>,
56 #[arg(long)]
58 output: Option<String>,
59 #[arg(long, num_args = 0..=1, default_missing_value = "")]
61 html: Option<String>,
62 #[arg(long, value_parser = ["2-minor", "3-review", "actionable"])]
64 tier: Option<String>,
65 #[arg(long)]
67 quiet: bool,
68}
69
70#[derive(Debug, Clone)]
72pub struct CueImpact {
73 pub show: String,
74 pub episode: String,
75 pub cue: String,
76 pub source_file: String,
77 pub duration_seconds: Value,
80 pub play_duration: Value,
81 pub loop_: bool,
82 pub natural_s: Option<f64>,
83 pub plays_now_s: Option<f64>,
84 pub delta_s: Option<f64>,
85 pub lost_pct: Option<f64>,
86 pub tier: String,
87 pub placement: String,
88 pub remediation: String,
89 pub note: String,
90}
91
92impl CueImpact {
93 fn row(&self) -> Map<String, Value> {
95 let opt = |v: Option<f64>| match v {
96 Some(x) => py_float(round_to(x, 1)),
97 None => Value::String(String::new()),
98 };
99 let mut m = Map::new();
100 m.insert("show".into(), self.show.clone().into());
101 m.insert("episode".into(), self.episode.clone().into());
102 m.insert("cue".into(), self.cue.clone().into());
103 m.insert("source_file".into(), self.source_file.clone().into());
104 m.insert("duration_seconds".into(), self.duration_seconds.clone());
105 m.insert("play_duration".into(), self.play_duration.clone());
106 m.insert("loop".into(), Value::Bool(self.loop_));
107 m.insert("natural_s".into(), opt(self.natural_s));
108 m.insert("plays_now_s".into(), opt(self.plays_now_s));
109 m.insert("delta_s".into(), opt(self.delta_s));
110 m.insert("lost_pct".into(), opt(self.lost_pct));
111 m.insert("tier".into(), self.tier.clone().into());
112 m.insert("placement".into(), self.placement.clone().into());
113 m.insert("remediation".into(), self.remediation.clone().into());
114 m.insert("note".into(), self.note.clone().into());
115 m
116 }
117}
118
119#[derive(Default)]
120pub struct ImpactReport {
121 pub impacts: Vec<CueImpact>,
122 pub configs_scanned: usize,
123}
124
125impl ImpactReport {
126 fn actionable(&self) -> Vec<&CueImpact> {
127 self.impacts
128 .iter()
129 .filter(|i| i.tier == "2-minor" || i.tier == "3-review")
130 .collect()
131 }
132
133 fn tally(&self) -> indexmap::IndexMap<String, usize> {
134 let mut counts: indexmap::IndexMap<String, usize> =
135 TIERS.iter().map(|t| (t.to_string(), 0)).collect();
136 for i in &self.impacts {
137 *counts.entry(i.tier.clone()).or_insert(0) += 1;
138 }
139 counts
140 }
141
142 fn by_show(&self) -> indexmap::IndexMap<String, indexmap::IndexMap<String, usize>> {
143 let mut out: indexmap::IndexMap<String, indexmap::IndexMap<String, usize>> =
144 indexmap::IndexMap::new();
145 for i in &self.impacts {
146 let tiers = out
147 .entry(i.show.clone())
148 .or_insert_with(|| TIERS.iter().map(|t| (t.to_string(), 0)).collect());
149 *tiers.entry(i.tier.clone()).or_insert(0) += 1;
150 }
151 out
152 }
153}
154
155pub fn classify_placement(cue: &str) -> &'static str {
157 let key = cue.to_uppercase();
158 let key = key.trim();
159 let before_colon = key.split(':').next().unwrap_or("");
160 if key.starts_with("MUSIC")
161 || key.starts_with("INTRO MUSIC")
162 || key.starts_with("OUTRO MUSIC")
163 || before_colon.contains(" MUSIC")
164 {
165 "MUSIC(bg)"
166 } else if key.starts_with("AMBIEN") {
167 "AMBI(bg)"
168 } else if key.starts_with("BEAT") {
169 "BEAT(fg)"
170 } else {
171 "SFX(fg)"
172 }
173}
174
175fn tier_for(delta_s: f64) -> &'static str {
176 if delta_s < NOCHANGE_S {
177 "1-nochange"
178 } else if delta_s < MINOR_S {
179 "2-minor"
180 } else {
181 "3-review"
182 }
183}
184
185fn as_float(v: &Value) -> Option<f64> {
187 match v {
188 Value::Number(n) => n.as_f64(),
189 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
190 Value::String(s) => s.trim().parse().ok(),
191 _ => None,
192 }
193}
194
195pub type DurationProbe<'a> = &'a dyn Fn(&Path) -> Result<i64, String>;
197
198pub fn measure_cue(
200 show: &str,
201 episode: &str,
202 cue: &str,
203 effect: &Map<String, Value>,
204 duration_fn: DurationProbe,
205 workspace: &Path,
206) -> Option<CueImpact> {
207 let source = effect.get("source").filter(|v| super::truthy(v))?;
208 let source = xil_core::workspace::python_str(source);
209
210 let mut impact = CueImpact {
211 show: show.to_string(),
212 episode: episode.to_string(),
213 cue: cue.to_string(),
214 source_file: basename(Path::new(&source)),
215 duration_seconds: effect
216 .get("duration_seconds")
217 .cloned()
218 .unwrap_or(Value::Null),
219 play_duration: effect.get("play_duration").cloned().unwrap_or(Value::Null),
220 loop_: effect.get("loop").is_some_and(super::truthy),
221 natural_s: None,
222 plays_now_s: None,
223 delta_s: None,
224 lost_pct: None,
225 tier: "EXCLUDED".into(),
226 placement: classify_placement(cue).into(),
227 remediation: String::new(),
228 note: String::new(),
229 };
230
231 let mut path = PathBuf::from(&source);
232 if !path.is_absolute() {
233 path = workspace.join(&source);
234 }
235 let natural = match duration_fn(&path) {
236 Ok(ms) => ms as f64 / 1000.0,
237 Err(name) => {
238 impact.tier = "MISSING".into();
239 impact.note = format!("source file unreadable ({name})");
240 return Some(impact);
241 }
242 };
243 if natural <= 0.0 {
244 impact.tier = "MISSING".into();
245 impact.note = "source file reports zero duration".into();
246 return Some(impact);
247 }
248 impact.natural_s = Some(natural);
249
250 let play_duration = if impact.play_duration.is_null() {
251 None
252 } else {
253 Some(as_float(&impact.play_duration).unwrap_or(0.0))
254 };
255 let duration_seconds = if impact.duration_seconds.is_null() {
256 None
257 } else {
258 Some(as_float(&impact.duration_seconds).unwrap_or(0.0))
259 };
260
261 let plays_now = if impact.loop_ {
262 impact.note = "looped bed (fills cue span; not clipped)".into();
263 natural
264 } else if let Some(pd) = play_duration {
265 impact.note = "explicit play_duration kept (takes precedence)".into();
266 natural * pd / 100.0
267 } else if duration_seconds.map_or(true, |d| d <= 0.0) {
268 impact.note = "plays full length (duration_seconds 0 or absent)".into();
269 natural
270 } else {
271 let d = duration_seconds.unwrap_or(0.0);
272 let p = d.min(natural);
273 impact.tier = tier_for(natural - p).into();
274 impact.note = String::new();
275 p
276 };
277 impact.plays_now_s = Some(plays_now);
278 let delta = (natural - plays_now).max(0.0);
279 impact.delta_s = Some(delta);
280 impact.lost_pct = Some(delta / natural * 100.0);
281 impact.remediation = if matches!(impact.tier.as_str(), "1-nochange" | "EXCLUDED" | "MISSING") {
282 String::new()
283 } else {
284 "play_duration: 100".into()
285 };
286 Some(impact)
287}
288
289pub fn discover_configs(
291 workspace: &Path,
292 show: Option<&str>,
293 episode: Option<&str>,
294) -> Vec<(String, String, PathBuf)> {
295 let configs_dir = workspace.join("configs");
296 if !configs_dir.is_dir() {
297 return Vec::new();
298 }
299 let mut show_dirs: Vec<PathBuf> = fs::read_dir(&configs_dir)
300 .map(|rd| rd.filter_map(Result::ok).map(|e| e.path()).collect())
301 .unwrap_or_default();
302 sort_py(&mut show_dirs);
303 let (prefix, suffix) = match episode {
304 Some(tag) => (format!("sfx_{tag}"), ".json"),
305 None => ("sfx_".to_string(), ".json"),
306 };
307 let mut found = Vec::new();
308 for show_dir in show_dirs {
309 let name = basename(&show_dir);
310 if !show_dir.is_dir() || show.is_some_and(|s| s != name) {
311 continue;
312 }
313 let mut paths = pathlib_glob(&show_dir, &prefix, suffix);
314 if episode.is_some() {
315 paths.retain(|p| basename(p) == format!("{prefix}{suffix}"));
317 }
318 for path in paths {
319 let stem = basename(&path);
320 let stem = stem.strip_suffix(".json").unwrap_or(&stem);
321 let tag = &stem["sfx_".len()..];
322 if tag.is_empty() {
323 continue;
324 }
325 found.push((name.clone(), tag.to_string(), path));
326 }
327 }
328 found
329}
330
331fn mutagen_probe(path: &Path) -> Result<i64, String> {
332 xil_audio::mpeg::duration_ms(path).map_err(|e| e.python_name().to_string())
333}
334
335pub fn analyze(workspace: &Path, show: Option<&str>, episode: Option<&str>) -> ImpactReport {
336 let mut report = ImpactReport::default();
337 for (slug, tag, path) in discover_configs(workspace, show, episode) {
338 let data: Value = match fs::read_to_string(&path)
339 .map_err(|e| e.to_string())
340 .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
341 {
342 Ok(v) => v,
343 Err(e) => {
344 log::warning(&format!(
345 " Skipping unreadable config {}: {e}",
346 path.display()
347 ));
348 continue;
349 }
350 };
351 report.configs_scanned += 1;
352 let effects = data
353 .get("effects")
354 .and_then(Value::as_object)
355 .cloned()
356 .unwrap_or_default();
357 for (cue, effect) in &effects {
358 let Some(effect) = effect.as_object() else {
359 continue;
360 };
361 if let Some(i) = measure_cue(&slug, &tag, cue, effect, &mutagen_probe, workspace) {
362 report.impacts.push(i);
363 }
364 }
365 }
366 report
367}
368
369fn write_csv<W: Write>(report: &ImpactReport, out: &mut W) -> std::io::Result<()> {
370 let rows: Vec<Map<String, Value>> = report.impacts.iter().map(CueImpact::row).collect();
371 pycsv::write_dicts(out, &CSV_COLUMNS, &rows)
372}
373
374fn log_summary(report: &ImpactReport) {
375 let tally = report.tally();
376 log::info("");
377 log::info(&format!(
378 " Scanned {} SFX config(s), {} source-backed cue(s)",
379 report.configs_scanned,
380 report.impacts.len()
381 ));
382 log::info("");
383 let header = format!(
384 " {} {} {} {} {} {}",
385 pad_right("show", 24),
386 pad_left("3-review", 9),
387 pad_left("2-minor", 8),
388 pad_left("1-nochange", 11),
389 pad_left("EXCLUDED", 9),
390 pad_left("MISSING", 8)
391 );
392 let rule = format!(" {}", "-".repeat(header.chars().count() - 2));
393 let line = |name: &str, t: &indexmap::IndexMap<String, usize>| {
394 format!(
395 " {} {} {} {} {} {}",
396 pad_right(name, 24),
397 pad_left(&t["3-review"].to_string(), 9),
398 pad_left(&t["2-minor"].to_string(), 8),
399 pad_left(&t["1-nochange"].to_string(), 11),
400 pad_left(&t["EXCLUDED"].to_string(), 9),
401 pad_left(&t["MISSING"].to_string(), 8)
402 )
403 };
404 log::info(&header);
405 log::info(&rule);
406 let mut shows: Vec<(String, indexmap::IndexMap<String, usize>)> =
407 report.by_show().into_iter().collect();
408 shows.sort_by(|a, b| a.0.cmp(&b.0));
409 for (slug, tiers) in &shows {
410 log::info(&line(slug, tiers));
411 }
412 log::info(&rule);
413 log::info(&line("TOTAL", &tally));
414
415 let actionable = report.actionable();
416 if actionable.is_empty() {
417 log::info("");
418 log::info(" No cues are losing audio — nothing to review.");
419 return;
420 }
421 let lost: f64 = actionable.iter().map(|i| i.delta_s.unwrap_or(0.0)).sum();
422 log::info("");
423 log::info(&format!(
424 " {} cue(s) lose audio, {}s total",
425 actionable.len(),
426 fixed(lost, 0)
427 ));
428 log::info("");
429 log::info(" Worst offenders:");
430 let mut worst = actionable.clone();
431 worst.sort_by(|a, b| {
433 b.delta_s
434 .unwrap_or(0.0)
435 .partial_cmp(&a.delta_s.unwrap_or(0.0))
436 .unwrap_or(std::cmp::Ordering::Equal)
437 });
438 for i in worst.iter().take(10) {
439 log::info(&format!(
440 " {}s lost {}/{} {} ({}s of {}s)",
441 pad_left(&fixed(i.delta_s.unwrap_or(0.0), 1), 6),
442 i.show,
443 i.episode,
444 head(&i.cue, 44),
445 fixed(i.plays_now_s.unwrap_or(0.0), 1),
446 fixed(i.natural_s.unwrap_or(0.0), 1)
447 ));
448 }
449}
450
451const HTML_CSS: &str = r#"
452:root { color-scheme: light dark; }
453body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0;
454 padding: 2rem 1.25rem; line-height: 1.5; background: #fff; color: #1a1a1a; }
455h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
456.sub { color: #666; margin: 0 0 1.5rem; font-size: .9rem; }
457.cards { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: 1.75rem; }
458.card { border: 1px solid #e0e0e0; border-radius: 8px; padding: .75rem 1rem; min-width: 8rem; }
459.card .n { font-size: 1.5rem; font-weight: 600; }
460.card .l { font-size: .75rem; color: #666; text-transform: uppercase; letter-spacing: .04em; }
461.wrap { overflow-x: auto; }
462table { border-collapse: collapse; width: 100%; font-size: .85rem; }
463th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ececec;
464 white-space: nowrap; }
465th { position: sticky; top: 0; background: #fafafa; font-weight: 600; }
466td.num { text-align: right; font-variant-numeric: tabular-nums; }
467td.cue { white-space: normal; min-width: 16rem; }
468.t3 { color: #b3261e; font-weight: 600; }
469.t2 { color: #9a6700; font-weight: 600; }
470.t1, .tE { color: #888; }
471.tM { color: #b3261e; }
472code { background: #f3f3f3; padding: .1rem .3rem; border-radius: 3px; font-size: .9em; }
473@media (prefers-color-scheme: dark) {
474 body { background: #14161a; color: #e6e6e6; }
475 .card, th, td { border-color: #2c2f36; }
476 th { background: #1b1e24; }
477 .sub, .card .l, .t1, .tE { color: #9aa0a6; }
478 code { background: #23262c; }
479 .t3, .tM { color: #ff8a80; }
480 .t2 { color: #ffd180; }
481}
482"#;
483
484fn tier_class(tier: &str) -> &'static str {
485 match tier {
486 "3-review" => "t3",
487 "2-minor" => "t2",
488 "1-nochange" => "t1",
489 "EXCLUDED" => "tE",
490 "MISSING" => "tM",
491 _ => "",
492 }
493}
494
495fn generated_stamp() -> String {
497 let now = chrono::Local::now();
498 format!("{} {}", now.format("%Y-%m-%d %H:%M"), tz_abbreviation())
499}
500
501fn tz_abbreviation() -> String {
504 std::process::Command::new("date")
505 .arg("+%Z")
506 .output()
507 .ok()
508 .filter(|o| o.status.success())
509 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
510 .filter(|s| !s.is_empty())
511 .unwrap_or_else(|| chrono::Local::now().format("%:z").to_string())
512}
513
514fn render_html(report: &ImpactReport, scope: &str) -> String {
515 let tally = report.tally();
516 let actionable = report.actionable();
517 let lost: f64 = actionable.iter().map(|i| i.delta_s.unwrap_or(0.0)).sum();
518 let generated = generated_stamp();
519
520 let cards: Vec<(&str, String)> = vec![
521 ("3-review", tally["3-review"].to_string()),
522 ("2-minor", tally["2-minor"].to_string()),
523 ("1-nochange", tally["1-nochange"].to_string()),
524 ("excluded", tally["EXCLUDED"].to_string()),
525 ("missing", tally["MISSING"].to_string()),
526 ("seconds lost", fixed(lost, 0)),
527 ];
528 let card_html: String = cards
529 .iter()
530 .map(|(label, n)| {
531 format!(
532 "<div class=\"card\"><div class=\"n\">{}</div><div class=\"l\">{}</div></div>",
533 html_escape(n),
534 html_escape(label)
535 )
536 })
537 .collect();
538
539 let order = |t: &str| match t {
540 "3-review" => 0,
541 "2-minor" => 1,
542 "MISSING" => 2,
543 "1-nochange" => 3,
544 "EXCLUDED" => 4,
545 _ => 9,
546 };
547 let mut rows: Vec<&CueImpact> = report.impacts.iter().collect();
548 rows.sort_by(|a, b| {
549 order(&a.tier)
550 .cmp(&order(&b.tier))
551 .then_with(|| {
552 (-b.delta_s.unwrap_or(0.0))
553 .partial_cmp(&(-a.delta_s.unwrap_or(0.0)))
554 .unwrap_or(std::cmp::Ordering::Equal)
555 .reverse()
556 })
557 .then_with(|| a.show.cmp(&b.show))
558 .then_with(|| a.episode.cmp(&b.episode))
559 });
560
561 let text_cell = |v: &str| {
562 if v.is_empty() {
563 "<td></td>".to_string()
564 } else {
565 format!("<td class=\"\">{}</td>", html_escape(v))
566 }
567 };
568 let num_cell = |v: Option<f64>| match v {
569 None => "<td class=\"num\"></td>".to_string(),
570 Some(x) => format!("<td class=\"num\">{}</td>", html_escape(&fixed(x, 1))),
571 };
572
573 let mut body_rows = String::new();
574 for i in rows {
575 body_rows.push_str("<tr>");
576 body_rows.push_str(&text_cell(&i.show));
577 body_rows.push_str(&text_cell(&i.episode));
578 body_rows.push_str(&format!("<td class=\"cue\">{}</td>", html_escape(&i.cue)));
579 body_rows.push_str(&text_cell(&i.source_file));
580 body_rows.push_str(&text_cell(&i.placement));
581 body_rows.push_str(&num_cell(i.natural_s));
582 body_rows.push_str(&num_cell(i.plays_now_s));
583 body_rows.push_str(&num_cell(i.delta_s));
584 body_rows.push_str(&num_cell(i.lost_pct));
585 body_rows.push_str(&format!(
586 "<td class=\"{}\">{}</td>",
587 tier_class(&i.tier),
588 html_escape(&i.tier)
589 ));
590 if i.remediation.is_empty() {
591 body_rows.push_str("<td></td>");
592 } else {
593 body_rows.push_str(&format!(
594 "<td><code>{}</code></td>",
595 html_escape(&i.remediation)
596 ));
597 }
598 body_rows.push_str(&format!("<td>{}</td>", html_escape(&i.note)));
599 body_rows.push_str("</tr>");
600 }
601
602 let headers = [
603 "show",
604 "episode",
605 "cue",
606 "source file",
607 "placement",
608 "natural s",
609 "plays now s",
610 "lost s",
611 "lost %",
612 "tier",
613 "remediation",
614 "note",
615 ];
616 let head_html: String = headers
617 .iter()
618 .map(|h| format!("<th>{}</th>", html_escape(h)))
619 .collect();
620
621 format!(
622 r#"<!doctype html>
623<html lang="en"><head><meta charset="utf-8">
624<meta name="viewport" content="width=device-width, initial-scale=1">
625<title>SFX clipping impact — {scope}</title>
626<style>{css}</style></head>
627<body>
628<h1>SFX source-clipping impact</h1>
629<p class="sub">{scope} · {configs} config(s) ·
630{cues} source-backed cue(s) · generated {generated}</p>
631<div class="cards">{card_html}</div>
632<p class="sub"><strong>How to read this:</strong> for a <code>source=</code> cue,
633<code>duration_seconds</code> clips the file at mix time — the parser writes a default of
634<code>5.0</code> into every skeleton entry. <em>Excluded</em> cues are looped beds or cues with a
635deliberate <code>play_duration</code>, which are never clipped by <code>duration_seconds</code>.
636Nothing here has been changed; the remediation column is the edit that would restore full length.</p>
637<p class="sub"><strong>Why <code>play_duration: 100</code> and not <code>duration_seconds: 0</code>?</strong>
638Both play the whole file, but only <code>play_duration</code> is replayed by the timeline edit
639journal. A <code>duration_seconds</code> edit is silently reset to <code>5.0</code> the next time the
640config is rebuilt from a skeleton — re-clipping a cue that was already approved.</p>
641<div class="wrap"><table><thead><tr>{head_html}</tr></thead>
642<tbody>{body_rows}</tbody></table></div>
643</body></html>
644"#,
645 scope = html_escape(scope),
646 css = HTML_CSS,
647 configs = report.configs_scanned,
648 cues = report.impacts.len(),
649 generated = html_escape(&generated),
650 )
651}
652
653fn filter_tier(report: ImpactReport, tier: Option<&str>) -> ImpactReport {
654 let Some(tier) = tier else {
655 return report;
656 };
657 let wanted: &[&str] = if tier == "actionable" {
658 &["2-minor", "3-review"]
659 } else {
660 std::slice::from_ref(&tier)
661 };
662 ImpactReport {
663 impacts: report
664 .impacts
665 .into_iter()
666 .filter(|i| wanted.contains(&i.tier.as_str()))
667 .collect(),
668 configs_scanned: report.configs_scanned,
669 }
670}
671
672fn execute(a: &Args) -> anyhow::Result<i32> {
673 let workspace = workspace_root();
674 let show = match (&a.show, &a.episode) {
676 (None, Some(_)) => Some(resolve_slug(None, "project.json")),
677 (s, _) => s.clone(),
678 };
679 let to_stdout = a.output.as_deref() == Some("-");
680
681 let report = analyze(&workspace, show.as_deref(), a.episode.as_deref());
682 if report.configs_scanned == 0 {
683 let scope = match &show {
684 Some(s) => format!("show={s}"),
685 None => "workspace".to_string(),
686 };
687 let msg = format!(
688 "No SFX configs found ({scope}) under {}",
689 workspace.join("configs").display()
690 );
691 if to_stdout {
692 eprintln!("{msg}");
693 } else {
694 log::error(&msg);
695 }
696 return Ok(1);
697 }
698
699 let report = filter_tier(report, a.tier.as_deref());
700 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
701
702 if to_stdout {
703 let mut out = std::io::stdout().lock();
704 write_csv(&report, &mut out)?;
705 out.flush()?;
706 } else {
707 let out_path = match &a.output {
708 Some(p) => PathBuf::from(p),
709 None => workspace
710 .join("reports")
711 .join(format!("sfx_impact_{date}.csv")),
712 };
713 if let Some(parent) = out_path.parent() {
714 fs::create_dir_all(parent)?;
715 }
716 let mut f = fs::File::create(&out_path)?;
717 write_csv(&report, &mut f)?;
718 log::info(&format!(
719 " Wrote {} ({} row(s))",
720 out_path.display(),
721 report.impacts.len()
722 ));
723 }
724
725 if let Some(html) = &a.html {
726 let html_path = if html.is_empty() {
727 workspace
728 .join("reports")
729 .join(format!("sfx_impact_{date}.html"))
730 } else {
731 PathBuf::from(html)
732 };
733 if let Some(parent) = html_path.parent() {
734 fs::create_dir_all(parent)?;
735 }
736 let mut scope = show.clone().unwrap_or_else(|| "all shows".to_string());
737 if let Some(ep) = &a.episode {
738 scope = format!("{scope} · {ep}");
739 }
740 fs::write(&html_path, render_html(&report, &scope))?;
741 if to_stdout {
742 eprintln!("Wrote {}", html_path.display());
743 } else {
744 log::info(&format!(" Wrote {}", html_path.display()));
745 }
746 }
747
748 if !a.quiet && !to_stdout {
749 log_summary(&report);
750 }
751 Ok(0)
752}
753
754pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
755 log::init("sfx-impact");
756 let a: Args = match super::parse_or_exit("xil-sfx-impact", args) {
757 Ok(a) => a,
758 Err(code) => return Ok(code),
759 };
760 if a.output.as_deref() == Some("-") {
762 execute(&a)
763 } else {
764 let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
765 execute(&a)
766 }
767}
768
769pub fn command() -> clap::Command {
771 <Args as clap::CommandFactory>::command()
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use serde_json::json;
778
779 fn probe_ms(ms: i64) -> impl Fn(&Path) -> Result<i64, String> {
780 move |_| Ok(ms)
781 }
782
783 fn measure(effect: Value, ms: i64) -> CueImpact {
784 let f = probe_ms(ms);
785 measure_cue(
786 "s",
787 "E1",
788 "SFX: X",
789 effect.as_object().unwrap(),
790 &f,
791 Path::new("/ws"),
792 )
793 .unwrap()
794 }
795
796 #[test]
797 fn placement_buckets() {
798 assert_eq!(classify_placement("OUTRO MUSIC"), "MUSIC(bg)");
799 assert_eq!(classify_placement("MUSIC: THEME"), "MUSIC(bg)");
800 assert_eq!(classify_placement("sting music: x"), "MUSIC(bg)");
801 assert_eq!(classify_placement("AMBIENCE: RAIN"), "AMBI(bg)");
802 assert_eq!(classify_placement("BEAT"), "BEAT(fg)");
803 assert_eq!(classify_placement("SFX: DOOR: MUSIC ROOM"), "SFX(fg)");
804 }
805
806 #[test]
807 fn precedence_mirrors_the_mixer() {
808 let looped = measure(
809 json!({"source": "a.mp3", "loop": true, "duration_seconds": 5.0}),
810 20000,
811 );
812 assert_eq!(looped.tier, "EXCLUDED");
813 assert_eq!(looped.delta_s, Some(0.0));
814
815 let pd = measure(
816 json!({"source": "a.mp3", "play_duration": 50, "duration_seconds": 5.0}),
817 20000,
818 );
819 assert_eq!(pd.tier, "EXCLUDED");
820 assert_eq!(pd.plays_now_s, Some(10.0));
821
822 let clipped = measure(json!({"source": "a.mp3", "duration_seconds": 5.0}), 20000);
823 assert_eq!(clipped.tier, "3-review");
824 assert_eq!(clipped.remediation, "play_duration: 100");
825 assert_eq!(clipped.lost_pct, Some(75.0));
826
827 let short = measure(json!({"source": "a.mp3", "duration_seconds": 5.0}), 1500);
828 assert_eq!(short.tier, "1-nochange");
829 assert_eq!(short.delta_s, Some(0.0));
830
831 let zero = measure(json!({"source": "a.mp3", "duration_seconds": 0}), 1500);
832 assert_eq!(zero.tier, "EXCLUDED");
833 assert!(zero.note.starts_with("plays full length"));
834
835 let f = |_: &Path| Err("HeaderNotFoundError".to_string());
836 let missing = measure_cue(
837 "s",
838 "E1",
839 "SFX: X",
840 json!({"source": "a.mp3"}).as_object().unwrap(),
841 &f,
842 Path::new("/ws"),
843 )
844 .unwrap();
845 assert_eq!(missing.tier, "MISSING");
846 assert_eq!(missing.note, "source file unreadable (HeaderNotFoundError)");
847
848 let none = measure_cue(
849 "s",
850 "E1",
851 "BEAT",
852 json!({"type": "silence"}).as_object().unwrap(),
853 &probe_ms(1),
854 Path::new("/ws"),
855 );
856 assert!(none.is_none());
857 }
858
859 #[test]
860 fn csv_row_rounds_and_blanks() {
861 let i = measure(json!({"source": "x/a.mp3", "duration_seconds": 5.0}), 12345);
862 let r = i.row();
863 assert_eq!(pycsv::cell(&r["natural_s"]), "12.3");
864 assert_eq!(pycsv::cell(&r["delta_s"]), "7.3");
865 assert_eq!(pycsv::cell(&r["lost_pct"]), "59.5");
866 assert_eq!(pycsv::cell(&r["play_duration"]), "");
867 assert_eq!(pycsv::cell(&r["loop"]), "False");
868 assert_eq!(pycsv::cell(&r["duration_seconds"]), "5.0");
869 assert_eq!(r["source_file"], "a.mp3");
870 }
871}