Skip to main content

xil/cmd/
sfx_impact.rs

1//! `xil sfx-impact` — which source-backed cues are clipped short by
2//! `duration_seconds`, and by how much. Port of `XILU021_sfx_impact.py`.
3
4use 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
20/// Tier thresholds in seconds of lost audio.
21const 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    /// Restrict to one show slug (default: every show in the workspace)
51    #[arg(long)]
52    show: Option<String>,
53    /// Restrict to one episode tag (e.g. S01E01)
54    #[arg(long, alias = "tag")]
55    episode: Option<String>,
56    /// CSV output path, or '-' for stdout (default: reports/sfx_impact_<date>.csv)
57    #[arg(long)]
58    output: Option<String>,
59    /// Also write a standalone HTML review page (default path: reports/sfx_impact_<date>.html)
60    #[arg(long, num_args = 0..=1, default_missing_value = "")]
61    html: Option<String>,
62    /// Only report cues at this tier ('actionable' = tiers 2 and 3)
63    #[arg(long, value_parser = ["2-minor", "3-review", "actionable"])]
64    tier: Option<String>,
65    /// Suppress the console summary (CSV only)
66    #[arg(long)]
67    quiet: bool,
68}
69
70/// One source-backed cue measured against its file on disk.
71#[derive(Debug, Clone)]
72pub struct CueImpact {
73    pub show: String,
74    pub episode: String,
75    pub cue: String,
76    pub source_file: String,
77    /// The config values as they are, so the CSV prints them as Python
78    /// would (`5.0`, `50`, empty for absent).
79    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    /// The CSV row, floats rounded to one place.
94    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
155/// Where a cue sits in the mix, from its key.
156pub 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
185/// `float(x)` on a config value the way Python would coerce it.
186fn 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
195/// Duration probe: path → milliseconds, or the Python exception name.
196pub type DurationProbe<'a> = &'a dyn Fn(&Path) -> Result<i64, String>;
197
198/// Measure one entry; `None` when it has no `source`.
199pub 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
289/// Sorted `(show, episode, path)` for every `sfx_<tag>.json` in scope.
290pub 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            // `sfx_<tag>.json` exactly, not `sfx_<tag>x.json`.
316            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    // Stable sort descending, like `sorted(..., reverse=True)`.
432    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
495/// `datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")`.
496fn generated_stamp() -> String {
497    let now = chrono::Local::now();
498    format!("{} {}", now.format("%Y-%m-%d %H:%M"), tz_abbreviation())
499}
500
501/// `%Z` as C `strftime` spells it (`EDT`, `UTC`). chrono only knows the
502/// numeric offset, so ask `date`, and fall back to the offset.
503fn 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    // An explicit --episode with no --show still means "this show".
675    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    // `--output -` makes stdout the CSV stream; the banner would corrupt it.
761    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
769/// The clap definition behind `--help`, for man pages.
770pub 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}