refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
Release / check-release (push) Successful in 17s
Release / build (push) Skipped
Release / package (deb) (push) Skipped
Release / package (rpm) (push) Skipped
Release / publish (push) Skipped
CI / test (push) Failing after 2m6s

This commit is contained in:
Elias Wendland committed 2026-07-17 15:50:50 +02:00
1 parent 2ea317e117
commit eb76ed1937
43 files changed
+4243 -2296

No files matched your search

+229 -92
View File
@@ -12,115 +12,252 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::path::Path;
use convertis_plugin_api::{ArtifactKind, MediaKind};
use std::{fs, path::Path};
#[tracing::instrument(skip(input, plugins))]
pub fn identify_format(path: &str, input: &[u8], plugins: &[Box<dyn Plugin>]) -> Option<&'static str> {
tracing::trace!("Identifying format for path: {}", path);
let mut ext_str_opt = None;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DetectedFormat {
pub format: String,
pub mime: String,
pub media_kind: MediaKind,
pub artifact_kind: ArtifactKind,
}
if let Some(kind) = infer::get(input) {
tracing::trace!("infer detected file type: {:?}", kind.mime_type());
ext_str_opt = Some(kind.extension());
} else if !input.is_empty() {
tracing::trace!("infer failed to detect file type from magic bytes.");
fn normalized(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"jpg" => "jpeg".to_owned(),
"htm" => "html".to_owned(),
"txt" | "ascii" => "text".to_owned(),
other => other.to_owned(),
}
}
let p = Path::new(path);
let mut ext = p
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
explicit.map(normalized).or_else(|| {
path.and_then(Path::extension)
.and_then(|value| value.to_str())
.map(normalized)
})
}
if ext.is_none() {
ext = Some(path.to_lowercase());
}
let mut ext_str = ext.as_deref().unwrap_or("");
if let Some(magic_ext) = ext_str_opt {
tracing::debug!("Using magic byte extension over path extension: {} -> {}", ext_str, magic_ext);
ext_str = magic_ext;
} else {
tracing::debug!("Using path extension: {}", ext_str);
}
let ext_str = match ext_str {
"jpg" => "jpeg",
other => other,
};
tracing::trace!("Normalized extension to check plugins against: {}", ext_str);
for plugin in plugins {
tracing::trace!("Checking plugin {} formats for support of {}", plugin.name(), ext_str);
if let Some(&f) = plugin.from_formats().iter().find(|&&f| f == ext_str) {
tracing::debug!("Plugin {} supports {} as input.", plugin.name(), f);
return Some(f);
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
if path.is_dir() {
if explicit.is_some_and(|format| normalized(format) != "frames") {
return Err("directory inputs currently support only the 'frames' format".to_owned());
}
if let Some(&f) = plugin.to_formats().iter().find(|&&f| f == ext_str) {
tracing::debug!("Plugin {} supports {} as output.", plugin.name(), f);
return Some(f);
let manifest = path.join(".convertis-frames.json");
if manifest.exists() {
let value: serde_json::Value = serde_json::from_slice(
&fs::read(&manifest)
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?,
)
.map_err(|error| format!("invalid frame manifest: {error}"))?;
if value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
!= Some(1)
{
return Err("unsupported frame manifest schema".to_owned());
}
} else {
let mut detected = None;
for entry in fs::read_dir(path)
.map_err(|error| error.to_string())?
.flatten()
{
if !entry.path().is_file() {
continue;
}
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
let Some(format) = identify_bytes(&bytes) else {
continue;
};
if format.media_kind != MediaKind::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(),
);
}
detected = Some(format.format);
}
if detected.is_none() {
return Err(
"directory has no frame manifest or recognizable image frames".to_owned(),
);
}
}
return Ok(DetectedFormat {
format: "frames".to_owned(),
mime: "application/vnd.convertis.frames+json".to_owned(),
media_kind: MediaKind::Frames,
artifact_kind: ArtifactKind::Directory,
});
}
let bytes =
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
if let Some(format) = explicit {
return Ok(from_format(&normalized(format), &bytes));
}
identify_bytes(&bytes)
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))
}
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
if bytes.starts_with(&[0, 0, 1, 0]) {
return Some(from_format("ico", bytes));
}
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
return Some(from_format("html", bytes));
}
if bytes.windows(4).any(|window| window == b"M4A ") {
return Some(from_format("m4a", bytes));
}
if bytes.starts_with(b"OggS") {
if bytes.windows(8).any(|window| window == b"OpusHead") {
return Some(from_format("opus", bytes));
}
if bytes.windows(6).any(|window| window == b"theora") {
return Some(from_format("ogv", bytes));
}
return Some(from_format("ogg", bytes));
}
if let Some(kind) = infer::get(bytes) {
return Some(from_format(&normalized(kind.extension()), bytes));
}
let text = std::str::from_utf8(bytes).ok()?;
if !text.contains('\0') {
return Some(from_format("text", bytes));
}
tracing::debug!("No plugin supports the format '{}'.", ext_str);
None
}
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
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,
};
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()
};
DetectedFormat {
format,
mime: mime.to_owned(),
media_kind,
artifact_kind: ArtifactKind::File,
}
}
fn gif_is_animated(bytes: &[u8]) -> bool {
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 {
return false;
};
let mut frames = 0;
while let Ok(Some(_)) = decoder.read_next_frame() {
frames += 1;
if frames > 1 {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin;
impl Plugin for MockPlugin {
fn name(&self) -> &'static str { "mock" }
fn from_formats(&self) -> Vec<&'static str> {
vec!["jpeg", "png", "mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "au"]
}
fn to_formats(&self) -> Vec<&'static str> { vec![] }
fn familiarity(&self, _: &str, _: &str) -> u8 { 0 }
fn quality(&self, _: &str, _: &str) -> u8 { 0 }
fn speed(&self, _: &str, _: &str) -> u8 { 0 }
fn convert(&self, _: &[u8], _: &str, _: &str, _: &Path) -> Result<Vec<u8>, String> { Ok(vec![]) }
#[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())
);
}
macro_rules! test_ident {
($name:ident, $ext:expr, $expected:expr) => {
#[test]
fn $name() {
let mock = Box::new(MockPlugin);
let plugins: Vec<Box<dyn Plugin>> = vec![mock];
assert_eq!(identify_format($ext, &[], &plugins), $expected);
}
};
#[test]
fn identifies_text_without_an_extension() {
assert_eq!(identify_bytes(b"hello\nworld").unwrap().format, "text");
}
test_ident!(test_identify_format_1, "test.jpg", Some("jpeg"));
test_ident!(test_identify_format_2, "test.jpeg", Some("jpeg"));
test_ident!(test_identify_format_3, "test.png", Some("png"));
test_ident!(test_identify_format_4, "png", Some("png"));
test_ident!(test_identify_format_5, "jpeg", Some("jpeg"));
test_ident!(test_identify_format_6, "test.txt", None);
test_ident!(test_identify_format_7, "FILE.JPG", Some("jpeg"));
test_ident!(test_identify_format_8, "file.PnG", Some("png"));
test_ident!(test_identify_format_9, "no_ext", None);
test_ident!(test_identify_format_10, "test.bmp", None);
test_ident!(test_identify_format_11, "file.JPEG", Some("jpeg"));
test_ident!(test_identify_format_12, "file.PNG", Some("png"));
test_ident!(
test_identify_format_13,
"complex.file.name.jpg",
Some("jpeg")
);
test_ident!(test_identify_format_14, ".hidden.png", Some("png"));
test_ident!(test_identify_format_15, "jpg", Some("jpeg")); // "jpg" passed without dot, treats as ext if no dot in path but path is "jpg", ext becomes "jpg"
test_ident!(test_identify_format_16, "a.jpg.txt", None);
test_ident!(test_identify_format_17, "a.png.bak", None);
test_ident!(test_identify_format_18, "a.b.c.JPEG", Some("jpeg"));
test_ident!(test_identify_format_19, "test_file_without_extension", None);
test_ident!(test_identify_format_20, ".jpg", None);
test_ident!(test_identify_format_21, "", None);
test_ident!(test_identify_format_22, "test.mp3", Some("mp3"));
test_ident!(test_identify_format_23, "test.WAV", Some("wav"));
test_ident!(test_identify_format_24, "audio.flac", Some("flac"));
test_ident!(test_identify_format_25, "music.ogg", Some("ogg"));
#[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");
}
}