Skip to main content

xil/
sfxgen.rs

1//! SFX asset generation and stem placement — the generation half of
2//! `sfx_common.py`, plus the backends of `sfx_backends.py`.
3//!
4//! Two phases, as in Python: every unique effect key gets a shared asset in
5//! `SFX/` (copied from its declared source, rendered as silence, or
6//! generated by a backend), then each script entry gets a copy of that asset
7//! under its sequence-numbered stem name. A manifest in the stems directory
8//! records what was placed, with a timestamped snapshot per run.
9
10use 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/// One direction entry matched to an effect (`load_sfx_entries` output).
31#[derive(Clone, Debug)]
32#[allow(dead_code)] // sfx_type, section and scene mirror the Python dict; the producer reads them.
33pub 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
43/// `file_nonempty(path)`.
44pub fn file_nonempty(path: &Path) -> bool {
45    fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false)
46}
47
48/// `resolve_source_path(source)`: workspace-relative, NFC/NFD tolerant.
49pub 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
75/// `load_sfx_entries(script, sfx, max_duration, direction_types, local_only, sfx_dir)`.
76pub 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(&section))
127        } else {
128            let mut s = format!("{seq:03}_{}", py_str(&section));
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
149// ── Backends ────────────────────────────────────────────────────────────────
150
151/// `SfxBackend`: `generate_to(out_path, prompt, duration, influence)` + `close()`.
152pub trait SfxBackend {
153    fn name(&self) -> &'static str;
154    /// `asset_comment`, when the backend attaches a provenance note.
155    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
170/// Retry 429 / 5xx / transport failures five times, waiting 10 s, 20 s, …
171pub 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
205/// Write `bytes` through a `.tmp` sibling and an atomic rename.
206fn 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
252/// MMAudio via `mmaudio_worker.py` (CC BY-NC 4.0 weights).
253pub 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
280/// `make_sfx_backend(name, ...)`. `Ok(None)` means the caller should exit
281/// with status 1 (the error is already logged, as `sys.exit(1)` does).
282pub 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
400/// `_trim_audio_file(path, seconds)`.
401fn 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
462// ── Shared assets and stems ─────────────────────────────────────────────────
463
464/// `shutil.copy2`: contents, permission bits and timestamps.
465pub 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
482/// `AudioSegment.silent(duration).export(path, format="mp3")`.
483fn 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
490/// `ensure_shared_sfx(key, effect, sfx_dir, defaults, show=, backend=)`.
491pub 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
571/// `place_episode_stem(shared, stem)` → whether it copied.
572pub 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
604/// `generate_sfx(entries, config, stems_dir, sfx_dir=, start_from=, backend=)`.
605pub 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
731/// `_sfx_manifest_upsert`: update in place when the content key matches.
732fn 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    // The Python index is built by assignment, so a key maps to the *last*
739    // matching entry object — search from the end.
740    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
753/// `dry_run_sfx(entries, config, stems_dir, sfx_dir=, backend_name=)`.
754pub 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}