use eframe::egui;
use image::{DynamicImage, ImageReader};
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const MAX_ACTIVE_WORKERS: usize = 3;
const MAX_CACHE_ENTRIES: usize = 420;
const MAX_QUEUED_WORK: usize = 256;
const THUMBNAIL_RESULT_CAPACITY: usize = MAX_ACTIVE_WORKERS * 2;
const SOURCE_IMAGE_BYTES_LIMIT: u64 = 128 * 1024 * 1024;
const SOURCE_IMAGE_PIXELS_LIMIT: u64 = 64_000_000;
const FFMPEG_THUMBNAIL_BYTES_LIMIT: usize = 32 * 1024 * 1024;
const FFMPEG_STDERR_BYTES_LIMIT: usize = 16 * 1024;
const FFMPEG_TIMEOUT_SECONDS: u64 = 8;
const IMAGE_MAX_EDGE: u32 = 360;
const VIDEO_POSTER_MAX_EDGE: u32 = 360;
const VIDEO_STRIP_WIDTH: u32 = 180;
const VIDEO_STRIP_FRAMES: usize = 6;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaClass {
Folder,
Image,
Video,
Audio,
Archive,
Code,
Pdf,
Text,
Symlink,
Special,
File,
}
impl MediaClass {
pub fn label(self) -> &'static str {
match self {
Self::Folder => "DIR",
Self::Image => "IMG",
Self::Video => "VID",
Self::Audio => "AUD",
Self::Archive => "ARC",
Self::Code => "SRC",
Self::Pdf => "PDF",
Self::Text => "TXT",
Self::Symlink => "LNK",
Self::Special => "SYS",
Self::File => "FIL",
}
}
fn supports_thumbnail(self) -> bool {
matches!(self, Self::Image | Self::Video)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum ThumbnailMode {
Poster,
VideoStrip,
}
#[derive(Clone, Debug, Eq)]
struct ThumbnailKey {
path: PathBuf,
mode: ThumbnailMode,
len: u64,
modified_stamp: u128,
}
impl PartialEq for ThumbnailKey {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
&& self.mode == other.mode
&& self.len == other.len
&& self.modified_stamp == other.modified_stamp
}
}
impl Hash for ThumbnailKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
self.mode.hash(state);
self.len.hash(state);
self.modified_stamp.hash(state);
}
}
#[derive(Clone)]
struct ThumbnailWork {
key: ThumbnailKey,
media_class: MediaClass,
}
struct RawFrame {
width: usize,
height: usize,
rgba: Vec<u8>,
}
struct GeneratedThumbnail {
key: ThumbnailKey,
frames: Result<Vec<RawFrame>, String>,
}
enum CachedThumbnail {
Ready {
textures: Vec<egui::TextureHandle>,
aspect: f32,
last_used: u64,
},
Failed {
error: String,
last_used: u64,
},
}
#[derive(Clone, Copy, Debug)]
pub struct ThumbnailView {
pub texture_id: egui::TextureId,
pub aspect: f32,
pub animated: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct ThumbnailStats {
pub cache_entries: usize,
pub cache_limit: usize,
pub pending: usize,
pub queued: usize,
pub queue_limit: usize,
pub active_workers: usize,
pub worker_limit: usize,
}
pub struct ThumbnailManager {
cache: HashMap<ThumbnailKey, CachedThumbnail>,
pending: HashSet<ThumbnailKey>,
queue: VecDeque<ThumbnailWork>,
tx: SyncSender<GeneratedThumbnail>,
rx: Receiver<GeneratedThumbnail>,
active_workers: usize,
touch_clock: u64,
}
impl ThumbnailManager {
pub fn new() -> Self {
let (tx, rx) = mpsc::sync_channel(THUMBNAIL_RESULT_CAPACITY);
Self {
cache: HashMap::new(),
pending: HashSet::new(),
queue: VecDeque::new(),
tx,
rx,
active_workers: 0,
touch_clock: 0,
}
}
pub fn poll(&mut self, ctx: &egui::Context) {
while let Ok(generated) = self.rx.try_recv() {
self.active_workers = self.active_workers.saturating_sub(1);
self.pending.remove(&generated.key);
self.touch_clock = self.touch_clock.saturating_add(1);
match generated.frames {
Ok(frames) if !frames.is_empty() => {
let mode = generated.key.mode;
let path_label = generated.key.path.display().to_string();
let mut textures = Vec::with_capacity(frames.len());
let mut aspect = 1.0;
for (index, frame) in frames.into_iter().enumerate() {
aspect = frame.width as f32 / frame.height.max(1) as f32;
let image = egui::ColorImage::from_rgba_unmultiplied(
[frame.width, frame.height],
&frame.rgba,
);
textures.push(ctx.load_texture(
format!("thumb:{mode:?}:{path_label}:{index}"),
image,
egui::TextureOptions::LINEAR,
));
}
self.cache.insert(
generated.key,
CachedThumbnail::Ready {
textures,
aspect,
last_used: self.touch_clock,
},
);
}
Ok(_) => {
self.cache.insert(
generated.key,
CachedThumbnail::Failed {
error: "thumbnail generator returned no frames".to_string(),
last_used: self.touch_clock,
},
);
}
Err(error) => {
self.cache.insert(
generated.key,
CachedThumbnail::Failed {
error,
last_used: self.touch_clock,
},
);
}
}
}
self.start_queued_work();
self.evict_cold_entries();
}
pub fn request_poster(&mut self, path: &Path, len: u64, modified_stamp: u128) {
let media_class = media_class_for_extension(path);
if !media_class.supports_thumbnail() {
return;
}
self.request(
path,
ThumbnailMode::Poster,
media_class,
len,
modified_stamp,
);
}
pub fn request_hover_strip(&mut self, path: &Path, len: u64, modified_stamp: u128) {
if media_class_for_extension(path) == MediaClass::Video {
self.request(
path,
ThumbnailMode::VideoStrip,
MediaClass::Video,
len,
modified_stamp,
);
}
}
pub fn thumbnail_for(
&mut self,
path: &Path,
len: u64,
modified_stamp: u128,
hovered: bool,
phase: f32,
) -> Option<ThumbnailView> {
if hovered {
if let Some(view) =
self.cached_view(path, ThumbnailMode::VideoStrip, len, modified_stamp, phase)
{
return Some(ThumbnailView {
animated: true,
..view
});
}
}
self.cached_view(path, ThumbnailMode::Poster, len, modified_stamp, phase)
}
pub fn is_loading(&self, path: &Path) -> bool {
self.pending.iter().any(|key| key.path == path)
}
pub fn poster_failure(&self, path: &Path, len: u64, modified_stamp: u128) -> Option<&str> {
let key = thumbnail_key_from_parts(path, ThumbnailMode::Poster, len, modified_stamp);
match self.cache.get(&key) {
Some(CachedThumbnail::Failed { error, .. }) => Some(error),
_ => None,
}
}
pub fn has_work(&self) -> bool {
self.active_workers > 0 || !self.queue.is_empty() || !self.pending.is_empty()
}
pub fn stats(&self) -> ThumbnailStats {
ThumbnailStats {
cache_entries: self.cache.len(),
cache_limit: MAX_CACHE_ENTRIES,
pending: self.pending.len(),
queued: self.queue.len(),
queue_limit: MAX_QUEUED_WORK,
active_workers: self.active_workers,
worker_limit: MAX_ACTIVE_WORKERS,
}
}
fn request(
&mut self,
path: &Path,
mode: ThumbnailMode,
media_class: MediaClass,
len: u64,
modified_stamp: u128,
) {
let key = thumbnail_key_from_parts(path, mode, len, modified_stamp);
if self.cache.contains_key(&key) || self.pending.contains(&key) {
return;
}
self.make_room_for_queued_work();
self.pending.insert(key.clone());
self.queue.push_back(ThumbnailWork { key, media_class });
self.start_queued_work();
}
fn cached_view(
&mut self,
path: &Path,
mode: ThumbnailMode,
len: u64,
modified_stamp: u128,
phase: f32,
) -> Option<ThumbnailView> {
let key = thumbnail_key_from_parts(path, mode, len, modified_stamp);
let entry = self.cache.get_mut(&key)?;
self.touch_clock = self.touch_clock.saturating_add(1);
match entry {
CachedThumbnail::Ready {
textures,
aspect,
last_used,
} => {
*last_used = self.touch_clock;
if textures.is_empty() {
return None;
}
let index = if mode == ThumbnailMode::VideoStrip && textures.len() > 1 {
((phase * 8.0) as usize) % textures.len()
} else {
0
};
Some(ThumbnailView {
texture_id: textures[index].id(),
aspect: *aspect,
animated: mode == ThumbnailMode::VideoStrip,
})
}
CachedThumbnail::Failed { last_used, .. } => {
*last_used = self.touch_clock;
None
}
}
}
fn start_queued_work(&mut self) {
while self.active_workers < MAX_ACTIVE_WORKERS {
let Some(work) = self.queue.pop_front() else {
break;
};
self.active_workers += 1;
let tx = self.tx.clone();
let key = work.key.clone();
if thread::Builder::new()
.name("guth-thumbnail-worker".to_string())
.spawn(move || {
let frames =
guard_thumbnail_work(|| generate_thumbnail(&work.key, work.media_class));
let _ = tx.send(GeneratedThumbnail {
key: work.key,
frames,
});
})
.is_err()
{
self.active_workers = self.active_workers.saturating_sub(1);
self.pending.remove(&key);
self.touch_clock = self.touch_clock.saturating_add(1);
self.cache.insert(
key,
CachedThumbnail::Failed {
error: "thumbnail worker could not start".to_string(),
last_used: self.touch_clock,
},
);
}
}
}
fn make_room_for_queued_work(&mut self) {
while self.queue.len() >= MAX_QUEUED_WORK {
let Some(work) = self.queue.pop_front() else {
break;
};
self.pending.remove(&work.key);
}
}
fn evict_cold_entries(&mut self) {
if self.cache.len() <= MAX_CACHE_ENTRIES {
return;
}
let mut entries: Vec<(ThumbnailKey, u64)> = self
.cache
.iter()
.map(|(key, value)| {
let last_used = match value {
CachedThumbnail::Ready { last_used, .. }
| CachedThumbnail::Failed { last_used, .. } => *last_used,
};
(key.clone(), last_used)
})
.collect();
entries.sort_by_key(|(_, last_used)| *last_used);
let remove_count = self.cache.len().saturating_sub(MAX_CACHE_ENTRIES);
for (key, _) in entries.into_iter().take(remove_count) {
self.cache.remove(&key);
}
}
}
impl Default for ThumbnailManager {
fn default() -> Self {
Self::new()
}
}
pub fn media_class_for_path(path: &Path, is_dir: bool, is_symlink: bool) -> MediaClass {
if is_symlink {
return MediaClass::Symlink;
}
if is_dir {
return MediaClass::Folder;
}
media_class_for_extension(path)
}
pub fn media_class_for_extension(path: &Path) -> MediaClass {
match path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => MediaClass::Image,
"mp4" | "mkv" | "mov" | "webm" | "avi" | "m4v" | "mpeg" | "mpg" => MediaClass::Video,
"mp3" | "flac" | "wav" | "ogg" | "m4a" | "aac" | "opus" => MediaClass::Audio,
"zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" | "bz2" => MediaClass::Archive,
"rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "java"
| "kt" | "swift" | "sh" | "toml" | "json" | "yaml" | "yml" => MediaClass::Code,
"pdf" => MediaClass::Pdf,
"md" | "txt" | "rst" | "log" | "csv" => MediaClass::Text,
_ => MediaClass::File,
}
}
pub fn modified_stamp(time: Option<SystemTime>) -> u128 {
time.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default()
}
#[deprecated(
since = "0.2.1",
note = "use modified_stamp for finer cache invalidation"
)]
pub fn modified_ms(time: Option<SystemTime>) -> u128 {
time.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn thumbnail_key_from_parts(
path: &Path,
mode: ThumbnailMode,
len: u64,
modified_stamp: u128,
) -> ThumbnailKey {
ThumbnailKey {
path: path.to_path_buf(),
mode,
len,
modified_stamp,
}
}
fn guard_thumbnail_work(
generate: impl FnOnce() -> Result<Vec<RawFrame>, String>,
) -> Result<Vec<RawFrame>, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(generate))
.unwrap_or_else(|_| Err("Thumbnail worker stopped unexpectedly".to_string()))
}
fn generate_thumbnail(
key: &ThumbnailKey,
media_class: MediaClass,
) -> Result<Vec<RawFrame>, String> {
match (media_class, key.mode) {
(MediaClass::Image, ThumbnailMode::Poster) => {
decode_image_thumbnail(&key.path, IMAGE_MAX_EDGE).map(|frame| vec![frame])
}
(MediaClass::Video, ThumbnailMode::Poster) => {
decode_video_poster(&key.path).map(|frame| vec![frame])
}
(MediaClass::Video, ThumbnailMode::VideoStrip) => decode_video_strip(&key.path),
_ => Err("unsupported thumbnail request".to_string()),
}
}
fn decode_image_thumbnail(path: &Path, max_edge: u32) -> Result<RawFrame, String> {
let metadata =
std::fs::metadata(path).map_err(|error| format!("image metadata failed: {error}"))?;
if metadata.len() > SOURCE_IMAGE_BYTES_LIMIT {
return Err(format!(
"image too large for thumbnail: {} bytes limit",
SOURCE_IMAGE_BYTES_LIMIT
));
}
let dimensions_reader = ImageReader::open(path)
.map_err(|error| format!("image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("image format failed: {error}"))?;
let (width, height) = dimensions_reader
.into_dimensions()
.map_err(|error| format!("image dimensions failed: {error}"))?;
let pixels = u64::from(width).saturating_mul(u64::from(height));
if pixels > SOURCE_IMAGE_PIXELS_LIMIT {
return Err(format!(
"image dimensions too large for thumbnail: {} pixels limit",
SOURCE_IMAGE_PIXELS_LIMIT
));
}
let image = ImageReader::open(path)
.map_err(|error| format!("image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("image format failed: {error}"))?
.decode()
.map_err(|error| format!("image decode failed: {error}"))?;
Ok(dynamic_to_frame(image, max_edge))
}
fn decode_video_poster(path: &Path) -> Result<RawFrame, String> {
let output = run_ffmpeg_png(
path,
&[
"-ss",
"00:00:01",
"-i",
path.to_string_lossy().as_ref(),
"-vf",
&format!("scale={VIDEO_POSTER_MAX_EDGE}:-2:force_original_aspect_ratio=decrease"),
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
],
)
.or_else(|_| {
run_ffmpeg_png(
path,
&[
"-i",
path.to_string_lossy().as_ref(),
"-vf",
&format!("scale={VIDEO_POSTER_MAX_EDGE}:-2:force_original_aspect_ratio=decrease"),
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
],
)
})?;
decode_png_bytes(&output)
}
fn decode_video_strip(path: &Path) -> Result<Vec<RawFrame>, String> {
let vf = format!(
"fps=2,scale={VIDEO_STRIP_WIDTH}:-2:force_original_aspect_ratio=decrease,tile={}x1",
VIDEO_STRIP_FRAMES
);
let output = run_ffmpeg_png(
path,
&[
"-i",
path.to_string_lossy().as_ref(),
"-vf",
&vf,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
],
)?;
split_strip_frame(decode_png_bytes(&output)?, VIDEO_STRIP_FRAMES)
}
fn run_ffmpeg_png(_path: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let mut child = Command::new("ffmpeg")
.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostdin")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("ffmpeg unavailable: {error}"))?;
let Some(stdout) = child.stdout.take() else {
terminate_child(&mut child);
return Err("ffmpeg stdout was not captured".to_string());
};
let Some(stderr) = child.stderr.take() else {
terminate_child(&mut child);
return Err("ffmpeg stderr was not captured".to_string());
};
let stdout_reader =
match spawn_pipe_reader("guth-ffmpeg-stdout", stdout, FFMPEG_THUMBNAIL_BYTES_LIMIT) {
Ok(reader) => reader,
Err(error) => {
terminate_child(&mut child);
return Err(error);
}
};
let stderr_reader =
match spawn_pipe_reader("guth-ffmpeg-stderr", stderr, FFMPEG_STDERR_BYTES_LIMIT) {
Ok(reader) => reader,
Err(error) => {
terminate_child(&mut child);
let _ = join_pipe_reader(stdout_reader, "stdout");
return Err(error);
}
};
let timeout = Duration::from_secs(FFMPEG_TIMEOUT_SECONDS);
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if start.elapsed() >= timeout => {
terminate_child(&mut child);
let _ = join_pipe_reader(stdout_reader, "stdout");
let stderr = join_pipe_reader(stderr_reader, "stderr").unwrap_or_default();
return Err(format!(
"ffmpeg thumbnail timed out after {}s{}",
FFMPEG_TIMEOUT_SECONDS,
stderr_suffix(&stderr)
));
}
Ok(None) => thread::sleep(Duration::from_millis(20)),
Err(error) => {
terminate_child(&mut child);
let _ = join_pipe_reader(stdout_reader, "stdout");
let _ = join_pipe_reader(stderr_reader, "stderr");
return Err(format!("ffmpeg wait failed: {error}"));
}
}
};
let stdout = join_pipe_reader(stdout_reader, "stdout")?;
let stderr = join_pipe_reader(stderr_reader, "stderr")?;
if status.success() && !stdout.is_empty() {
Ok(stdout)
} else {
Err(format!(
"ffmpeg thumbnail failed with {status}{}",
stderr_suffix(&stderr)
))
}
}
fn spawn_pipe_reader<R>(
name: &str,
reader: R,
limit: usize,
) -> Result<thread::JoinHandle<Result<Vec<u8>, String>>, String>
where
R: Read + Send + 'static,
{
thread::Builder::new()
.name(name.to_string())
.spawn(move || read_bounded_pipe(reader, limit))
.map_err(|error| format!("could not start {name} reader: {error}"))
}
fn read_bounded_pipe<R: Read>(mut reader: R, limit: usize) -> Result<Vec<u8>, String> {
let mut output = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| format!("ffmpeg pipe read failed: {error}"))?;
if read == 0 {
return Ok(output);
}
if output.len().saturating_add(read) > limit {
return Err(format!("ffmpeg pipe output exceeded {limit} bytes"));
}
output.extend_from_slice(&buffer[..read]);
}
}
fn join_pipe_reader(
reader: thread::JoinHandle<Result<Vec<u8>, String>>,
name: &str,
) -> Result<Vec<u8>, String> {
reader
.join()
.map_err(|_| format!("ffmpeg {name} reader panicked"))?
}
fn terminate_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn stderr_suffix(stderr: &[u8]) -> String {
let text = String::from_utf8_lossy(stderr);
let trimmed = text.trim();
if trimmed.is_empty() {
String::new()
} else {
format!(": {trimmed}")
}
}
fn decode_png_bytes(bytes: &[u8]) -> Result<RawFrame, String> {
let image = image::load(Cursor::new(bytes), image::ImageFormat::Png)
.map_err(|error| format!("png decode failed: {error}"))?;
Ok(dynamic_to_frame(image, u32::MAX))
}
fn dynamic_to_frame(image: DynamicImage, max_edge: u32) -> RawFrame {
let image = if max_edge == u32::MAX {
image
} else {
image.thumbnail(max_edge, max_edge)
};
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
RawFrame {
width: width as usize,
height: height as usize,
rgba: rgba.into_raw(),
}
}
fn split_strip_frame(frame: RawFrame, columns: usize) -> Result<Vec<RawFrame>, String> {
if columns == 0 || frame.width < columns {
return Ok(vec![frame]);
}
let frame_width = frame.width / columns;
if frame_width == 0 {
return Ok(vec![frame]);
}
let mut frames = Vec::with_capacity(columns);
for column in 0..columns {
let start_x = column * frame_width;
let mut rgba = vec![0; frame_width * frame.height * 4];
for y in 0..frame.height {
let src_start = (y * frame.width + start_x) * 4;
let src_end = src_start + frame_width * 4;
let dst_start = y * frame_width * 4;
rgba[dst_start..dst_start + frame_width * 4]
.copy_from_slice(&frame.rgba[src_start..src_end]);
}
frames.push(RawFrame {
width: frame_width,
height: frame.height,
rgba,
});
}
Ok(frames)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_common_media_extensions() {
assert_eq!(
media_class_for_extension(Path::new("a.PNG")),
MediaClass::Image
);
assert_eq!(
media_class_for_extension(Path::new("a.mp4")),
MediaClass::Video
);
assert_eq!(
media_class_for_extension(Path::new("a.rs")),
MediaClass::Code
);
assert_eq!(
media_class_for_extension(Path::new("a.pdf")),
MediaClass::Pdf
);
}
#[test]
fn splits_horizontal_strip() {
let frame = RawFrame {
width: 6,
height: 1,
rgba: vec![
1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255, 5, 0, 0, 255, 6, 0, 0, 255,
],
};
let frames = split_strip_frame(frame, 3).unwrap();
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].rgba[0], 1);
assert_eq!(frames[1].rgba[0], 3);
assert_eq!(frames[2].rgba[0], 5);
}
#[test]
fn bounded_pipe_reader_accepts_output_within_limit() {
let output = read_bounded_pipe(Cursor::new(b"small"), 8).unwrap();
assert_eq!(output, b"small");
}
#[test]
fn bounded_pipe_reader_rejects_output_over_limit() {
let error = read_bounded_pipe(Cursor::new(b"too-large"), 4).unwrap_err();
assert!(error.contains("exceeded"));
}
#[test]
fn poster_failure_retains_diagnostic() {
let mut manager = ThumbnailManager::new();
let path = Path::new("missing-video.mp4");
let key = thumbnail_key_from_parts(path, ThumbnailMode::Poster, 12, 34);
manager.cache.insert(
key,
CachedThumbnail::Failed {
error: "ffmpeg unavailable".to_string(),
last_used: 1,
},
);
assert_eq!(
manager.poster_failure(path, 12, 34),
Some("ffmpeg unavailable")
);
}
#[test]
fn stderr_suffix_omits_empty_text() {
assert_eq!(stderr_suffix(b"\n\t "), "");
assert_eq!(stderr_suffix(b"bad input"), ": bad input");
}
#[test]
fn thumbnail_worker_converts_panics_to_failures() {
let result = guard_thumbnail_work(|| panic!("decoder panic"));
assert!(matches!(
result,
Err(message) if message == "Thumbnail worker stopped unexpectedly"
));
}
}