1use std::collections::HashSet;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use crate::cmd::py_str;
16use anyhow::{anyhow, bail};
17use serde_json::{Map, Value};
18use unicode_normalization::UnicodeNormalization;
19use xil_audio::fx::py_repr;
20use xil_audio::segment::Segment;
21use xil_core::fsutil::basename;
22use xil_core::log;
23use xil_core::pyfmt::round_to;
24use xil_core::pyjson::{dumps, py_float, Style};
25use xil_core::sfxlib::shared_sfx_path;
26use xil_core::workspace::workspace_root;
27
28use crate::mix::config::{SfxConfig, SfxEntry};
29
30#[derive(Clone, Debug)]
32#[allow(dead_code)] pub struct PlanEntry {
34 pub seq: i64,
35 pub text: String,
36 pub direction_type: Option<String>,
37 pub stem_name: String,
38 pub sfx_type: String,
39 pub section: Value,
40 pub scene: Option<String>,
41}
42
43pub fn file_nonempty(path: &Path) -> bool {
45 fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false)
46}
47
48pub fn resolve_source_path(source: &str) -> Option<PathBuf> {
50 let mut candidates = vec![source.to_string()];
51 for form in [
52 source.nfc().collect::<String>(),
53 source.nfd().collect::<String>(),
54 ] {
55 if !candidates.contains(&form) {
56 candidates.push(form);
57 }
58 }
59 for c in candidates {
60 let p = PathBuf::from(&c);
61 let p = if p.is_absolute() {
62 p
63 } else {
64 workspace_root().join(p)
65 };
66 if let Ok(real) = fs::canonicalize(&p) {
67 if real.is_file() {
68 return Some(real);
69 }
70 }
71 }
72 None
73}
74
75pub fn load_sfx_entries(
77 script_path: &Path,
78 cfg: &SfxConfig,
79 max_duration: Option<f64>,
80 direction_types: Option<&HashSet<&str>>,
81 local_only: bool,
82 sfx_dir: &Path,
83) -> anyhow::Result<Vec<PlanEntry>> {
84 let script: Value = serde_json::from_str(&fs::read_to_string(script_path)?)?;
85 let mut out = Vec::new();
86 for e in script["entries"].as_array().into_iter().flatten() {
87 let Some(o) = e.as_object() else { continue };
88 if o.get("type").and_then(Value::as_str) != Some("direction") {
89 continue;
90 }
91 let dt = o.get("direction_type").and_then(Value::as_str);
92 if let Some(types) = direction_types {
93 if !dt.is_some_and(|d| types.contains(d)) {
94 continue;
95 }
96 }
97 let text = o.get("text").and_then(Value::as_str).unwrap_or("");
98 let Some(effect) = cfg.effects.get(text) else {
99 continue;
100 };
101 if effect.duration_seconds == 0.0 {
102 continue;
103 }
104 if max_duration.is_some_and(|m| effect.duration_seconds > m) {
105 continue;
106 }
107 if local_only
108 && effect.type_ == "sfx"
109 && effect.source.is_none()
110 && !file_nonempty(&shared_sfx_path(sfx_dir, text, "elevenlabs"))
111 {
112 log::debug(&format!(
113 "--local-only: skipping {} (not in SFX/)",
114 py_repr(text)
115 ));
116 continue;
117 }
118 let seq = o.get("seq").and_then(Value::as_i64).unwrap_or(0);
119 let section = o.get("section").cloned().unwrap_or(Value::Null);
120 let scene = o
121 .get("scene")
122 .and_then(Value::as_str)
123 .filter(|s| !s.is_empty())
124 .map(str::to_string);
125 let stem_name = if seq < 0 {
126 format!("n{:03}_{}_sfx", seq.abs(), py_str(§ion))
127 } else {
128 let mut s = format!("{seq:03}_{}", py_str(§ion));
129 if let Some(sc) = &scene {
130 s.push('-');
131 s.push_str(sc);
132 }
133 s.push_str("_sfx");
134 s
135 };
136 out.push(PlanEntry {
137 seq,
138 text: text.to_string(),
139 direction_type: dt.map(str::to_string),
140 stem_name,
141 sfx_type: effect.type_.clone(),
142 section,
143 scene: o.get("scene").and_then(Value::as_str).map(str::to_string),
144 });
145 }
146 Ok(out)
147}
148
149pub trait SfxBackend {
153 fn name(&self) -> &'static str;
154 fn asset_comment(&self) -> Option<&'static str> {
156 None
157 }
158 fn generate_to(
159 &mut self,
160 out_path: &Path,
161 prompt: Option<&str>,
162 duration_seconds: f64,
163 prompt_influence: f64,
164 ) -> anyhow::Result<()>;
165 fn close(&mut self) -> anyhow::Result<()> {
166 Ok(())
167 }
168}
169
170pub fn with_api_retries<T>(
172 mut call: impl FnMut() -> Result<T, xil_api::ApiError>,
173) -> anyhow::Result<T> {
174 let (max_retries, delay) = (5u64, 10u64);
175 for attempt in 1..=max_retries {
176 match call() {
177 Ok(v) => return Ok(v),
178 Err(err) => {
179 let status = err.status();
180 let rate = status == Some(429);
181 let server = status.is_some_and(|s| s >= 500);
182 let network = status.is_none();
183 if (rate || server || network) && attempt < max_retries {
184 let wait = delay * attempt;
185 let reason = if rate {
186 "429 rate limited".to_string()
187 } else if server {
188 format!("{} server error", status.unwrap_or(0))
189 } else {
190 "network error (TransportError)".to_string()
191 };
192 log::warning(&format!(
193 "[{reason}] — retrying in {wait}s (attempt {attempt}/{max_retries})"
194 ));
195 std::thread::sleep(Duration::from_secs(wait));
196 } else {
197 return Err(anyhow!("elevenlabs.core.api_error.ApiError: {err}"));
198 }
199 }
200 }
201 }
202 unreachable!("the last attempt returns")
203}
204
205fn write_atomic(out_path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
207 let dir = out_path
208 .parent()
209 .filter(|p| !p.as_os_str().is_empty())
210 .unwrap_or(Path::new("."));
211 let tmp = tempfile::Builder::new()
212 .prefix("tmp")
213 .rand_bytes(8)
214 .suffix(".tmp")
215 .tempfile_in(dir)?;
216 fs::write(tmp.path(), bytes)?;
217 tmp.persist(out_path).map_err(|e| anyhow!(e.error))?;
218 Ok(())
219}
220
221pub struct ElevenLabsBackend {
222 pub client: xil_api::elevenlabs::Client,
223}
224
225impl SfxBackend for ElevenLabsBackend {
226 fn name(&self) -> &'static str {
227 "elevenlabs"
228 }
229
230 fn generate_to(
231 &mut self,
232 out_path: &Path,
233 prompt: Option<&str>,
234 duration_seconds: f64,
235 prompt_influence: f64,
236 ) -> anyhow::Result<()> {
237 let prompt_s = prompt.map(py_repr).unwrap_or_else(|| "None".into());
238 log::info(&format!(
239 " [api] text-to-sound-effects → {prompt_s} ({duration_seconds:.1}s)"
240 ));
241 let text = prompt.unwrap_or("");
242 let bytes = with_api_retries(|| {
243 self.client
244 .sound_generation(text, Some(duration_seconds), Some(prompt_influence))
245 })?;
246 write_atomic(out_path, &bytes)?;
247 log::info(&format!(" [api] saved {}", basename(out_path)));
248 Ok(())
249 }
250}
251
252pub struct MMAudioBackend {
254 python: String,
255 script: PathBuf,
256 device: String,
257 guidance: f64,
258 steps: i64,
259 negative_prompt: String,
260 seed: Option<i64>,
261 native_duration: f64,
262 worker: Option<xil_workers::Worker>,
263}
264
265pub const MMAUDIO_COMMENT: &str =
266 "Generated by MMAudio (hkchengrex/MMAudio). Model weights are CC BY-NC 4.0 — NON-COMMERCIAL USE ONLY.";
267
268const MMAUDIO_LICENCE_ERROR: &str = "MMAudio weights are CC BY-NC 4.0 (non-commercial only). Pass --mmaudio-accept-noncommercial to acknowledge that generated audio must not be used in a monetised production.";
269
270pub struct MMAudioOptions<'a> {
271 pub python: Option<&'a str>,
272 pub cfg: f64,
273 pub steps: i64,
274 pub negative_prompt: &'a str,
275 pub seed: Option<i64>,
276 pub duration: f64,
277 pub accept_noncommercial: bool,
278}
279
280pub fn make_sfx_backend(
283 name: &str,
284 client: xil_api::elevenlabs::Client,
285 mm: &MMAudioOptions,
286) -> anyhow::Result<Option<Box<dyn SfxBackend>>> {
287 match name {
288 "elevenlabs" => Ok(Some(Box::new(ElevenLabsBackend { client }))),
289 "mmaudio" => {
290 if !mm.accept_noncommercial {
291 bail!("ValueError: {MMAUDIO_LICENCE_ERROR}");
292 }
293 let package_dir = crate::workers::python_package_dir();
294 let Some(python) = xil_core::workspace::resolve_venv_python(
295 "venv-mmaudio",
296 mm.python,
297 package_dir.as_deref(),
298 ) else {
299 log::error(
300 "Cannot find the venv-mmaudio Python. Pass --mmaudio-python PATH, set XIL_CODEROOT to the directory containing venv-mmaudio/, or build it with: xil setup mmaudio",
301 );
302 return Ok(None);
303 };
304 let script = package_dir
305 .map(|d| xil_workers::worker_script(&d, "mmaudio_worker.py"))
306 .ok_or_else(|| {
307 anyhow!("cannot locate the xil_pipeline package that ships mmaudio_worker.py")
308 })?;
309 log::warning(
310 "MMAudio weights are CC BY-NC 4.0 — NON-COMMERCIAL USE ONLY. Generated assets are tagged .mmaudio and carry the notice in ID3.",
311 );
312 Ok(Some(Box::new(MMAudioBackend {
313 python,
314 script,
315 device: "cuda".into(),
316 guidance: mm.cfg,
317 steps: mm.steps,
318 negative_prompt: mm.negative_prompt.to_string(),
319 seed: mm.seed,
320 native_duration: mm.duration,
321 worker: None,
322 })))
323 }
324 other => bail!("ValueError: Unknown sfx backend: {}", py_repr(other)),
325 }
326}
327
328impl MMAudioBackend {
329 fn start(&mut self) -> anyhow::Result<()> {
330 log::info(&format!(
331 "Starting MMAudio worker ({}, {})…",
332 self.python, self.device
333 ));
334 let mut w = xil_workers::Worker::spawn(
335 Path::new(&self.python),
336 &self.script,
337 std::slice::from_ref(&self.device),
338 )?;
339 let ready = w.wait_ready(|line| log::debug(&format!("MMAudio worker startup: {line}")))?;
340 let Some(msg) = ready else {
341 bail!(
342 "RuntimeError: MMAudio worker exited before sending ready signal. Check that venv-mmaudio is set up (git clone hkchengrex/MMAudio, then pip install -e .) and the weights have downloaded."
343 );
344 };
345 let sr = msg.get("sr").and_then(Value::as_i64).unwrap_or(16000);
346 let device = msg
347 .get("device")
348 .map(py_str)
349 .unwrap_or_else(|| self.device.clone());
350 log::info(&format!(
351 "MMAudio worker ready (sample_rate={sr}, device={device})"
352 ));
353 self.worker = Some(w);
354 Ok(())
355 }
356
357 fn generate(
358 &mut self,
359 prompt: Option<&str>,
360 out_path: &Path,
361 duration: f64,
362 ) -> anyhow::Result<()> {
363 if self.worker.is_none() {
364 self.start()?;
365 }
366 let mut req = Map::new();
367 req.insert(
368 "prompt".into(),
369 prompt.map(Value::from).unwrap_or(Value::Null),
370 );
371 req.insert(
372 "out_path".into(),
373 out_path.to_string_lossy().into_owned().into(),
374 );
375 req.insert("duration_seconds".into(), py_float(duration));
376 req.insert("guidance_scale".into(), py_float(self.guidance));
377 req.insert("num_inference_steps".into(), self.steps.into());
378 req.insert(
379 "negative_prompt".into(),
380 self.negative_prompt.clone().into(),
381 );
382 req.insert(
383 "seed".into(),
384 self.seed.map(Value::from).unwrap_or(Value::Null),
385 );
386 let w = self.worker.as_mut().expect("started");
387 w.send(&Value::Object(req))?;
388 let raw = w.read_line()?;
389 if raw.is_empty() {
390 bail!("RuntimeError: MMAudio worker closed pipe unexpectedly.");
391 }
392 let resp: Value = serde_json::from_str(&raw)?;
393 if let Some(err) = resp.get("error") {
394 bail!("RuntimeError: MMAudio: {}", py_str(err));
395 }
396 Ok(())
397 }
398}
399
400fn trim_audio_file(path: &Path, seconds: f64) -> anyhow::Result<()> {
402 let clip = Segment::from_file(path)?;
403 let target_ms = (seconds * 1000.0) as i64;
404 if clip.len_ms() <= target_ms {
405 return Ok(());
406 }
407 let dir = path
408 .parent()
409 .filter(|p| !p.as_os_str().is_empty())
410 .unwrap_or(Path::new("."));
411 let tmp = tempfile::Builder::new()
412 .prefix("tmp")
413 .rand_bytes(8)
414 .suffix(".mp3")
415 .tempfile_in(dir)?
416 .into_temp_path();
417 clip.slice(None, Some(target_ms as f64))
418 .export_ffmpeg(&tmp, "mp3", &[])?;
419 fs::rename(&tmp, path)?;
420 let _ = tmp.keep();
421 Ok(())
422}
423
424impl SfxBackend for MMAudioBackend {
425 fn name(&self) -> &'static str {
426 "mmaudio"
427 }
428
429 fn asset_comment(&self) -> Option<&'static str> {
430 Some(MMAUDIO_COMMENT)
431 }
432
433 fn generate_to(
434 &mut self,
435 out_path: &Path,
436 prompt: Option<&str>,
437 duration_seconds: f64,
438 _influence: f64,
439 ) -> anyhow::Result<()> {
440 let target = duration_seconds.max(0.0);
441 let gen = self.native_duration.max(target);
442 let prompt_s = prompt.map(py_repr).unwrap_or_else(|| "None".into());
443 let shown = if target != 0.0 { target } else { gen };
444 log::info(&format!(
445 " [mmaudio] {prompt_s} — generating {gen:.1}s (native), trimming to {shown:.1}s"
446 ));
447 self.generate(prompt, out_path, gen)?;
448 if target != 0.0 && target < gen {
449 trim_audio_file(out_path, target)?;
450 }
451 Ok(())
452 }
453
454 fn close(&mut self) -> anyhow::Result<()> {
455 if let Some(w) = self.worker.take() {
456 let _ = w.close();
457 }
458 Ok(())
459 }
460}
461
462pub fn copy2(src: &Path, dst: &Path) -> std::io::Result<()> {
466 fs::copy(src, dst)?;
467 let meta = fs::metadata(src)?;
468 let atime = filetime::FileTime::from_last_access_time(&meta);
469 let mtime = filetime::FileTime::from_last_modification_time(&meta);
470 filetime::set_file_times(dst, atime, mtime)
471}
472
473fn default_influence(cfg: &SfxConfig, effect: &SfxEntry) -> f64 {
474 effect.prompt_influence.unwrap_or_else(|| {
475 cfg.defaults
476 .get("prompt_influence")
477 .and_then(|v| crate::mix::config::Num::from_value(v).map(|n| n.f()))
478 .unwrap_or(0.3)
479 })
480}
481
482fn export_silence(path: &Path, duration_seconds: f64) -> anyhow::Result<()> {
484 let ms = (duration_seconds * 1000.0) as i64;
485 fs::File::create(path)?;
486 Segment::silent(ms as f64).export_ffmpeg(path, "mp3", &[])?;
487 Ok(())
488}
489
490pub fn ensure_shared_sfx(
492 key: &str,
493 effect: &SfxEntry,
494 sfx_dir: &Path,
495 cfg: &SfxConfig,
496 backend: &mut dyn SfxBackend,
497) -> anyhow::Result<PathBuf> {
498 let is_model = effect.type_ == "sfx" && effect.source.is_none();
499 let path = shared_sfx_path(
500 sfx_dir,
501 key,
502 if is_model {
503 backend.name()
504 } else {
505 "elevenlabs"
506 },
507 );
508 if effect.source.is_none() && file_nonempty(&path) {
509 return Ok(path);
510 }
511 fs::create_dir_all(sfx_dir)?;
512 if let Some(source) = &effect.source {
513 match resolve_source_path(source) {
514 Some(real) => {
515 let same = path.exists()
516 && fs::canonicalize(&path).ok().as_deref() == Some(real.as_path());
517 if !same {
518 copy2(&real, &path)?;
519 }
520 }
521 None => {
522 let Some(prompt) = &effect.prompt else {
523 bail!(
524 "FileNotFoundError: Source file not found: '{source}' (key: '{key}'). Add the file or add a 'prompt' field to generate it."
525 );
526 };
527 println!(
528 " [warn] source '{source}' not found — generating via {} for '{key}'",
529 backend.name()
530 );
531 backend.generate_to(
532 &path,
533 Some(prompt),
534 effect.duration_seconds,
535 default_influence(cfg, effect),
536 )?;
537 xil_audio::tags::tag_mp3_full(
538 &path,
539 &cfg.show,
540 Some(key),
541 None,
542 None,
543 backend.asset_comment(),
544 None,
545 )?;
546 return Ok(path);
547 }
548 }
549 } else if effect.type_ == "silence" {
550 export_silence(&path, effect.duration_seconds)?;
551 } else {
552 backend.generate_to(
553 &path,
554 effect.prompt.as_deref(),
555 effect.duration_seconds,
556 default_influence(cfg, effect),
557 )?;
558 }
559 xil_audio::tags::tag_mp3_full(
560 &path,
561 &cfg.show,
562 Some(key),
563 None,
564 None,
565 backend.asset_comment(),
566 None,
567 )?;
568 Ok(path)
569}
570
571pub fn place_episode_stem(shared: &Path, stem: &Path) -> anyhow::Result<bool> {
573 if file_nonempty(stem) {
574 return Ok(false);
575 }
576 if let Some(parent) = stem.parent().filter(|p| !p.as_os_str().is_empty()) {
577 fs::create_dir_all(parent)?;
578 }
579 copy2(shared, stem)?;
580 Ok(true)
581}
582
583type ContentKey = (String, String, String, String, Option<u64>);
584
585fn content_key(e: &Map<String, Value>) -> ContentKey {
586 let s = |k: &str| match e.get(k) {
587 Some(Value::String(v)) => v.clone(),
588 Some(Value::Null) | None => String::new(),
589 Some(other) => py_str(other),
590 };
591 let dur = e
592 .get("duration_seconds")
593 .and_then(Value::as_f64)
594 .map(|d| round_to(d, 3).to_bits());
595 (
596 s("effect_key"),
597 s("sfx_type"),
598 s("source_path"),
599 s("prompt"),
600 dur,
601 )
602}
603
604pub fn generate_sfx(
606 entries: &[PlanEntry],
607 cfg: &SfxConfig,
608 stems_dir: &Path,
609 sfx_dir: &Path,
610 start_from: i64,
611 backend: &mut dyn SfxBackend,
612) -> anyhow::Result<()> {
613 fs::create_dir_all(stems_dir)?;
614 let run_started_at = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
615 let tag = basename(stems_dir);
616 let mf_path = stems_dir.join(format!("{tag}_sfx_manifest.json"));
617 let mut manifest: Value = fs::read_to_string(&mf_path)
618 .ok()
619 .and_then(|t| serde_json::from_str(&t).ok())
620 .unwrap_or_else(|| serde_json::json!({"version": 1, "entries": []}));
621
622 let todo: Vec<&PlanEntry> = entries.iter().filter(|e| e.seq >= start_from).collect();
623 log::info(&format!("--- SFX: Processing {} entries ---", todo.len()));
624
625 let mut unique: Vec<&str> = Vec::new();
626 for e in &todo {
627 if !unique.contains(&e.text.as_str()) {
628 unique.push(&e.text);
629 }
630 }
631 let mut shared: std::collections::HashMap<&str, PathBuf> = std::collections::HashMap::new();
632 for key in &unique {
633 let effect = &cfg.effects[*key];
634 let path = ensure_shared_sfx(key, effect, sfx_dir, cfg, backend)?;
635 log::info(&format!(" Shared: {}", path.display()));
636 shared.insert(key, path);
637 }
638
639 let (mut copied, mut skipped, mut omitted) = (0, 0, 0);
640 for e in &todo {
641 let stem = stems_dir.join(format!("{}.mp3", e.stem_name));
642 let shared_path = &shared[e.text.as_str()];
643 let effect = &cfg.effects[&e.text];
644 if xil_audio::tags::read_sfx_grade(shared_path) == xil_audio::tags::SFX_GRADE_REJECTED {
645 if stem.exists() {
646 fs::remove_file(&stem)?;
647 }
648 log::info(&format!(
649 " [grade] omitted (rejected): {}",
650 basename(shared_path)
651 ));
652 omitted += 1;
653 continue;
654 }
655 if place_episode_stem(shared_path, &stem)? {
656 log::info(&format!(" Placed: {}", stem.display()));
657 log::info(&format!(
658 " SHA256: {}",
659 crate::cmd::mp3_hash::hash_file(&stem)?
660 ));
661 copied += 1;
662 } else {
663 log::info(&format!(" Exists: {} — skipping", stem.display()));
664 skipped += 1;
665 }
666 if let Ok(sha) = crate::cmd::mp3_hash::hash_file(&stem) {
667 let mut entry = Map::new();
668 entry.insert("effect_key".into(), e.text.clone().into());
669 entry.insert("sfx_type".into(), effect.type_.clone().into());
670 entry.insert(
671 "source_path".into(),
672 effect
673 .source
674 .clone()
675 .map(Value::from)
676 .unwrap_or(Value::Null),
677 );
678 entry.insert(
679 "prompt".into(),
680 effect
681 .prompt
682 .clone()
683 .map(Value::from)
684 .unwrap_or(Value::Null),
685 );
686 entry.insert("duration_seconds".into(), py_float(effect.duration_seconds));
687 entry.insert("sha256".into(), sha.into());
688 entry.insert("seq_at_placement".into(), e.seq.into());
689 entry.insert("stem_filename".into(), basename(&stem).into());
690 entry.insert(
691 "placed_at".into(),
692 chrono::Local::now()
693 .format("%Y-%m-%dT%H:%M:%S")
694 .to_string()
695 .into(),
696 );
697 upsert(&mut manifest, entry);
698 }
699 }
700 log::info(&format!(
701 "--- SFX Complete: {} shared assets, {copied} placed, {skipped} skipped, {omitted} omitted (rejected) ---",
702 unique.len()
703 ));
704 let save = |path: &Path| -> std::io::Result<()> {
705 let tmp = PathBuf::from(format!("{}.tmp", path.display()));
706 fs::write(&tmp, dumps(&manifest, Style::INDENT2))?;
707 fs::rename(&tmp, path)
708 };
709 let n = manifest["entries"].as_array().map_or(0, Vec::len);
710 match save(&mf_path) {
711 Ok(()) => {
712 log::info(&format!(
713 " SFX Manifest: {} ({n} entries)",
714 basename(&mf_path)
715 ));
716 let snap = PathBuf::from(
717 mf_path
718 .to_string_lossy()
719 .replace(".json", &format!("_{run_started_at}.json")),
720 );
721 match save(&snap) {
722 Ok(()) => log::info(&format!(" SFX Snapshot: {}", basename(&snap))),
723 Err(e) => log::warning(&format!("Could not write SFX manifest: {e}")),
724 }
725 }
726 Err(e) => log::warning(&format!("Could not write SFX manifest: {e}")),
727 }
728 Ok(())
729}
730
731fn upsert(manifest: &mut Value, entry: Map<String, Value>) {
733 let key = content_key(&entry);
734 let list = manifest
735 .get_mut("entries")
736 .and_then(Value::as_array_mut)
737 .expect("manifest has entries");
738 for existing in list.iter_mut().rev() {
741 if let Some(o) = existing.as_object_mut() {
742 if content_key(o) == key {
743 for (k, v) in entry {
744 o.insert(k, v);
745 }
746 return;
747 }
748 }
749 }
750 list.push(Value::Object(entry));
751}
752
753pub fn dry_run_sfx(
755 entries: &[PlanEntry],
756 cfg: &SfxConfig,
757 stems_dir: &Path,
758 sfx_dir: &Path,
759 backend_name: &str,
760) {
761 let is_local = backend_name != "elevenlabs";
762 let bar = "=".repeat(70);
763 log::info(&format!("\n{bar}"));
764 log::info(&format!(
765 "SFX DRY RUN — {} entries (backend: {backend_name})",
766 entries.len()
767 ));
768 log::info(&format!(" stems dir: {}", stems_dir.display()));
769 log::info(&format!(" shared dir: {}", sfx_dir.display()));
770 log::info(&format!("{bar}\n"));
771
772 let cats = ["MUSIC", "AMBIENCE", "SFX", "silence"];
773 let mut new_n = [0usize; 4];
774 let mut dur = [0f64; 4];
775 let (mut new_count, mut cached, mut exists, mut missing) = (0, 0, 0, 0);
776 let mut missing_sources: Vec<String> = Vec::new();
777
778 for e in entries {
779 let Some(effect) = cfg.effects.get(&e.text) else {
780 continue;
781 };
782 let stem = stems_dir.join(format!("{}.mp3", e.stem_name));
783 let is_source = effect.source.is_some();
784 let is_model = effect.type_ == "sfx" && !is_source;
785 let shared_backend = if is_model { backend_name } else { "elevenlabs" };
786 let shared_file: String = match &effect.source {
787 Some(src) => src.clone(),
788 None => shared_sfx_path(sfx_dir, &e.text, shared_backend)
789 .to_string_lossy()
790 .into_owned(),
791 };
792 let resolved: Option<PathBuf> = if is_source {
793 resolve_source_path(effect.source.as_deref().unwrap_or(""))
794 } else {
795 Some(PathBuf::from(&shared_file))
796 };
797 let status = if stem.exists() {
798 exists += 1;
799 "EXISTS"
800 } else if is_source && resolved.is_none() {
801 missing += 1;
802 missing_sources.push(format!(" '{}' → {shared_file}", e.text));
803 "MISSING"
804 } else if resolved.as_ref().is_some_and(|r| r.exists()) {
805 cached += 1;
806 "CACHED"
807 } else {
808 new_count += 1;
809 " NEW"
810 };
811 let seq_label = if e.seq < 0 {
812 format!("n{:03}", e.seq.abs())
813 } else {
814 format!("{:03}", e.seq)
815 };
816 if effect.type_ == "silence" {
817 log::info(&format!(
818 " [{status}] {seq_label} | silence | {:5.1}s | {}",
819 effect.duration_seconds, e.text
820 ));
821 if status == " NEW" {
822 new_n[3] += 1;
823 dur[3] += effect.duration_seconds;
824 }
825 } else if is_source {
826 log::info(&format!(
827 " [{status}] {seq_label} | copy | | ~ 0 credits | {}",
828 e.text
829 ));
830 log::info(&format!(" source: {shared_file}"));
831 } else {
832 let credits = (effect.duration_seconds * 40.0) as i64;
833 let bucket = match e.direction_type.as_deref().filter(|d| !d.is_empty()) {
834 Some("MUSIC") => 0,
835 Some("AMBIENCE") => 1,
836 _ => 2,
837 };
838 if status == " NEW" {
839 new_n[bucket] += 1;
840 dur[bucket] += effect.duration_seconds;
841 }
842 if is_local {
843 log::info(&format!(
844 " [{status}] {seq_label} | sfx | {:5.1}s | local (free) | {}",
845 effect.duration_seconds, e.text
846 ));
847 } else {
848 log::info(&format!(
849 " [{status}] {seq_label} | sfx | {:5.1}s | ~{credits:5} credits | {}",
850 effect.duration_seconds, e.text
851 ));
852 }
853 log::info(&format!(
854 " prompt: {}",
855 effect.prompt.clone().unwrap_or_else(|| "None".into())
856 ));
857 }
858 log::info(&format!(" stem: {}.mp3", e.stem_name));
859 if !is_source {
860 log::info(&format!(
861 " shared: {}",
862 basename(Path::new(&shared_file))
863 ));
864 }
865 log::info("");
866 }
867
868 let total_dur: f64 = dur.iter().sum();
869 let total_credits = (total_dur * 40.0) as i64;
870 log::info(&bar);
871 log::info(&format!(
872 "SUMMARY: {} total — {new_count} new, {cached} cached, {exists} on disk, {missing} MISSING (backend: {backend_name})",
873 entries.len()
874 ));
875 for (i, cat) in cats.iter().take(3).enumerate() {
876 let present = entries.iter().any(|e| {
877 e.direction_type
878 .as_deref()
879 .filter(|d| !d.is_empty())
880 .unwrap_or("SFX")
881 == *cat
882 });
883 if new_n[i] > 0 || present {
884 if is_local {
885 log::info(&format!(
886 " {cat:<9}: {:3} new, {:6.1}s (local, free)",
887 new_n[i], dur[i]
888 ));
889 } else {
890 log::info(&format!(
891 " {cat:<9}: {:3} new, {:6.1}s, ~{:6} credits",
892 new_n[i],
893 dur[i],
894 (dur[i] * 40.0) as i64
895 ));
896 }
897 }
898 }
899 if new_n[3] > 0 {
900 log::info(&format!(" {:<9}: {:3} new (free)", "silence", new_n[3]));
901 }
902 if is_local {
903 log::info(&format!(
904 " {:<9}: {new_count:3}, {total_dur:.1}s (local generation — no API credits)",
905 "TOTAL NEW"
906 ));
907 } else {
908 log::info(&format!(" {:<9}: {new_count:3}, {total_dur:.1}s, ~{total_credits} credits (silence & cached are free)", "TOTAL NEW"));
909 }
910 if !missing_sources.is_empty() {
911 log::error(&format!(
912 "{} source file(s) declared but not found:",
913 missing_sources.len()
914 ));
915 for m in &missing_sources {
916 log::error(m);
917 }
918 }
919 log::info(&format!("{bar}\n"));
920}