Skip to main content

xil/cmd/
sfx_hydrate.rs

1//! `xil sfx-hydrate` — write pipe-hint fields from a parsed JSON into the
2//! SFX config without re-parsing. Port of `XILU013_sfx_hydrate.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use indexmap::IndexMap;
10use serde_json::{Map, Value};
11use xil_core::fsutil::basename;
12use xil_core::script::hints::{filter_sfx_overrides, format_hint_attr};
13use xil_core::workspace::{derive_paths, resolve_slug};
14use xil_core::{banner, log};
15
16use super::parse::{backfill_sfx_sources, hint_target_exists};
17
18const SCRIPT_NAME: &str = "XILU013_sfx_hydrate.py";
19
20#[derive(Parser)]
21#[command(
22    name = "xil-sfx-hydrate",
23    about = "Write pipe-hint source and attribute fields (play_volume_pct, play_duration_pct) from parsed JSON into the SFX config without re-parsing the script."
24)]
25struct Args {
26    /// Episode tag (e.g. S04E04)
27    #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
28    episode: Option<String>,
29    /// Raw non-episodic tag (e.g. V01C03)
30    #[arg(long)]
31    tag: Option<String>,
32    /// Show name override (default: from project.json)
33    #[arg(long)]
34    show: Option<String>,
35    /// Override parsed JSON path
36    #[arg(long)]
37    parsed: Option<PathBuf>,
38    /// Override SFX config path
39    #[arg(long)]
40    sfx: Option<PathBuf>,
41    /// Replace a cue's existing 'source' when the script hint differs, instead of only filling in missing ones — the only way to correct a 'NEW STEM NEEDED' placeholder or a stale path. A replacement is skipped (with a warning) if the hinted file is not on disk. Replacements are written to the edit journal, so they survive a later rebuild from a fresh script. Preview with --dry-run first.
42    #[arg(long)]
43    force: bool,
44    /// Report changes without writing
45    #[arg(long)]
46    dry_run: bool,
47}
48
49enum Action {
50    Add(String),
51    Replace(String),
52    Skip(String),
53}
54
55/// What one cue would get: the source action, the attribute hints that
56/// differ from the config, and the source it holds today.
57struct Pending {
58    key: String,
59    action: Option<Action>,
60    changed: IndexMap<String, f64>,
61    current: Option<String>,
62}
63
64/// Overrides carried on a parsed direction entry.
65fn entry_overrides(e: &Value) -> IndexMap<String, f64> {
66    e.get("sfx_overrides")
67        .and_then(Value::as_object)
68        .map(|m| {
69            m.iter()
70                .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
71                .collect()
72        })
73        .unwrap_or_default()
74}
75
76/// Apply the hints in `parsed` to the config at `sfx_path`; returns how many
77/// entries would be (or were) updated.
78pub fn hydrate_sfx_config(
79    parsed: &Value,
80    sfx_path: &Path,
81    dry_run: bool,
82    force: bool,
83) -> anyhow::Result<usize> {
84    let sfx_data: Value = serde_json::from_str(&fs::read_to_string(sfx_path)?)?;
85    let effects = sfx_data
86        .get("effects")
87        .and_then(Value::as_object)
88        .cloned()
89        .unwrap_or_default();
90    let empty = Map::new();
91
92    let mut pending: Vec<Pending> = Vec::new();
93    let mut seen_clean: Vec<String> = Vec::new();
94    for entry in parsed
95        .get("entries")
96        .and_then(Value::as_array)
97        .cloned()
98        .unwrap_or_default()
99    {
100        if entry.get("type").and_then(Value::as_str) != Some("direction") {
101            continue;
102        }
103        let sfx_source = entry
104            .get("sfx_source")
105            .and_then(Value::as_str)
106            .filter(|s| !s.is_empty())
107            .map(str::to_string);
108        let overrides = entry_overrides(&entry);
109        if sfx_source.is_none() && overrides.is_empty() {
110            continue;
111        }
112        let text = entry
113            .get("text")
114            .and_then(Value::as_str)
115            .unwrap_or("")
116            .to_string();
117        if seen_clean.contains(&text) {
118            continue;
119        }
120        seen_clean.push(text.clone());
121
122        let effect = effects
123            .get(&text)
124            .and_then(Value::as_object)
125            .unwrap_or(&empty);
126        let current = effect
127            .get("source")
128            .filter(|v| super::truthy(v))
129            .map(xil_core::workspace::python_str);
130        // Classify so --dry-run shows what a forced run would actually do.
131        let action = match (&sfx_source, &current) {
132            (Some(src), None) => Some(Action::Add(src.clone())),
133            (Some(src), Some(cur)) if force && cur != src => Some(if hint_target_exists(src) {
134                Action::Replace(src.clone())
135            } else {
136                Action::Skip(src.clone())
137            }),
138            _ => None,
139        };
140        // Filter with the same rule the write path uses, so the report never
141        // promises a field backfill_sfx_sources will drop.
142        let is_silence = effect.get("type").and_then(Value::as_str) == Some("silence");
143        let mut warn = |m: String| log::warning(&m);
144        let usable = filter_sfx_overrides(
145            &text,
146            is_silence,
147            &overrides,
148            if dry_run { Some(&mut warn) } else { None },
149        );
150        let changed: IndexMap<String, f64> = usable
151            .into_iter()
152            .filter(|(k, v)| effect.get(k).and_then(Value::as_f64) != Some(*v))
153            .collect();
154        if action.is_some() || !changed.is_empty() {
155            pending.push(Pending {
156                key: text,
157                action,
158                changed,
159                current,
160            });
161        }
162    }
163
164    if pending.is_empty() {
165        log::info("  Nothing to apply — SFX config already matches the script hints.");
166        return Ok(0);
167    }
168
169    let prefix = if dry_run { "[dry-run] " } else { "" };
170    let name = |s: &str| basename(Path::new(s));
171    let mut skipped = 0usize;
172    for p in &pending {
173        let mut parts: Vec<String> = Vec::new();
174        let current = p.current.as_deref().unwrap_or("?");
175        match &p.action {
176            Some(Action::Add(src)) => parts.push(format!("+ {}", name(src))),
177            Some(Action::Replace(src)) => parts.push(format!("{} → {}", name(current), name(src))),
178            Some(Action::Skip(src)) => {
179                skipped += 1;
180                log::warning(&format!(
181                    "  {prefix}  {}  SKIP  {} not found on disk — keeping {}",
182                    p.key,
183                    name(src),
184                    name(current)
185                ));
186                continue;
187            }
188            None => {}
189        }
190        parts.extend(p.changed.iter().map(|(k, v)| format_hint_attr(k, *v)));
191        log::info(&format!("  {prefix}  {}  →  {}", p.key, parts.join(", ")));
192    }
193
194    if skipped > 0 {
195        log::warning(&format!(
196            "  {skipped} replacement(s) skipped because the hinted file is missing."
197        ));
198    }
199
200    if !dry_run {
201        backfill_sfx_sources(parsed, sfx_path, force)?;
202    }
203    Ok(pending.len())
204}
205
206fn execute(a: &Args, tag: &str) -> anyhow::Result<i32> {
207    let slug = resolve_slug(a.show.as_deref(), "project.json");
208    let p = derive_paths(&slug, tag);
209    let parsed_path = a.parsed.clone().unwrap_or_else(|| p["parsed"].clone());
210    let sfx_path = a.sfx.clone().unwrap_or_else(|| p["sfx"].clone());
211
212    if !parsed_path.exists() {
213        log::error(&format!("Parsed JSON not found: {}", parsed_path.display()));
214        return Ok(1);
215    }
216    if !sfx_path.exists() {
217        log::error(&format!("SFX config not found: {}", sfx_path.display()));
218        log::info("Run `xil parse --episode TAG` first to generate it.");
219        return Ok(1);
220    }
221
222    let parsed: Value = serde_json::from_str(&fs::read_to_string(&parsed_path)?)?;
223    let count = hydrate_sfx_config(&parsed, &sfx_path, a.dry_run, a.force)?;
224    if count > 0 {
225        let action = if a.dry_run { "Would update" } else { "Updated" };
226        log::info(&format!(
227            "  {action} {count} source field(s) in {}",
228            sfx_path.display()
229        ));
230    }
231    if a.dry_run && count > 0 {
232        log::info("  Re-run without --dry-run to apply changes.");
233    }
234    Ok(0)
235}
236
237pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
238    log::init("sfx-hydrate");
239    let a: Args = match super::parse_or_exit("xil-sfx-hydrate", args) {
240        Ok(a) => a,
241        Err(code) => return Ok(code),
242    };
243    let tag = a
244        .episode
245        .clone()
246        .or_else(|| a.tag.clone())
247        .unwrap_or_default();
248    let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
249    execute(&a, &tag)
250}
251
252/// The clap definition behind `--help`, for man pages.
253pub fn command() -> clap::Command {
254    <Args as clap::CommandFactory>::command()
255}