357 lines
13 KiB
Rust
357 lines
13 KiB
Rust
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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<String> {
|
|
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<DetectedFormat, String> {
|
|
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<DetectedFormat> {
|
|
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"<!DOCTYPE html") || bytes.starts_with(b"<html") {
|
|
tracing::trace!(rule = "html-prefix", "matched content signature");
|
|
return Some(from_format("html", bytes));
|
|
}
|
|
tracing::trace!(
|
|
rule = "html-prefix",
|
|
matched = false,
|
|
"checked content signature"
|
|
);
|
|
if bytes.windows(4).any(|window| window == b"M4A ") {
|
|
tracing::trace!(rule = "m4a-brand", "matched content signature");
|
|
return Some(from_format("m4a", bytes));
|
|
}
|
|
tracing::trace!(
|
|
rule = "m4a-brand",
|
|
matched = false,
|
|
"checked content signature"
|
|
);
|
|
if bytes.starts_with(b"OggS") {
|
|
tracing::trace!(rule = "ogg-header", "matched content signature");
|
|
if bytes.windows(8).any(|window| window == b"OpusHead") {
|
|
tracing::trace!(rule = "opus-header", "matched Ogg subtype signature");
|
|
return Some(from_format("opus", bytes));
|
|
}
|
|
if bytes.windows(6).any(|window| window == b"theora") {
|
|
tracing::trace!(rule = "theora-header", "matched Ogg subtype signature");
|
|
return Some(from_format("ogv", bytes));
|
|
}
|
|
tracing::trace!("no Ogg subtype signature matched; using generic Ogg format");
|
|
return Some(from_format("ogg", bytes));
|
|
}
|
|
tracing::trace!(
|
|
rule = "ogg-header",
|
|
matched = false,
|
|
"checked content signature"
|
|
);
|
|
if let Some(kind) = infer::get(bytes) {
|
|
let format = normalized(kind.extension());
|
|
tracing::trace!(
|
|
infer_extension = kind.extension(),
|
|
mime = kind.mime_type(),
|
|
format,
|
|
"matched infer signature database"
|
|
);
|
|
return Some(from_format(&format, bytes));
|
|
}
|
|
tracing::trace!("infer signature database did not match");
|
|
let text = std::str::from_utf8(bytes).ok()?;
|
|
tracing::trace!(
|
|
utf8 = true,
|
|
character_count = text.chars().count(),
|
|
"content is valid UTF-8"
|
|
);
|
|
if !text.contains('\0') {
|
|
tracing::trace!(rule = "utf8-without-nul", "classified content as text");
|
|
return Some(from_format("text", bytes));
|
|
}
|
|
tracing::trace!("content did not match any supported identification rule");
|
|
None
|
|
}
|
|
|
|
fn from_format(format: &str, bytes: &[u8]) -> 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");
|
|
}
|
|
}
|