1use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use anyhow::bail;
11use clap::Parser;
12use indexmap::IndexMap;
13use serde_json::{Map, Value};
14use xil_audio::tags::tag_wav;
15use xil_core::fsutil::abspath;
16use xil_core::workspace::{derive_paths, resolve_slug, show_slug, workspace_root, DEFAULT_SLUG};
17use xil_core::{banner, log};
18
19use crate::mix::config::{CastConfig, SfxConfig, Voice};
20use crate::mix::timeline::{
21 build_timeline_data, render_html_timeline, render_terminal_timeline, render_text_timeline_map,
22};
23use crate::mix::{self, Label, StemPlan};
24
25const SILENCE_GAP_MS: i64 = 600;
26
27const SCRIPT_TEMPLATE: &str = include_str!("../mix/templates/open_in_audacity.py.tmpl");
28const SCRIPT_TEMPLATE_AUP3: &str = include_str!("../mix/templates/open_in_audacity_aup3.py.tmpl");
29
30const LAYERS: [(&str, &str, &str); 5] = [
32 (
33 "dialogue",
34 "layer_dialogue",
35 "Spoken dialogue (audio filter chain + pan applied per speaker)",
36 ),
37 (
38 "ambience",
39 "layer_ambience",
40 "Looped environmental background (no ducking)",
41 ),
42 (
43 "music",
44 "layer_music",
45 "Music stings and themes (no ducking)",
46 ),
47 (
48 "sfx",
49 "layer_sfx",
50 "One-shot sound effects and beat silences",
51 ),
52 (
53 "vintage_filter",
54 "layer_vintage_filter",
55 "Record player crackle (vintage filter active spans)",
56 ),
57];
58
59#[derive(Parser)]
60#[command(
61 name = "xil-daw",
62 about = "DAW Export — export episode as layered WAV files for Audacity"
63)]
64#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
65struct Args {
66 #[arg(long)]
68 episode: Option<String>,
69 #[arg(long)]
71 tag: Option<String>,
72 #[arg(long)]
74 show: Option<String>,
75 #[arg(long)]
77 parsed: Option<String>,
78 #[arg(long)]
80 output_dir: Option<String>,
81 #[arg(long)]
83 dry_run: bool,
84 #[arg(long)]
86 save_aup3: bool,
87 #[arg(long = "macro")]
89 macro_: bool,
90 #[arg(long)]
92 timeline: bool,
93 #[arg(long)]
95 timeline_html: bool,
96 #[arg(long, default_value_t = SILENCE_GAP_MS, allow_negative_numbers = true)]
98 gap_ms: i64,
99}
100
101fn validate_tag_for_script(tag: &str) -> anyhow::Result<()> {
103 let ok = !tag.is_empty()
104 && tag
105 .chars()
106 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
107 if !ok {
108 bail!(
109 "Tag {} contains characters that are not safe for script generation. Tags must match ^[A-Za-z0-9_-]+$ (letters, digits, hyphens, underscores only).",
110 xil_audio::fx::py_repr(tag)
111 );
112 }
113 Ok(())
114}
115
116fn write_labels(output_dir: &str, fname: &str, labels: &[Label]) -> std::io::Result<()> {
118 let mut out = String::new();
119 for l in labels {
120 out.push_str(&format!("{:.3}\t{:.3}\t{}\n", l.start_s, l.end_s, l.text));
121 }
122 fs::write(Path::new(output_dir).join(fname), out)
123}
124
125fn command_output(program: &str, args: &[&str]) -> Option<String> {
126 let out = Command::new(program)
127 .args(args)
128 .stderr(Stdio::null())
129 .output()
130 .ok()?;
131 if !out.status.success() {
132 return None;
133 }
134 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
135}
136
137fn audacity_config_dir() -> Option<PathBuf> {
139 let appdata = match std::env::var("APPDATA").ok().filter(|v| !v.is_empty()) {
140 Some(a) => a,
141 None => {
142 let win = command_output("cmd.exe", &["/c", "echo %APPDATA%"])?;
143 command_output("wslpath", &["-u", &win])?
144 }
145 };
146 let dir = Path::new(&appdata).join("audacity");
147 dir.is_dir().then_some(dir)
148}
149
150fn find_audacity_macros_dir() -> Option<PathBuf> {
151 let dir = audacity_config_dir()?.join("Macros");
152 dir.is_dir().then_some(dir)
153}
154
155fn has_audacity4() -> bool {
157 audacity_config_dir()
158 .is_some_and(|d| d.join("Audacity4.ini").exists() || d.join("Audacity4").exists())
159}
160
161fn to_windows_path(linux_path: &str) -> String {
162 command_output("wslpath", &["-w", linux_path]).unwrap_or_else(|| linux_path.to_string())
163}
164
165fn macro_slug(show: &str) -> String {
166 if show.is_empty() {
167 DEFAULT_SLUG.to_uppercase()
168 } else {
169 show_slug(show).to_uppercase()
170 }
171}
172
173fn generate_audacity_macro(
175 output_dir: &str,
176 tag: &str,
177 layer_files: &[(String, String)],
178 show: &str,
179 season_title: Option<&str>,
180 episode_title: Option<&str>,
181 artist: &str,
182) -> anyhow::Result<Option<PathBuf>> {
183 let Some(macros_dir) = find_audacity_macros_dir() else {
184 return Ok(None);
185 };
186 let abs_output = abspath(Path::new(output_dir));
187 let mut lines = Vec::new();
188 for (_, filename) in layer_files {
189 if !filename.ends_with(".wav") {
190 continue;
191 }
192 let win = to_windows_path(&abs_output.join(filename).to_string_lossy());
193 lines.push(format!("Import2: Filename=\"{win}\""));
194 }
195 let title = match (
196 season_title.filter(|s| !s.is_empty()),
197 episode_title.filter(|s| !s.is_empty()),
198 ) {
199 (Some(s), Some(e)) => format!("{tag}: {s} - {e}"),
200 (None, Some(e)) => format!("{tag}: {e}"),
201 _ => tag.to_string(),
202 };
203 use chrono::Datelike;
204 let year = chrono::Local::now().year();
205 lines.push(format!(
206 "SetProject: X-Genre=\"Podcast\" X-Album=\"{show}\" X-Artist=\"{artist}\" X-Title=\"{title}\" X-Year=\"{year}\""
207 ));
208 let path = macros_dir.join(format!("{}_{tag}.txt", macro_slug(show)));
209 fs::write(&path, lines.join("\n") + "\n")?;
210 Ok(Some(path))
211}
212
213fn layers_repr(layer_files: &[(String, String)]) -> String {
215 let items: Vec<String> = layer_files
216 .iter()
217 .map(|(a, b)| {
218 format!(
219 "({}, {})",
220 xil_audio::fx::py_repr(a),
221 xil_audio::fx::py_repr(b)
222 )
223 })
224 .collect();
225 format!("[{}]", items.join(", "))
226}
227
228fn make_audacity_script(
230 tag: &str,
231 layer_files: &[(String, String)],
232 save_aup3: bool,
233 show: &str,
234) -> String {
235 let template = if save_aup3 {
236 SCRIPT_TEMPLATE_AUP3
237 } else {
238 SCRIPT_TEMPLATE
239 };
240 let show_label = if show.is_empty() { "Episode" } else { show };
241 template
242 .replace("@@XIL_LAYERS_REPR@@", &layers_repr(layer_files))
243 .replace("@@XIL_SHOW_LABEL@@", show_label)
244 .replace("@@XIL_TAG@@", tag)
245}
246
247fn derive_vintage_scenes_from_parsed(parsed_path: &Path) -> Vec<String> {
249 let Some(parsed) = fs::read_to_string(parsed_path)
250 .ok()
251 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
252 else {
253 return Vec::new();
254 };
255 let mut scenes: Vec<String> = Vec::new();
256 for e in parsed
257 .get("entries")
258 .and_then(Value::as_array)
259 .into_iter()
260 .flatten()
261 {
262 let Some(o) = e.as_object() else { continue };
263 let s = |k: &str| o.get(k).and_then(Value::as_str);
264 if s("type") == Some("direction")
265 && s("direction_type") == Some("VINTAGE FILTER")
266 && s("text").unwrap_or("").to_uppercase() == "VINTAGE FILTER ENGAGES"
267 {
268 if let Some(scene) = s("scene").filter(|x| !x.is_empty()) {
269 if !scenes.iter().any(|x| x == scene) {
270 scenes.push(scene.to_string());
271 }
272 }
273 }
274 }
275 scenes
276}
277
278fn fmt_pct_1(v: Option<f64>) -> String {
279 v.map(|x| format!("{x:.1}%"))
280 .unwrap_or_else(|| "100% (full)".into())
281}
282
283fn fmt_vol(v: Option<mix::config::Num>) -> String {
284 v.map(|n| format!("{:.0}%", n.f()))
285 .unwrap_or_else(|| "unity".into())
286}
287
288fn fmt_secs(v: Option<mix::config::Num>) -> String {
289 v.map(|n| format!("{:.1}s", n.f()))
290 .unwrap_or_else(|| "none".into())
291}
292
293fn speaker_filter_lines(cast: &IndexMap<String, Voice>) -> Vec<(String, String)> {
294 cast.iter()
295 .filter(|(_, v)| v.filter.truthy())
296 .map(|(k, v)| (k.clone(), v.filter.py_str()))
297 .collect()
298}
299
300fn dry_run_daw(
302 tag: &str,
303 plans: &[StemPlan],
304 output_dir: &str,
305 stems_dir: &str,
306 cast: &IndexMap<String, Voice>,
307 vintage_scenes: &[String],
308) -> anyhow::Result<()> {
309 validate_tag_for_script(tag)?;
310 let bg: Vec<&StemPlan> = plans.iter().filter(|p| p.is_background()).collect();
311 let count = |dt: &str| bg.iter().filter(|p| p.dt() == Some(dt)).count();
312 let sfx = plans
313 .iter()
314 .filter(|p| matches!(p.dt(), Some("SFX") | Some("BEAT")))
315 .count();
316 let dialogue: Vec<&StemPlan> = plans.iter().filter(|p| p.is_dialogue()).collect();
317 let vintage_count = if vintage_scenes.is_empty() {
318 0
319 } else {
320 dialogue
321 .iter()
322 .filter(|p| p.scene.as_ref().is_some_and(|s| vintage_scenes.contains(s)))
323 .count()
324 };
325 let stems_shown = if stems_dir.is_empty() {
326 format!("stems/{tag}")
327 } else {
328 stems_dir.to_string()
329 };
330
331 log::info(&format!("\n--- DAW Export Dry Run: {tag} ---"));
332 log::info(&format!(" Stems directory : {stems_shown}"));
333 log::info(&format!(" Output directory: {output_dir}/"));
334 log::info("");
335 log::info(" Layer Stems");
336 log::info(" ─────────────────────────────");
337 log::info(&format!(" dialogue {:3} stems", dialogue.len()));
338 if !vintage_scenes.is_empty() {
339 log::info(&format!(
340 " vintage scenes : {} ({vintage_count} stems — mono collapse + LPF 5kHz)",
341 vintage_scenes.join(", ")
342 ));
343 }
344 let filtered = speaker_filter_lines(cast);
345 if !filtered.is_empty() {
346 let parts: Vec<String> = filtered.iter().map(|(k, v)| format!("{k}={v}")).collect();
347 log::info(&format!(" per-speaker : {}", parts.join(" ")));
348 }
349 log::info(&format!(
350 " ambience {:3} stems (looped to scene boundaries)",
351 count("AMBIENCE")
352 ));
353 log::info(&format!(
354 " music {:3} stems (one-shot at cue points)",
355 count("MUSIC")
356 ));
357 log::info(&format!(
358 " vintage filter {:3} stems (crackle looped to DISENGAGES)",
359 count("VINTAGE FILTER")
360 ));
361 log::info(&format!(" sfx {sfx:3} stems"));
362 log::info("");
363 log::info(" Output files (all same duration as foreground track):");
364 for (_, suffix, desc) in LAYERS {
365 log::info(&format!(" {output_dir}/{tag}_{suffix}.wav — {desc}"));
366 }
367 log::info(&format!(" {output_dir}/{tag}_open_in_audacity.py"));
368 log::info("");
369 Ok(())
370}
371
372struct Ctx<'a> {
373 cast: &'a CastConfig,
374 stems_dir: String,
375 parsed_path: PathBuf,
376 output_dir: String,
377 tag: String,
378 slug: String,
379 sfx: Option<&'a SfxConfig>,
380 a: &'a Args,
381}
382
383fn write_timeline_outputs(
384 c: &Ctx,
385 plans: &[StemPlan],
386 index: &IndexMap<i64, Map<String, Value>>,
387 total_ms: i64,
388 timeline: &IndexMap<i64, i64>,
389 layers: [Vec<Label>; 4],
390) -> anyhow::Result<()> {
391 let [amb, mus, sfx, vf] = layers;
392 let dlg = mix::compute_dialogue_labels(plans, timeline)?;
393 let td = build_timeline_data(
394 &c.tag,
395 total_ms as f64 / 1000.0,
396 dlg,
397 amb,
398 mus,
399 sfx,
400 vf,
401 mix::derive_structure_bands(index, timeline, total_ms, "section"),
402 mix::derive_structure_bands(index, timeline, total_ms, "scene"),
403 );
404 let txt = Path::new(&c.output_dir).join(format!("{}_timeline.txt", c.tag));
405 render_text_timeline_map(&td, &txt, &c.slug)?;
406 log::info(&format!(" Written: {}", txt.display()));
407 if c.a.timeline {
408 println!("{}", render_terminal_timeline(&td));
409 }
410 if c.a.timeline_html {
411 let html = Path::new(&c.output_dir).join(format!("{}_timeline.html", c.tag));
412 render_html_timeline(
413 &td,
414 &html,
415 Some(Path::new(&c.stems_dir)),
416 &c.slug,
417 &c.tag,
418 Some(Path::new(&c.output_dir)),
419 )?;
420 log::info(&format!(" Written: {}", html.display()));
421 }
422 Ok(())
423}
424
425fn export_layer(
426 c: &Ctx,
427 layer: xil_audio::segment::Segment,
428 key: &str,
429 title: &str,
430 layer_files: &mut Vec<(String, String)>,
431) -> anyhow::Result<()> {
432 let fname = format!("{}_layer_{key}.wav", c.tag);
433 let wav = Path::new(&c.output_dir).join(&fname);
434 layer.export_wav(&wav)?;
435 drop(layer);
436 tag_wav(
437 &wav,
438 &c.cast.show,
439 Some(&format!("{} {title}", c.tag)),
440 Some(&c.cast.artist),
441 )?;
442 layer_files.push((title.to_string(), fname.clone()));
443 log::info(&format!(" Written: {}/{fname}", c.output_dir));
444 Ok(())
445}
446
447fn export_labels(
448 c: &Ctx,
449 key: &str,
450 title: &str,
451 labels: &[Label],
452 layer_files: &mut Vec<(String, String)>,
453) -> anyhow::Result<()> {
454 let fname = format!("{}_labels_{key}.txt", c.tag);
455 write_labels(&c.output_dir, &fname, labels)?;
456 layer_files.push((format!("Labels ({title})"), fname.clone()));
457 log::info(&format!(" Written: {}/{fname}", c.output_dir));
458 Ok(())
459}
460
461fn export_daw_layers(c: &Ctx) -> anyhow::Result<()> {
463 validate_tag_for_script(&c.tag)?;
464 let index = mix::load_entries_index(&c.parsed_path)?;
465 let plans = mix::collect_stem_plans(Path::new(&c.stems_dir), &index, c.sfx);
466 if plans.is_empty() {
467 log::warning(&format!(
468 "No stems found in {}/. Run XILP002 first.",
469 c.stems_dir
470 ));
471 return Ok(());
472 }
473 log::info(&format!(
474 "--- Building foreground timeline from {} stems ---",
475 plans.len()
476 ));
477 let parsed_scenes = derive_vintage_scenes_from_parsed(&c.parsed_path);
478 let config_scenes: Vec<String> = c.sfx.map(|s| s.vintage_scenes.clone()).unwrap_or_default();
479 let vintage_scenes = if parsed_scenes.is_empty() {
480 config_scenes.clone()
481 } else {
482 parsed_scenes.clone()
483 };
484 if !parsed_scenes.is_empty() && parsed_scenes != config_scenes && !config_scenes.is_empty() {
485 log::info(&format!(
486 " vintage_scenes: using parsed JSON ({} scenes) — sfx_config list ignored",
487 parsed_scenes.len()
488 ));
489 }
490 let cast = &c.cast.cast;
491 let (foreground, cue_timeline) =
492 mix::build_foreground(&plans, cast, c.a.gap_ms, &vintage_scenes)?;
493 if foreground.len_ms() == 0 {
494 log::warning("No foreground stems — cannot determine episode duration.");
495 return Ok(());
496 }
497 let total_ms = foreground.len_ms();
498 drop(foreground);
499 log::info(&format!(
500 " Episode duration: {:.1}s",
501 total_ms as f64 / 1000.0
502 ));
503 fs::create_dir_all(&c.output_dir)?;
504 let mut layer_files: Vec<(String, String)> = Vec::new();
505
506 log::info("--- Building dialogue layer ---");
507 if !vintage_scenes.is_empty() {
508 let n = plans
509 .iter()
510 .filter(|p| {
511 p.is_dialogue() && p.scene.as_ref().is_some_and(|s| vintage_scenes.contains(s))
512 })
513 .count();
514 log::info(&format!(
515 " vintage scenes : {} ({n} stems — mono collapse + LPF 5kHz)",
516 vintage_scenes.join(", ")
517 ));
518 }
519 for (speaker, fval) in speaker_filter_lines(cast) {
520 log::info(&format!(" per-speaker : {speaker} → {fval}"));
521 }
522 let (dlg, labels) =
523 mix::build_dialogue_layer(&plans, &cue_timeline, total_ms, cast, &vintage_scenes)?;
524 export_layer(c, dlg, "dialogue", "Dialogue", &mut layer_files)?;
525 export_labels(c, "dialogue", "Dialogue", &labels, &mut layer_files)?;
526
527 log::info("--- Building ambience layer ---");
528 for plan in mix::by_seq(&plans) {
529 if plan.dt() != Some("AMBIENCE") || plan.filepath.is_empty() {
530 continue;
531 }
532 log::info(&format!(
533 " seq {}: vol={} trim={} ramp_in={} ramp_out={} {}",
534 plan.seq,
535 fmt_vol(plan.volume_percentage),
536 fmt_pct_1(plan.play_duration),
537 fmt_secs(plan.ramp_in_seconds),
538 fmt_secs(plan.ramp_out_seconds),
539 xil_core::fsutil::basename(Path::new(&plan.filepath))
540 ));
541 }
542 let (amb, amb_labels) = mix::build_ambience_layer(&plans, &cue_timeline, total_ms, 0.0)?;
543 export_layer(c, amb, "ambience", "Ambience", &mut layer_files)?;
544 export_labels(c, "ambience", "Ambience", &amb_labels, &mut layer_files)?;
545
546 log::info("--- Building music layer ---");
547 for plan in mix::by_seq(&plans) {
548 if plan.dt() != Some("MUSIC") || plan.filepath.is_empty() {
549 continue;
550 }
551 log::info(&format!(
552 " seq {}: vol={} trim={} ramp_out={} {}",
553 plan.seq,
554 fmt_vol(plan.volume_percentage),
555 fmt_pct_1(plan.play_duration),
556 fmt_secs(plan.ramp_out_seconds),
557 xil_core::fsutil::basename(Path::new(&plan.filepath))
558 ));
559 }
560 let (mus, mus_labels) = mix::build_music_layer(&plans, &cue_timeline, total_ms, 0.0, true)?;
561 export_layer(c, mus, "music", "Music", &mut layer_files)?;
562 export_labels(c, "music", "Music", &mus_labels, &mut layer_files)?;
563
564 log::info("--- Building SFX layer ---");
565 let (sfx, sfx_labels) = mix::build_sfx_layer(&plans, &cue_timeline, total_ms)?;
566 export_layer(c, sfx, "sfx", "SFX", &mut layer_files)?;
567 export_labels(c, "sfx", "SFX", &sfx_labels, &mut layer_files)?;
568
569 log::info("--- Building vintage filter layer ---");
570 let (vf, vf_labels) = mix::build_vintage_filter_layer(&plans, &cue_timeline, total_ms, 0.0)?;
571 export_layer(c, vf, "vintage_filter", "Vintage Filter", &mut layer_files)?;
572 export_labels(
573 c,
574 "vintage_filter",
575 "Vintage Filter",
576 &vf_labels,
577 &mut layer_files,
578 )?;
579
580 let script_fname = format!("{}_open_in_audacity.py", c.tag);
581 let script_path = Path::new(&c.output_dir).join(&script_fname);
582 fs::write(
583 &script_path,
584 make_audacity_script(&c.tag, &layer_files, c.a.save_aup3, &c.cast.show),
585 )?;
586 #[cfg(unix)]
587 {
588 use std::os::unix::fs::PermissionsExt;
589 fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
590 }
591 log::info(&format!(" Written: {}/{script_fname}", c.output_dir));
592
593 if c.a.macro_ {
594 let path = generate_audacity_macro(
595 &c.output_dir,
596 &c.tag,
597 &layer_files,
598 &c.cast.show,
599 c.cast.season_title.as_deref(),
600 c.cast.title.as_deref(),
601 &c.cast.artist,
602 )?;
603 match path {
604 Some(p) => {
605 log::info(&format!(" Written: {}", p.display()));
606 if has_audacity4() {
607 log::warning(
608 "Audacity 4 has no Macro Manager — this macro runs in Audacity 3 only. In Audacity 4, import the layer WAVs manually (see the helper script).",
609 );
610 }
611 }
612 None => log::warning("Audacity Macros directory not found — macro not written."),
613 }
614 }
615
616 write_timeline_outputs(
617 c,
618 &plans,
619 &index,
620 total_ms,
621 &cue_timeline,
622 [amb_labels, mus_labels, sfx_labels, vf_labels],
623 )?;
624
625 log::info("");
626 log::info(&format!(
627 "--- Done! {} layer WAVs in {}/ ---",
628 layer_files.len(),
629 c.output_dir
630 ));
631 log::info(&format!(
632 " Import into Audacity: python {}/{script_fname}",
633 c.output_dir
634 ));
635 if c.a.macro_ {
636 log::info(&format!(
637 " Audacity macro: Tools → Macros → {}_{} → Apply to Project (Audacity 3 only)",
638 macro_slug(&c.cast.show),
639 c.tag
640 ));
641 }
642 if c.a.save_aup3 {
643 log::info(&format!(
644 " Will save project: {}/{}.aup3",
645 c.output_dir, c.tag
646 ));
647 }
648 Ok(())
649}
650
651pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
652 log::init("daw");
653 let _banner = banner::begin(super::prog(), &super::argv_line(args));
654 let a: Args = match super::parse_or_exit("xil-daw", args) {
655 Ok(a) => a,
656 Err(code) => return Ok(code),
657 };
658 execute(&a)?;
659 Ok(0)
660}
661
662fn execute(a: &Args) -> anyhow::Result<()> {
663 let arg_tag = a
664 .episode
665 .clone()
666 .or_else(|| a.tag.clone())
667 .unwrap_or_default();
668 let slug = resolve_slug(a.show.as_deref(), "project.json");
669 let p = derive_paths(&slug, &arg_tag);
670 let cast_path = &p["cast"];
671 if !cast_path.exists() {
672 log::error(&format!("Cast config not found: {}", cast_path.display()));
673 log::info("Run XILP001 first or check your --episode flag.");
674 return Ok(());
675 }
676 let cast = CastConfig::load(cast_path)?;
677 let tag = cast.tag.clone();
678 let stems_dir = mix::join(&workspace_root().join("stems").join(&slug), &tag);
679 let parsed_path = a
680 .parsed
681 .clone()
682 .filter(|s| !s.is_empty())
683 .map(PathBuf::from)
684 .unwrap_or_else(|| p["parsed"].clone());
685 let output_dir = a
686 .output_dir
687 .clone()
688 .filter(|s| !s.is_empty())
689 .unwrap_or_else(|| p["daw"].to_string_lossy().into_owned());
690
691 if !parsed_path.exists() {
692 log::warning(&format!(
693 "Parsed JSON not found: {}. Run XILP001 first.",
694 xil_audio::fx::py_repr(&parsed_path.to_string_lossy())
695 ));
696 return Ok(());
697 }
698 let sfx = if p["sfx"].exists() {
699 Some(SfxConfig::load(&p["sfx"])?)
700 } else {
701 None
702 };
703
704 let index = mix::load_entries_index(&parsed_path)?;
705 let plans = mix::collect_stem_plans(Path::new(&stems_dir), &index, sfx.as_ref());
706 let derived = derive_vintage_scenes_from_parsed(&parsed_path);
707 let config_scenes: Vec<String> = sfx
708 .as_ref()
709 .map(|s| s.vintage_scenes.clone())
710 .unwrap_or_default();
711 let resolved = if derived.is_empty() {
712 config_scenes.clone()
713 } else {
714 derived.clone()
715 };
716 if !derived.is_empty() && derived != config_scenes && !config_scenes.is_empty() {
717 log::info(&format!(
718 " vintage_scenes: derived from parsed JSON ({} scenes)",
719 derived.len()
720 ));
721 }
722
723 let c = Ctx {
724 cast: &cast,
725 stems_dir,
726 parsed_path,
727 output_dir,
728 tag,
729 slug,
730 sfx: sfx.as_ref(),
731 a,
732 };
733
734 if a.dry_run {
735 dry_run_daw(
736 &c.tag,
737 &plans,
738 &c.output_dir,
739 &c.stems_dir,
740 &cast.cast,
741 &resolved,
742 )?;
743 let (total_ms, timeline) = mix::build_foreground_timeline_only(&plans, a.gap_ms)?;
744 let amb = mix::compute_ambience_labels(&plans, &timeline, total_ms);
745 let mus = mix::compute_music_labels(&plans, &timeline, total_ms, true)?;
746 let sfx_l = mix::compute_sfx_labels(&plans, &timeline)?;
747 let vf = mix::compute_vintage_filter_labels(&plans, &timeline, total_ms);
748 return write_timeline_outputs(
749 &c,
750 &plans,
751 &index,
752 total_ms,
753 &timeline,
754 [amb, mus, sfx_l, vf],
755 );
756 }
757 export_daw_layers(&c)
758}
759
760pub fn command() -> clap::Command {
762 <Args as clap::CommandFactory>::command()
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn unsafe_tags_are_refused() {
771 assert!(validate_tag_for_script("S01E01").is_ok());
772 assert!(validate_tag_for_script("V01-C_3").is_ok());
773 assert!(validate_tag_for_script("S01E01\"; rm").is_err());
774 assert!(validate_tag_for_script("").is_err());
775 }
776
777 #[test]
778 fn layers_repr_is_python_repr() {
779 let files = vec![(
780 "Dialogue".to_string(),
781 "S01E01_layer_dialogue.wav".to_string(),
782 )];
783 assert_eq!(
784 layers_repr(&files),
785 "[('Dialogue', 'S01E01_layer_dialogue.wav')]"
786 );
787 }
788
789 #[test]
790 fn script_template_is_filled() {
791 let files = vec![("SFX".to_string(), "T_layer_sfx.wav".to_string())];
792 let s = make_audacity_script("T", &files, true, "");
793 assert!(!s.contains("@@XIL_"));
794 assert!(s.contains("\"\"\"Open Episode T DAW layers in Audacity."));
795 assert!(s.contains("T.aup3"));
796 }
797}