1use std::fs;
8use std::path::Path;
9
10use indexmap::IndexMap;
11use serde_json::{Map, Value};
12use xil_core::fsutil::{abspath, relpath};
13use xil_core::pyfmt::html_escape;
14use xil_core::pyjson::{dumps, py_float, Style};
15
16use super::Label;
17
18const HTML_TEMPLATE: &str = include_str!("templates/timeline.html.tmpl");
19
20pub struct TimelineData {
22 pub tag: String,
23 pub total_duration_s: f64,
24 pub layers: IndexMap<&'static str, Vec<Label>>,
25 pub sections: Vec<Label>,
26 pub scenes: Vec<Label>,
27}
28
29#[allow(clippy::too_many_arguments)]
31pub fn build_timeline_data(
32 tag: &str,
33 total_s: f64,
34 dlg: Vec<Label>,
35 amb: Vec<Label>,
36 mus: Vec<Label>,
37 sfx: Vec<Label>,
38 vf: Vec<Label>,
39 sections: Vec<Label>,
40 scenes: Vec<Label>,
41) -> TimelineData {
42 let mut layers = IndexMap::new();
43 layers.insert("dialogue", dlg);
44 layers.insert("ambience", amb);
45 layers.insert("music", mus);
46 layers.insert("sfx", sfx);
47 layers.insert("vintage_filter", vf);
48 TimelineData {
49 tag: tag.to_string(),
50 total_duration_s: total_s,
51 layers,
52 sections,
53 scenes,
54 }
55}
56
57pub fn format_time(seconds: f64) -> String {
59 let whole = seconds as i64;
60 format!("{}:{:02}", whole.div_euclid(60), whole.rem_euclid(60))
61}
62
63fn terminal_columns() -> usize {
65 let env_cols = std::env::var("COLUMNS")
66 .ok()
67 .and_then(|v| v.parse::<i64>().ok())
68 .unwrap_or(0);
69 if env_cols > 0 {
70 return env_cols as usize;
71 }
72 match stdout_columns() {
73 Some(c) if c > 0 => c,
74 _ => 120,
75 }
76}
77
78#[cfg(any(target_os = "linux", target_os = "macos"))]
79fn stdout_columns() -> Option<usize> {
80 #[repr(C)]
81 struct Winsize {
82 ws_row: u16,
83 ws_col: u16,
84 ws_xpixel: u16,
85 ws_ypixel: u16,
86 }
87 extern "C" {
88 fn ioctl(fd: i32, request: std::ffi::c_ulong, ...) -> i32;
89 }
90 #[cfg(target_os = "linux")]
91 const TIOCGWINSZ: std::ffi::c_ulong = 0x5413;
92 #[cfg(target_os = "macos")]
93 const TIOCGWINSZ: std::ffi::c_ulong = 0x4008_7468;
94 let mut ws = Winsize {
95 ws_row: 0,
96 ws_col: 0,
97 ws_xpixel: 0,
98 ws_ypixel: 0,
99 };
100 let rc = unsafe { ioctl(1, TIOCGWINSZ, &mut ws as *mut Winsize) };
102 (rc == 0).then_some(ws.ws_col as usize)
103}
104
105#[cfg(not(any(target_os = "linux", target_os = "macos")))]
106fn stdout_columns() -> Option<usize> {
107 None
108}
109
110pub fn render_terminal_timeline(data: &TimelineData) -> String {
112 render_terminal_timeline_width(data, terminal_columns())
113}
114
115pub fn render_terminal_timeline_width(data: &TimelineData, width: usize) -> String {
116 let total_s = data.total_duration_s;
117 if total_s <= 0.0 {
118 return format!("--- Timeline: {} (0:00) ---\n (no audio)\n", data.tag);
119 }
120 let label_col = 12usize;
121 let track_width = (width as i64 - label_col as i64 - 2).max(20) as usize;
122 let interval = if total_s <= 180.0 {
123 30
124 } else if total_s <= 600.0 {
125 60
126 } else {
127 120
128 };
129
130 let mut lines: Vec<String> = Vec::new();
131 lines.push(format!(
132 "--- Timeline: {} ({}) ---",
133 data.tag,
134 format_time(total_s)
135 ));
136 lines.push(String::new());
137
138 let num_ticks = (total_s / interval as f64).floor() as i64 + 1;
139 let col_of = |t: f64| (t / total_s * track_width as f64) as i64;
140
141 let mut ruler: Vec<char> = " ".repeat(label_col).chars().collect();
142 for i in 0..num_ticks {
143 let t = (i * interval) as f64;
144 let col = col_of(t);
145 if col >= track_width as i64 {
146 break;
147 }
148 let pad = col - (ruler.len() as i64 - label_col as i64);
149 if pad > 0 {
150 ruler.extend(std::iter::repeat(' ').take(pad as usize));
151 }
152 ruler.extend(format_time(t).chars());
153 }
154
155 let mut ticks = vec![' '; track_width];
156 for i in 0..num_ticks {
157 let t = (i * interval) as f64;
158 let col = col_of(t);
159 if col >= track_width as i64 {
160 break;
161 }
162 let col = col as usize;
163 ticks[col] = if i == 0 {
164 '├'
165 } else if col == track_width - 1 {
166 '┤'
167 } else {
168 '┼'
169 };
170 }
171 for c in ticks.iter_mut() {
172 if *c == ' ' {
173 *c = '─';
174 }
175 }
176 lines.push(ruler.into_iter().collect());
177 lines.push(format!(
178 "{}{}",
179 " ".repeat(label_col),
180 ticks.iter().collect::<String>()
181 ));
182 lines.push(String::new());
183
184 let config = [
185 ("dialogue", "DIALOGUE", '█'),
186 ("ambience", "AMBIENCE", '▓'),
187 ("music", "MUSIC", '█'),
188 ("sfx", "SFX", '█'),
189 ("vintage_filter", "VTG FILTER", '▒'),
190 ];
191 for (key, name, fill) in config {
192 let spans = data.layers.get(key).map(Vec::as_slice).unwrap_or(&[]);
193 if spans.is_empty() {
194 continue;
195 }
196 let mut bar = vec![' '; track_width];
197 let mut positions: Vec<(usize, Vec<char>)> = Vec::new();
198 for span in spans {
199 let col_start = (span.start_s / total_s * track_width as f64) as i64;
200 let col_end = (span.end_s / total_s * track_width as f64) as i64;
201 let col_start = col_start.min(track_width as i64 - 1).max(0);
202 let col_end = col_end.min(track_width as i64).max(col_start + 1);
203 let ch = if col_end - col_start <= 1 && key == "sfx" {
204 if span.end_s - span.start_s < 1.5 {
205 '·'
206 } else {
207 fill
208 }
209 } else {
210 fill
211 };
212 for c in col_start..col_end {
213 bar[c as usize] = ch;
214 }
215 let mut label: Vec<char> = span.text.chars().collect();
216 if label.len() > 12 {
217 label.truncate(11);
218 label.push('…');
219 }
220 positions.push((col_start as usize, label));
221 }
222 let mut label_row = vec![' '; track_width];
223 for (col, lbl) in positions {
224 let end = (col + lbl.len()).min(track_width);
225 if (col..end).all(|i| label_row[i] == ' ') {
226 for (i, ch) in lbl.iter().enumerate() {
227 if col + i < track_width {
228 label_row[col + i] = *ch;
229 }
230 }
231 }
232 }
233 let padded = format!(" {:<width$}", name, width = label_col - 2);
234 lines.push(format!("{padded}{}", bar.iter().collect::<String>()));
235 lines.push(format!(
236 "{}{}",
237 " ".repeat(label_col),
238 label_row.iter().collect::<String>()
239 ));
240 lines.push(String::new());
241 }
242 lines.join("\n")
243}
244
245fn fmt_mmss_tenths(seconds: f64) -> String {
247 let m = (seconds as i64).div_euclid(60);
248 let s = seconds - (m * 60) as f64;
249 let text = format!("{m}:{s:04.1}");
250 format!("{text:>7}")
251}
252
253pub fn render_text_timeline_map(
255 data: &TimelineData,
256 output_path: &Path,
257 slug: &str,
258) -> std::io::Result<()> {
259 let empty = Vec::new();
260 let dialogue = data.layers.get("dialogue").unwrap_or(&empty);
261 let sfx = data.layers.get("sfx").unwrap_or(&empty);
262 let mut spans: Vec<(bool, &Label)> = dialogue
263 .iter()
264 .map(|l| (true, l))
265 .chain(sfx.iter().map(|l| (false, l)))
266 .collect();
267 spans.sort_by(|a, b| {
268 a.1.start_s
269 .partial_cmp(&b.1.start_s)
270 .unwrap_or(std::cmp::Ordering::Equal)
271 .then(a.1.seq.unwrap_or(0).cmp(&b.1.seq.unwrap_or(0)))
272 });
273 let show_part = if slug.is_empty() {
274 String::new()
275 } else {
276 format!(" — {slug}")
277 };
278 let mut lines = vec![
279 format!(
280 "# Timeline map: {}{show_part} ({})",
281 data.tag,
282 format_time(data.total_duration_s)
283 ),
284 "# dialogue + SFX foreground timing; music/ambience omitted".to_string(),
285 "#".to_string(),
286 "# START END LAYER SEQ WHO/WHAT".to_string(),
287 ];
288 for (is_dlg, sp) in spans {
289 let layer = if is_dlg { "DLG" } else { "SFX" };
290 let seq = match sp.seq {
291 Some(s) => format!("#{s:03}"),
292 None => " ".to_string(),
293 };
294 let who = match (&sp.snippet, is_dlg) {
295 (Some(snip), true) if !snip.is_empty() => format!("{} “{snip}…”", sp.text),
296 _ => sp.text.clone(),
297 };
298 lines.push(format!(
299 " {} – {} {layer} {seq} {who}",
300 fmt_mmss_tenths(sp.start_s),
301 fmt_mmss_tenths(sp.end_s)
302 ));
303 }
304 if let Some(parent) = output_path.parent().filter(|p| !p.as_os_str().is_empty()) {
305 fs::create_dir_all(parent)?;
306 }
307 fs::write(output_path, lines.join("\n") + "\n")
308}
309
310fn opt_json<T>(v: &Option<T>, f: impl Fn(&T) -> Value) -> Value {
311 v.as_ref().map(f).unwrap_or(Value::Null)
312}
313
314fn span_json(sp: &Label) -> Value {
315 let mut m = Map::new();
316 m.insert("start_s".into(), py_float(sp.start_s));
317 m.insert("end_s".into(), py_float(sp.end_s));
318 m.insert("label".into(), Value::String(sp.text.clone()));
319 m.insert("ramp_in_s".into(), opt_json(&sp.ramp_in_s, |n| n.to_json()));
320 m.insert(
321 "ramp_out_s".into(),
322 opt_json(&sp.ramp_out_s, |n| n.to_json()),
323 );
324 m.insert(
325 "play_duration".into(),
326 opt_json(&sp.play_duration, |x| py_float(*x)),
327 );
328 m.insert(
329 "snippet".into(),
330 opt_json(&sp.snippet, |s| Value::String(s.clone())),
331 );
332 m.insert(
333 "volume_pct".into(),
334 opt_json(&sp.volume_pct, |n| n.to_json()),
335 );
336 m.insert("seq".into(), opt_json(&sp.seq, |s| Value::from(*s)));
337 m.insert(
338 "tts_model".into(),
339 opt_json(&sp.tts_model, |s| Value::String(s.clone())),
340 );
341 Value::Object(m)
342}
343
344fn band_json(sp: &Label) -> Value {
345 let mut m = Map::new();
346 m.insert("start_s".into(), py_float(sp.start_s));
347 m.insert("end_s".into(), py_float(sp.end_s));
348 m.insert("label".into(), Value::String(sp.text.clone()));
349 Value::Object(m)
350}
351
352fn rel_audio(full_abs: &Path, out_dir: &Path) -> String {
355 let rel = relpath(full_abs, out_dir)
356 .to_string_lossy()
357 .replace(std::path::MAIN_SEPARATOR, "/");
358 let mtime = fs::metadata(full_abs)
359 .and_then(|m| m.modified())
360 .ok()
361 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
362 .map(|d| d.as_secs())
363 .unwrap_or(0);
364 format!("{rel}?v={mtime}")
365}
366
367pub fn render_html_timeline(
369 data: &TimelineData,
370 output_path: &Path,
371 stems_dir: Option<&Path>,
372 slug: &str,
373 tag: &str,
374 layers_dir: Option<&Path>,
375) -> std::io::Result<()> {
376 let out_dir = abspath(output_path)
377 .parent()
378 .map(Path::to_path_buf)
379 .unwrap_or_default();
380
381 let seq_re = regex::Regex::new(r"^(n?)(\d+)_").expect("static regex");
382 let mut clips = Map::new();
383 if let Some(dir) = stems_dir.filter(|d| d.is_dir()) {
384 let mut names: Vec<String> = fs::read_dir(dir)?
385 .filter_map(Result::ok)
386 .map(|e| e.file_name().to_string_lossy().into_owned())
387 .collect();
388 names.sort();
389 for fname in names {
390 if !fname.ends_with(".mp3") {
391 continue;
392 }
393 if let Some(m) = seq_re.captures(&fname) {
394 let Ok(n) = m[2].parse::<i64>() else { continue };
395 let seq = if &m[1] == "n" { -n } else { n };
396 let full = abspath(&dir.join(&fname));
397 clips.insert(seq.to_string(), Value::String(rel_audio(&full, &out_dir)));
398 }
399 }
400 }
401 let clips_json = dumps(&Value::Object(clips), Style::COMPACT);
402
403 let mut layers = Map::new();
404 for (key, spans) in &data.layers {
405 layers.insert(
406 key.to_string(),
407 Value::Array(spans.iter().map(span_json).collect()),
408 );
409 }
410 let mut json_data = Map::new();
411 json_data.insert("tag".into(), Value::String(data.tag.clone()));
412 json_data.insert("total_duration_s".into(), py_float(data.total_duration_s));
413 json_data.insert("layers".into(), Value::Object(layers));
414 json_data.insert(
415 "sections".into(),
416 Value::Array(data.sections.iter().map(band_json).collect()),
417 );
418 json_data.insert(
419 "scenes".into(),
420 Value::Array(data.scenes.iter().map(band_json).collect()),
421 );
422
423 let span_count: usize = data.layers.values().map(Vec::len).sum();
424 let slug_or_tag = if slug.is_empty() { &data.tag } else { slug };
425 let tag_or_tag = if tag.is_empty() { &data.tag } else { tag };
426 let slug_js = format!(
427 "const XIL_SLUG = {};\nconst XIL_TAG = {};",
428 dumps(&Value::String(slug_or_tag.to_string()), Style::COMPACT),
429 dumps(&Value::String(tag_or_tag.to_string()), Style::COMPACT)
430 );
431
432 let mut layer_audio = Map::new();
433 if let Some(dir) = layers_dir.filter(|d| d.is_dir()) {
434 for key in ["dialogue", "sfx", "music", "ambience", "vintage_filter"] {
435 let wav = dir.join(format!("{}_layer_{key}.wav", data.tag));
436 if wav.exists() {
437 layer_audio.insert(
438 key.into(),
439 Value::String(rel_audio(&abspath(&wav), &out_dir)),
440 );
441 }
442 }
443 }
444
445 let content = HTML_TEMPLATE
446 .replace("@@XIL_TAG_ESCAPED@@", &html_escape(&data.tag))
447 .replace("@@XIL_DURATION@@", &format_time(data.total_duration_s))
448 .replace("@@XIL_SPAN_COUNT@@", &span_count.to_string())
449 .replace("@@XIL_CLIPS_JSON@@", &clips_json)
450 .replace(
451 "@@XIL_GENERATED_AT@@",
452 &chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(),
453 )
454 .replace("@@XIL_SLUG_JS@@", &slug_js)
455 .replace(
456 "@@XIL_LAYER_AUDIO_JSON@@",
457 &dumps(&Value::Object(layer_audio), Style::COMPACT),
458 )
459 .replace(
461 "@@XIL_DATA_JSON@@",
462 &dumps(&Value::Object(json_data), Style::COMPACT),
463 );
464
465 if let Some(parent) = output_path.parent().filter(|p| !p.as_os_str().is_empty()) {
466 fs::create_dir_all(parent)?;
467 }
468 fs::write(output_path, content)
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn time_formats() {
477 assert_eq!(format_time(0.0), "0:00");
478 assert_eq!(format_time(125.9), "2:05");
479 assert_eq!(fmt_mmss_tenths(5.25), " 0:05.2");
480 assert_eq!(fmt_mmss_tenths(65.35), " 1:05.3");
481 }
482
483 #[test]
484 fn empty_timeline_says_so() {
485 let td = build_timeline_data(
486 "S01E01",
487 0.0,
488 vec![],
489 vec![],
490 vec![],
491 vec![],
492 vec![],
493 vec![],
494 vec![],
495 );
496 assert_eq!(
497 render_terminal_timeline_width(&td, 80),
498 "--- Timeline: S01E01 (0:00) ---\n (no audio)\n"
499 );
500 }
501
502 #[test]
503 fn template_carries_every_marker() {
504 for m in [
505 "@@XIL_TAG_ESCAPED@@",
506 "@@XIL_DURATION@@",
507 "@@XIL_SPAN_COUNT@@",
508 "@@XIL_DATA_JSON@@",
509 "@@XIL_CLIPS_JSON@@",
510 "@@XIL_GENERATED_AT@@",
511 "@@XIL_SLUG_JS@@",
512 "@@XIL_LAYER_AUDIO_JSON@@",
513 ] {
514 assert!(HTML_TEMPLATE.contains(m), "{m}");
515 }
516 }
517}