1use 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 #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
28 episode: Option<String>,
29 #[arg(long)]
31 tag: Option<String>,
32 #[arg(long)]
34 show: Option<String>,
35 #[arg(long)]
37 parsed: Option<PathBuf>,
38 #[arg(long)]
40 sfx: Option<PathBuf>,
41 #[arg(long)]
43 force: bool,
44 #[arg(long)]
46 dry_run: bool,
47}
48
49enum Action {
50 Add(String),
51 Replace(String),
52 Skip(String),
53}
54
55struct Pending {
58 key: String,
59 action: Option<Action>,
60 changed: IndexMap<String, f64>,
61 current: Option<String>,
62}
63
64fn 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
76pub 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 let action = match (&sfx_source, ¤t) {
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 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
252pub fn command() -> clap::Command {
254 <Args as clap::CommandFactory>::command()
255}