Skip to main content

xil/mix/
timeline.rs

1//! Multitrack timeline views. Port of `timeline_viz.py`.
2//!
3//! The HTML page's ~900 lines of CSS and JavaScript live in
4//! `templates/timeline.html.tmpl`, rendered out of the reference Python by
5//! `tools/templates/extract.py` with a marker for each dynamic value.
6
7use 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
20/// `TimelineData`: the five layers, plus section and scene bands.
21pub 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/// `build_timeline_data(...)`.
30#[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
57/// `_format_time(seconds)` — `M:SS`.
58pub 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
63/// `shutil.get_terminal_size((120, 24)).columns`.
64fn 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    // SAFETY: TIOCGWINSZ fills a `struct winsize`; fd 1 is stdout.
101    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
110/// `render_terminal_timeline(data)`.
111pub 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
245/// `_fmt_mmss_tenths(seconds)` — `M:SS.t`, right-aligned to 7.
246fn 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
253/// `render_text_timeline_map(data, output_path, slug=...)`.
254pub 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
352/// `os.path.relpath(full, out_dir)` with `/` separators and an mtime
353/// cache-buster.
354fn 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
367/// `render_html_timeline(data, output_path, stems_dir, slug=, tag=, layers_dir=)`.
368pub 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        // Last: the data JSON is the only value that could contain a marker.
460        .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}