Skip to main content

alpha2_ffmpeg_bridge/
log.rs

1//! `FFmpeg`'s warning and error lines, kept for typed errors.
2//!
3//! `FFmpeg` often explains a failure only in its log (an `OSStatus`, an NVENC status), while the
4//! returned code says `EINVAL`. An error path takes a [`log_mark`] before the call and reads
5//! [`log_since`] after it. Lines are filtered by the logging context's class, not by thread, so
6//! concurrent exports can see each other's lines.
7
8use std::collections::VecDeque;
9use std::ffi::{CStr, c_char, c_int};
10use std::sync::{Mutex, Once};
11
12const RING_LINES: usize = 256;
13const DETAIL_LINES: usize = 4;
14const DETAIL_CHARS: usize = 200;
15
16/// One warning or error line `FFmpeg` logged.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct LogLine {
19    /// Logging order.
20    pub seq: u64,
21    /// `FFmpeg`'s level (`AV_LOG_ERROR` is 16, `AV_LOG_WARNING` 24).
22    pub level: i32,
23    /// The logging context's name (`h264_nvenc`), or empty.
24    pub class: String,
25    /// Without the trailing newline.
26    pub text: String,
27}
28
29/// A point in the log for [`log_since`].
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct LogMark(u64);
32
33struct Ring {
34    next: u64,
35    lines: VecDeque<LogLine>,
36}
37
38static RING: Mutex<Ring> = Mutex::new(Ring {
39    next: 0,
40    lines: VecDeque::new(),
41});
42
43unsafe extern "C" {
44    fn alpha2_log_install();
45}
46
47/// Routes `FFmpeg`'s log through the capture. Idempotent; [`log_mark`] calls it.
48pub fn install_log_capture() {
49    static INSTALL: Once = Once::new();
50    // SAFETY: the shim only sets FFmpeg's log callback, which may happen at any time.
51    INSTALL.call_once(|| unsafe { alpha2_log_install() });
52}
53
54/// The current end of the log. Installs the capture first.
55#[must_use]
56pub fn log_mark() -> LogMark {
57    install_log_capture();
58    LogMark(ring().next)
59}
60
61/// Lines logged since `mark` by one of `classes` (any when empty), oldest first. Lines the ring has
62/// dropped are missing.
63#[must_use]
64pub fn log_since(mark: LogMark, classes: &[&str]) -> Vec<LogLine> {
65    ring()
66        .lines
67        .iter()
68        .filter(|line| line.seq >= mark.0)
69        .filter(|line| classes.is_empty() || classes.contains(&line.class.as_str()))
70        .cloned()
71        .collect()
72}
73
74/// Formats the last four lines as an error message tail (`"; FFmpeg said: [class] text | ..."`),
75/// each cut to 200 characters with non-ASCII escaped. Empty when there are no lines.
76#[must_use]
77pub fn log_detail(lines: &[LogLine]) -> String {
78    if lines.is_empty() {
79        return String::new();
80    }
81    let quoted = lines[lines.len().saturating_sub(DETAIL_LINES)..]
82        .iter()
83        .map(|line| {
84            let text = ascii(line.text.trim())
85                .chars()
86                .take(DETAIL_CHARS)
87                .collect::<String>();
88            if line.class.is_empty() {
89                text
90            } else {
91                format!("[{}] {text}", ascii(&line.class))
92            }
93        })
94        .collect::<Vec<_>>();
95    format!("; FFmpeg said: {}", quoted.join(" | "))
96}
97
98fn ascii(text: &str) -> String {
99    text.chars()
100        .map(|c| {
101            if c == ' ' || c.is_ascii_graphic() {
102                c.to_string()
103            } else {
104                c.escape_unicode().to_string()
105            }
106        })
107        .collect()
108}
109
110fn ring() -> std::sync::MutexGuard<'static, Ring> {
111    // Lines are pushed whole, so a poisoned ring is still consistent.
112    RING.lock()
113        .unwrap_or_else(std::sync::PoisonError::into_inner)
114}
115
116fn record(level: i32, class: String, text: &str) {
117    let mut ring = ring();
118    let seq = ring.next;
119    ring.next += 1;
120    if ring.lines.len() == RING_LINES {
121        ring.lines.pop_front();
122    }
123    ring.lines.push_back(LogLine {
124        seq,
125        level,
126        class,
127        text: text.trim_end_matches(['\n', '\r']).to_owned(),
128    });
129}
130
131/// Called by the log shim, on the logging thread, for each warning or error line.
132///
133/// # Safety
134/// `class_name` and `line` are null or NUL-terminated strings live for the call.
135#[unsafe(no_mangle)]
136unsafe extern "C" fn alpha2_log_record(
137    level: c_int,
138    class_name: *const c_char,
139    line: *const c_char,
140) {
141    let text = |pointer: *const c_char| {
142        if pointer.is_null() {
143            String::new()
144        } else {
145            // SAFETY: the shim passes NUL-terminated strings that outlive this call.
146            unsafe { CStr::from_ptr(pointer) }
147                .to_string_lossy()
148                .into_owned()
149        }
150    };
151    // A panic must not unwind into C.
152    let _ = std::panic::catch_unwind(|| record(level, text(class_name), &text(line)));
153}
154
155#[cfg(test)]
156pub(crate) fn record_for_tests(level: i32, class: &str, text: &str) {
157    record(level, class.to_owned(), text);
158}