// Copyright (C) 2026 Elias Wendland // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, version 3 exclusively. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . use convertis_plugin_api::{ArtifactKind, MediaKind}; use std::{fs, path::Path}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct DetectedFormat { pub format: String, pub mime: String, pub media_kind: MediaKind, pub artifact_kind: ArtifactKind, } fn normalized(format: &str) -> String { let normalized = match format.to_ascii_lowercase().as_str() { "jpg" => "jpeg".to_owned(), "htm" => "html".to_owned(), "txt" | "ascii" => "text".to_owned(), other => other.to_owned(), }; tracing::trace!( input = format, output = normalized, "normalized format name" ); normalized } pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option { tracing::trace!(path = ?path, explicit, "resolving requested format"); let requested = explicit.map(normalized).or_else(|| { path.and_then(Path::extension) .and_then(|value| value.to_str()) .map(normalized) }); tracing::trace!(?requested, "resolved requested format"); requested } pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result { tracing::debug!(path = %path.display(), explicit, is_directory = path.is_dir(), "starting path identification"); if path.is_dir() { tracing::trace!(path = %path.display(), "identifying directory artifact"); if explicit.is_some_and(|format| normalized(format) != "frames") { return Err("directory inputs currently support only the 'frames' format".to_owned()); } let manifest = path.join(".convertis-frames.json"); tracing::trace!(manifest = %manifest.display(), exists = manifest.exists(), "checking frame manifest"); if manifest.exists() { tracing::trace!(manifest = %manifest.display(), "reading frame manifest"); let bytes = fs::read(&manifest) .map_err(|error| format!("could not read {}: {error}", manifest.display()))?; tracing::trace!(manifest = %manifest.display(), byte_count = bytes.len(), "parsing frame manifest"); let value: serde_json::Value = serde_json::from_slice(&bytes) .map_err(|error| format!("invalid frame manifest: {error}"))?; let schema_version = value .get("schema_version") .and_then(serde_json::Value::as_u64); tracing::trace!(?schema_version, "read frame manifest schema version"); if schema_version != Some(1) { return Err("unsupported frame manifest schema".to_owned()); } } else { tracing::trace!(path = %path.display(), "scanning directory for recognizable frames"); let mut detected = None; for entry in fs::read_dir(path).map_err(|error| error.to_string())? { let entry = match entry { Ok(entry) => entry, Err(error) => { tracing::trace!(%error, "could not inspect frame-directory entry"); continue; } }; tracing::trace!(entry = %entry.path().display(), "inspecting frame-directory entry"); if !entry.path().is_file() { tracing::trace!(entry = %entry.path().display(), "skipping non-file frame-directory entry"); continue; } let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?; tracing::trace!(entry = %entry.path().display(), byte_count = bytes.len(), "read potential frame file"); let Some(format) = identify_bytes(&bytes) else { tracing::trace!(entry = %entry.path().display(), "frame candidate was not recognizable"); continue; }; if format.media_kind != MediaKind::Image { tracing::trace!(entry = %entry.path().display(), media_kind = ?format.media_kind, "frame candidate was not a still image"); continue; } if detected .as_ref() .is_some_and(|current| current != &format.format) { return Err( "frame directories without metadata must use one image format".to_owned(), ); } tracing::trace!(entry = %entry.path().display(), format = %format.format, "accepted frame candidate"); detected = Some(format.format); } if detected.is_none() { return Err( "directory has no frame manifest or recognizable image frames".to_owned(), ); } } tracing::debug!(path = %path.display(), "identified frames directory"); return Ok(DetectedFormat { format: "frames".to_owned(), mime: "application/vnd.convertis.frames+json".to_owned(), media_kind: MediaKind::Frames, artifact_kind: ArtifactKind::Directory, }); } tracing::trace!(path = %path.display(), "reading file for content identification"); let bytes = fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; tracing::trace!(path = %path.display(), byte_count = bytes.len(), "read file for identification"); if let Some(format) = explicit { let format = normalized(format); tracing::debug!(path = %path.display(), format, "using explicit input format"); return Ok(from_format(&format, &bytes)); } let identified = identify_bytes(&bytes) .ok_or_else(|| format!("could not identify {} from its contents", path.display()))?; tracing::debug!(path = %path.display(), format = %identified.format, "identified file contents"); Ok(identified) } pub fn identify_bytes(bytes: &[u8]) -> Option { tracing::trace!( byte_count = bytes.len(), "starting content signature identification" ); if bytes.starts_with(&[0, 0, 1, 0]) { tracing::trace!(rule = "ico-header", "matched content signature"); return Some(from_format("ico", bytes)); } tracing::trace!( rule = "ico-header", matched = false, "checked content signature" ); if bytes.starts_with(b" DetectedFormat { tracing::trace!( format, byte_count = bytes.len(), "deriving media details from format" ); let animation = match format { "webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0, "png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"), "gif" => gif_is_animated(bytes), _ => false, }; tracing::trace!(format, animation, "determined animation state"); let media_kind = if animation { MediaKind::Animation } else if matches!( format, "png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "avif" | "heic" | "jxl" | "svg" ) { MediaKind::Image } else if matches!( format, "mp3" | "wav" | "flac" | "aac" | "m4a" | "opus" | "ogg" | "wma" | "aiff" | "au" ) { MediaKind::Audio } else if matches!( format, "mp4" | "webm" | "mkv" | "avi" | "mov" | "wmv" | "flv" | "m4v" | "mpeg" | "ogv" ) { MediaKind::Video } else if format == "text" { MediaKind::Text } else if format == "html" { MediaKind::Document } else { MediaKind::Unknown }; let mime = match format { "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "bmp" => "image/bmp", "tiff" => "image/tiff", "ico" => "image/x-icon", "mp3" => "audio/mpeg", "wav" => "audio/wav", "flac" => "audio/flac", "mp4" => "video/mp4", "webm" => "video/webm", "text" => "text/plain; charset=utf-8", "html" => "text/html; charset=utf-8", _ => "application/octet-stream", }; let format = if animation { match format { "png" | "apng" => "apng".to_owned(), "gif" => "animated-gif".to_owned(), "webp" => "animated-webp".to_owned(), other => other.to_owned(), } } else { format.to_owned() }; tracing::trace!(format, mime, ?media_kind, "derived media details"); DetectedFormat { format, mime: mime.to_owned(), media_kind, artifact_kind: ArtifactKind::File, } } fn gif_is_animated(bytes: &[u8]) -> bool { tracing::trace!(byte_count = bytes.len(), "checking GIF animation state"); let mut options = gif::DecodeOptions::new(); options.set_color_output(gif::ColorOutput::Indexed); let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else { tracing::trace!("GIF decoder rejected content while checking animation"); return false; }; let mut frames = 0; while let Ok(Some(_)) = decoder.read_next_frame() { frames += 1; tracing::trace!(frames, "decoded GIF frame while checking animation"); if frames > 1 { tracing::trace!(frames, animated = true, "GIF has multiple frames"); return true; } } tracing::trace!( frames, animated = false, "GIF has fewer than two decodable frames" ); false } #[cfg(test)] mod tests { use super::*; #[test] fn output_extension_is_only_target_intent() { assert_eq!( requested_format(Some(Path::new("output.JPG")), None), Some("jpeg".into()) ); assert_eq!( requested_format(Some(Path::new("output.jpg")), Some("png")), Some("png".into()) ); } #[test] fn identifies_text_without_an_extension() { assert_eq!(identify_bytes(b"hello\nworld").unwrap().format, "text"); } #[test] fn identifies_ico_magic() { assert_eq!(identify_bytes(&[0, 0, 1, 0, 1, 0]).unwrap().format, "ico"); } #[test] fn content_wins_over_a_misleading_name() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("actually-an-image.txt"); let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); png.resize(32, 0); std::fs::write(&path, png).unwrap(); assert_eq!(identify_path(&path, None).unwrap().format, "png"); } #[test] fn distinguishes_animated_webp() { let mut webp = b"RIFF\x16\0\0\0WEBPVP8X\x0a\0\0\0\x02".to_vec(); webp.resize(32, 0); assert_eq!(identify_bytes(&webp).unwrap().format, "animated-webp"); } }