refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
This commit is contained in:
1 parent
2ea317e117
commit
eb76ed1937
43 files changed
+4243
-2296
No files matched your search
+229
-92
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user