Skip to main content

rns_git/
media.rs

1//! Optional WebP conversion for Nomad Network media responses.
2//!
3//! Temporary files are owned by the conversion call. The returned bytes belong
4//! to the Resource, so disconnects cannot leave link-owned spool directories.
5use std::ffi::OsStr;
6use std::io::{Read, Seek, SeekFrom, Write};
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{Duration, Instant};
11
12const TIMEOUT: Duration = Duration::from_secs(8);
13static NO_BACKEND_LOGGED: AtomicBool = AtomicBool::new(false);
14const BACKENDS: &[(&str, &[&str])] = &[
15    ("magick", &["-", "webp:-"]),
16    ("convert", &["-", "webp:-"]),
17    ("gm", &["convert", "-", "webp:-"]),
18    (
19        "ffmpeg",
20        &[
21            "-y",
22            "-loglevel",
23            "error",
24            "-i",
25            "-",
26            "-f",
27            "webp",
28            "pipe:1",
29        ],
30    ),
31    (
32        "avconv",
33        &[
34            "-y",
35            "-loglevel",
36            "error",
37            "-i",
38            "-",
39            "-f",
40            "webp",
41            "pipe:1",
42        ],
43    ),
44];
45
46fn select_backend(
47    forced: Option<&OsStr>,
48    available: impl Fn(&str) -> bool,
49) -> Option<(&'static str, &'static [&'static str])> {
50    BACKENDS
51        .iter()
52        .copied()
53        .find(|(name, _)| forced.is_none_or(|forced| forced == OsStr::new(name)) && available(name))
54}
55
56fn executable_available(name: &str) -> bool {
57    std::env::var_os("PATH").is_some_and(|path| {
58        std::env::split_paths(&path).any(|directory| {
59            let path = directory.join(name);
60            let Ok(metadata) = path.metadata() else {
61                return false;
62            };
63            if !metadata.is_file() {
64                return false;
65            }
66            #[cfg(unix)]
67            {
68                use std::os::unix::fs::PermissionsExt;
69                metadata.permissions().mode() & 0o111 != 0
70            }
71            #[cfg(not(unix))]
72            {
73                true
74            }
75        })
76    })
77}
78
79pub(crate) fn convert_to_webp(input: &[u8]) -> Option<Vec<u8>> {
80    convert_with_options(
81        input,
82        ConversionOptions {
83            quality: None,
84            ..Default::default()
85        },
86    )
87}
88
89/// Encoding controls for standalone file conversion.
90#[derive(Debug, Clone, Copy)]
91pub struct ConversionOptions {
92    /// Encoder quality, clamped to 1–100; None uses the encoder default.
93    pub quality: Option<i32>,
94    /// Preserve aspect ratio and shrink to fit; zero/None disables resizing.
95    pub max_dimension: Option<u32>,
96    pub timeout: Duration,
97}
98
99impl Default for ConversionOptions {
100    fn default() -> Self {
101        Self {
102            quality: Some(85),
103            max_dimension: None,
104            timeout: TIMEOUT,
105        }
106    }
107}
108
109/// Convert a file into an owned temporary WebP file, removed when dropped.
110/// The source is never modified; unavailable backends or conversion failures
111/// return None. Call `persist` on the returned file to retain it permanently.
112pub fn convert_file_to_webp(
113    source: impl AsRef<Path>,
114    options: ConversionOptions,
115) -> Option<tempfile::NamedTempFile> {
116    let input = std::fs::read(source).ok()?;
117    let output = convert_with_options(&input, options)?;
118    let mut file = tempfile::Builder::new()
119        .prefix("rns_media_")
120        .suffix(".webp")
121        .tempfile()
122        .ok()?;
123    file.write_all(&output).ok()?;
124    file.seek(SeekFrom::Start(0)).ok()?;
125    Some(file)
126}
127
128fn configured_args(name: &str, args: &[&str], options: ConversionOptions) -> Vec<String> {
129    let mut result: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
130    let mut extra = Vec::new();
131    if let Some(quality) = options.quality {
132        extra.extend(["-quality".into(), quality.clamp(1, 100).to_string()]);
133    }
134    let image_magick = matches!(name, "magick" | "convert" | "gm");
135    if let Some(dimension) = options.max_dimension.filter(|dimension| *dimension > 0) {
136        if image_magick {
137            extra.extend(["-resize".into(), format!("{dimension}x{dimension}>")]);
138        } else {
139            extra.extend(["-vf".into(), format!("scale='min(iw,{dimension})':'min(ih,{dimension})':force_original_aspect_ratio=decrease")]);
140        }
141    }
142    let index = if image_magick {
143        result.len() - 1
144    } else {
145        result
146            .iter()
147            .position(|arg| arg == "-f")
148            .unwrap_or(result.len())
149    };
150    result.splice(index..index, extra);
151    result
152}
153
154fn convert_with_options(input: &[u8], options: ConversionOptions) -> Option<Vec<u8>> {
155    let forced = std::env::var_os("RNGIT_MEDIA_BACKEND").filter(|value| !value.is_empty());
156    let Some((name, args)) = select_backend(forced.as_deref(), executable_available) else {
157        if !NO_BACKEND_LOGGED.swap(true, Ordering::Relaxed) {
158            log::warn!("No WebP encoding backend available; install ImageMagick or ffmpeg to enable media conversion");
159        }
160        return None;
161    };
162    NO_BACKEND_LOGGED.store(false, Ordering::Relaxed);
163    match convert(
164        input,
165        Command::new(name).args(configured_args(name, args, options)),
166        options.timeout,
167    ) {
168        Ok(output) => Some(output),
169        Err(err) => {
170            log::warn!("Media conversion via {name} failed: {err}; serving original media");
171            None
172        }
173    }
174}
175
176fn convert(input: &[u8], command: &mut Command, timeout: Duration) -> crate::Result<Vec<u8>> {
177    let mut source = tempfile::tempfile()?;
178    source.write_all(input)?;
179    source.seek(SeekFrom::Start(0))?;
180    let mut output = tempfile::tempfile()?;
181    let mut error = tempfile::tempfile()?;
182    let mut child = command
183        .stdin(Stdio::from(source))
184        .stdout(Stdio::from(output.try_clone()?))
185        .stderr(Stdio::from(error.try_clone()?))
186        .spawn()?;
187    let deadline = Instant::now() + timeout;
188    let status = loop {
189        match child.try_wait() {
190            Ok(Some(status)) => break status,
191            Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
192            result => {
193                let _ = child.kill();
194                let _ = child.wait();
195                return Err(crate::Error::msg(match result {
196                    Err(err) => err.to_string(),
197                    _ => "media conversion timed out".into(),
198                }));
199            }
200        }
201    };
202    if !status.success() {
203        error.seek(SeekFrom::Start(0))?;
204        let mut details = Vec::new();
205        error.take(1024).read_to_end(&mut details)?;
206        return Err(crate::Error::msg(format!(
207            "{status}: {}",
208            String::from_utf8_lossy(&details)
209        )));
210    }
211    output.seek(SeekFrom::Start(0))?;
212    let mut bytes = Vec::new();
213    output.read_to_end(&mut bytes)?;
214    if webp_dimensions(&bytes).is_none() {
215        return Err(crate::Error::msg("invalid WebP output"));
216    }
217    Ok(bytes)
218}
219
220fn webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
221    if data.len() < 30 || &data[..4] != b"RIFF" || &data[8..12] != b"WEBP" {
222        return None;
223    }
224    let (width, height) = match &data[12..16] {
225        b"VP8X" => (
226            u32::from_le_bytes([data[24], data[25], data[26], 0]) + 1,
227            u32::from_le_bytes([data[27], data[28], data[29], 0]) + 1,
228        ),
229        b"VP8 " => (
230            (u16::from_le_bytes([data[26], data[27]]) & 0x3fff) as u32,
231            (u16::from_le_bytes([data[28], data[29]]) & 0x3fff) as u32,
232        ),
233        b"VP8L" => {
234            let bits = u32::from_le_bytes(data[21..25].try_into().ok()?);
235            ((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)
236        }
237        _ => return None,
238    };
239    (width > 0 && height > 0).then_some((width, height))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn backend_options_clamp_quality_and_disable_zero_resize() {
248        for (name, args) in BACKENDS {
249            let configured = configured_args(
250                name,
251                args,
252                ConversionOptions {
253                    quality: Some(200),
254                    max_dimension: Some(0),
255                    ..Default::default()
256                },
257            );
258            assert!(configured
259                .windows(2)
260                .any(|pair| pair == ["-quality", "100"]));
261            assert!(!configured
262                .iter()
263                .any(|arg| arg == "-resize" || arg == "-vf"));
264            let configured = configured_args(
265                name,
266                args,
267                ConversionOptions {
268                    quality: Some(-5),
269                    max_dimension: Some(640),
270                    ..Default::default()
271                },
272            );
273            assert!(configured.windows(2).any(|pair| pair == ["-quality", "1"]));
274            assert!(configured.iter().any(|arg| arg.contains("640")));
275            assert_eq!(configured.last().unwrap(), args.last().unwrap());
276        }
277    }
278
279    #[test]
280    #[ignore = "requires an installed WebP backend"]
281    fn file_conversion_resizes_without_upscaling_and_owns_cleanup() {
282        let mut source = tempfile::NamedTempFile::new().unwrap();
283        let pixels =
284            b"P6\n4 2\n255\n\xff\0\0\0\xff\0\xff\0\0\0\xff\0\xff\0\0\0\xff\0\xff\0\0\0\xff\0";
285        source.write_all(pixels).unwrap();
286        for (limit, dimensions) in [(2, (2, 1)), (8, (4, 2))] {
287            let output = convert_file_to_webp(
288                source.path(),
289                ConversionOptions {
290                    max_dimension: Some(limit),
291                    ..Default::default()
292                },
293            )
294            .unwrap();
295            let path = output.path().to_owned();
296            assert_eq!(
297                webp_dimensions(&std::fs::read(&path).unwrap()),
298                Some(dimensions)
299            );
300            drop(output);
301            assert!(!path.exists());
302            assert_eq!(std::fs::read(source.path()).unwrap(), pixels);
303        }
304        std::fs::write(source.path(), b"not an image").unwrap();
305        assert!(convert_file_to_webp(source.path(), Default::default()).is_none());
306        assert!(convert_file_to_webp(source.path().join("missing"), Default::default()).is_none());
307    }
308
309    #[test]
310    fn backend_preference_and_forced_selection() {
311        assert_eq!(select_backend(None, |_| true).unwrap().0, "magick");
312        assert_eq!(
313            select_backend(None, |name| name == "ffmpeg").unwrap().0,
314            "ffmpeg"
315        );
316        assert_eq!(
317            select_backend(Some(OsStr::new("gm")), |_| true).unwrap().0,
318            "gm"
319        );
320        assert!(select_backend(Some(OsStr::new("unknown")), |_| true).is_none());
321        assert!(select_backend(Some(OsStr::new("magick")), |name| name == "convert").is_none());
322    }
323
324    #[test]
325    fn validates_all_webp_headers_and_rejects_invalid_output() {
326        let mut bytes = vec![0; 30];
327        bytes[..4].copy_from_slice(b"RIFF");
328        bytes[8..12].copy_from_slice(b"WEBP");
329        for header in [b"VP8X", b"VP8L"] {
330            bytes[12..16].copy_from_slice(header);
331            assert_eq!(webp_dimensions(&bytes), Some((1, 1)));
332        }
333        bytes[12..16].copy_from_slice(b"VP8 ");
334        assert_eq!(webp_dimensions(&bytes), None);
335        bytes[26] = 2;
336        bytes[28] = 3;
337        assert_eq!(webp_dimensions(&bytes), Some((2, 3)));
338        assert_eq!(webp_dimensions(&bytes[..29]), None);
339        bytes[0] = 0;
340        assert_eq!(webp_dimensions(&bytes), None);
341    }
342
343    #[test]
344    fn conversion_rejects_invalid_output_failure_and_timeout() {
345        assert!(convert(b"invalid", &mut Command::new("cat"), TIMEOUT).is_err());
346        assert!(convert(b"", &mut Command::new("false"), TIMEOUT).is_err());
347        let start = Instant::now();
348        assert!(convert(
349            b"",
350            Command::new("sleep").arg("5"),
351            Duration::from_millis(30)
352        )
353        .unwrap_err()
354        .to_string()
355        .contains("timed out"));
356        assert!(start.elapsed() < Duration::from_secs(1));
357    }
358
359    #[test]
360    #[ignore = "requires an installed WebP backend"]
361    fn real_backend_converts_pixels() {
362        let pixels = b"P6\n2 1\n255\n\xff\0\0\0\xff\0";
363        let output = convert_to_webp(pixels).expect("available backend must convert pixels");
364        assert_eq!(webp_dimensions(&output), Some((2, 1)));
365    }
366}