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
+36
-51
@@ -16,79 +16,65 @@ use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "A file converter program.", long_about = None)]
|
||||
#[command(about = "A modular file converter.", long_about = None)]
|
||||
pub struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
help = "List all supported formats.",
|
||||
help = "List formats supported by installed plugins.",
|
||||
exclusive = true
|
||||
)]
|
||||
pub list_formats: bool,
|
||||
|
||||
#[arg(
|
||||
short = 'v',
|
||||
long,
|
||||
default_value = "warn",
|
||||
help = "Output information about the conversion (trace, debug, info, warn, error)"
|
||||
help = "List installed and known official plugins.",
|
||||
exclusive = true
|
||||
)]
|
||||
pub list_plugins: bool,
|
||||
|
||||
#[arg(short = 'v', long, default_value = "warn")]
|
||||
pub verbose: Verbosity,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Test the conversion process without actually converting the file."
|
||||
)]
|
||||
#[arg(short, long, help = "Show the selected route without converting.")]
|
||||
pub test: bool,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
help = "Output nothing to the console, silently fail on errors."
|
||||
)]
|
||||
#[arg(short, long)]
|
||||
pub quiet: bool,
|
||||
|
||||
#[arg(
|
||||
short = 'y',
|
||||
long,
|
||||
help = "Overwrite output files without asking.",
|
||||
conflicts_with = "no"
|
||||
)]
|
||||
#[arg(short = 'y', long, conflicts_with = "no")]
|
||||
pub yes: bool,
|
||||
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long,
|
||||
help = "Do not overwrite output files, exit immediately if file exists.",
|
||||
conflicts_with = "yes"
|
||||
)]
|
||||
#[arg(short = 'n', long, conflicts_with = "yes")]
|
||||
pub no: bool,
|
||||
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long,
|
||||
help = "Get rid of the warning message when not piping the file."
|
||||
)]
|
||||
#[arg(short = 'c', long)]
|
||||
pub write_to_console: bool,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "fqs",
|
||||
help = "Set the priority of the conversion process (e.g. -p fqs means \"familiarity, speed, quality\")."
|
||||
)]
|
||||
#[arg(short, long, default_value = "fqs")]
|
||||
pub priority: String,
|
||||
|
||||
#[arg(help = "The input file path.")]
|
||||
pub input_path: Option<String>,
|
||||
#[arg(long = "from", help = "Override content-based input detection.")]
|
||||
pub from_format: Option<String>,
|
||||
|
||||
#[arg(help = "The output file path (optional).")]
|
||||
pub output_path: Option<String>,
|
||||
#[arg(long = "to", help = "Override output-extension target selection.")]
|
||||
pub to_format: Option<String>,
|
||||
|
||||
#[arg(
|
||||
short = 'T',
|
||||
long,
|
||||
default_value = "/dev/shm",
|
||||
help = "Directory to use for temporary files during conversion. Defaults to /dev/shm (RAM). Use a disk path for very large files."
|
||||
)]
|
||||
#[arg(long = "option", value_name = "KEY=VALUE", action = clap::ArgAction::Append)]
|
||||
pub options: Vec<String>,
|
||||
|
||||
#[arg(long = "plugin-dir", value_name = "DIRECTORY", action = clap::ArgAction::Append)]
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
|
||||
#[arg(long, help = "Load only explicitly configured plugin directories.")]
|
||||
pub no_default_plugins: bool,
|
||||
|
||||
#[arg(help = "Input file or directory.")]
|
||||
pub input_path: Option<PathBuf>,
|
||||
|
||||
#[arg(help = "Output file or directory.")]
|
||||
pub output_path: Option<PathBuf>,
|
||||
|
||||
#[arg(short = 'T', long, default_value = "/dev/shm")]
|
||||
pub temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -102,8 +88,8 @@ pub enum Verbosity {
|
||||
}
|
||||
|
||||
impl From<Verbosity> for tracing::Level {
|
||||
fn from(v: Verbosity) -> Self {
|
||||
match v {
|
||||
fn from(value: Verbosity) -> Self {
|
||||
match value {
|
||||
Verbosity::Trace => tracing::Level::TRACE,
|
||||
Verbosity::Debug => tracing::Level::DEBUG,
|
||||
Verbosity::Info => tracing::Level::INFO,
|
||||
@@ -112,4 +98,3 @@ impl From<Verbosity> for tracing::Level {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
// 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, Conversion, OptionSpec, PluginMetadata};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
const AUDIO: &[&str] = &[
|
||||
"mp3", "ogg", "aac", "m4a", "opus", "wma", "wav", "flac", "aiff", "au",
|
||||
];
|
||||
const VIDEO_INPUTS: &[&str] = &[
|
||||
"mp4",
|
||||
"webm",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"gif",
|
||||
"animated-gif",
|
||||
"m4v",
|
||||
"mpeg",
|
||||
"ogv",
|
||||
"apng",
|
||||
"webp",
|
||||
"animated-webp",
|
||||
];
|
||||
const VIDEO_OUTPUTS: &[&str] = &[
|
||||
"mp4",
|
||||
"webm",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"animated-gif",
|
||||
"m4v",
|
||||
"mpeg",
|
||||
"ogv",
|
||||
"apng",
|
||||
"animated-webp",
|
||||
];
|
||||
const IMAGES: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
|
||||
const MAGICK_IMAGES: &[&str] = &[
|
||||
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "heic", "jxl", "svg", "pdf",
|
||||
];
|
||||
|
||||
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
||||
formats
|
||||
.iter()
|
||||
.flat_map(|from| {
|
||||
formats
|
||||
.iter()
|
||||
.filter(move |to| to != &from)
|
||||
.map(move |to| Conversion::file(from, to, scores))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
||||
inputs
|
||||
.iter()
|
||||
.flat_map(|from| {
|
||||
outputs
|
||||
.iter()
|
||||
.filter(move |to| to != &from)
|
||||
.map(move |to| Conversion::file(from, to, scores))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn metadata(
|
||||
id: &str,
|
||||
package: &str,
|
||||
description: &str,
|
||||
conversions: Vec<Conversion>,
|
||||
) -> PluginMetadata {
|
||||
PluginMetadata {
|
||||
id: id.to_owned(),
|
||||
package: package.to_owned(),
|
||||
description: description.to_owned(),
|
||||
conversions,
|
||||
options: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn official_plugins() -> Vec<PluginMetadata> {
|
||||
let mut plugins = vec![
|
||||
metadata(
|
||||
"ffmpeg-audio",
|
||||
"convertis-ffmpeg-audio",
|
||||
"FFmpeg audio conversion",
|
||||
all_pairs(AUDIO, (240, 220, 190)),
|
||||
),
|
||||
metadata(
|
||||
"ffmpeg-video",
|
||||
"convertis-ffmpeg-video",
|
||||
"FFmpeg video conversion",
|
||||
cross_pairs(VIDEO_INPUTS, VIDEO_OUTPUTS, (240, 210, 180)),
|
||||
),
|
||||
metadata(
|
||||
"native-image",
|
||||
"convertis-native-image",
|
||||
"Native common image conversion",
|
||||
all_pairs(IMAGES, (255, 230, 240)),
|
||||
),
|
||||
metadata(
|
||||
"imagemagick",
|
||||
"convertis-imagemagick",
|
||||
"ImageMagick conversion",
|
||||
all_pairs(MAGICK_IMAGES, (220, 220, 170)),
|
||||
),
|
||||
metadata(
|
||||
"graphicsmagick",
|
||||
"convertis-graphicsmagick",
|
||||
"GraphicsMagick conversion",
|
||||
all_pairs(MAGICK_IMAGES, (210, 215, 180)),
|
||||
),
|
||||
metadata(
|
||||
"html",
|
||||
"convertis-html",
|
||||
"Self-contained HTML generation",
|
||||
AUDIO
|
||||
.iter()
|
||||
.chain(VIDEO_INPUTS)
|
||||
.chain(MAGICK_IMAGES)
|
||||
.chain([&"text"])
|
||||
.map(|from| Conversion::file(from, "html", (255, 255, 255)))
|
||||
.collect(),
|
||||
),
|
||||
metadata(
|
||||
"image-ascii",
|
||||
"convertis-image-ascii",
|
||||
"Image to ASCII text",
|
||||
IMAGES
|
||||
.iter()
|
||||
.map(|from| Conversion::file(from, "text", (255, 180, 230)))
|
||||
.collect(),
|
||||
),
|
||||
];
|
||||
|
||||
let mut to_frames: Vec<_> = VIDEO_INPUTS
|
||||
.iter()
|
||||
.map(|from| {
|
||||
let mut conversion = Conversion::file(from, "frames", (255, 230, 190));
|
||||
conversion.output_kind = ArtifactKind::Directory;
|
||||
conversion
|
||||
})
|
||||
.collect();
|
||||
plugins.push(metadata(
|
||||
"ffmpeg-video-to-frames",
|
||||
"convertis-ffmpeg-video-to-frames",
|
||||
"Extract video frames",
|
||||
std::mem::take(&mut to_frames),
|
||||
));
|
||||
plugins.last_mut().unwrap().options = vec![
|
||||
OptionSpec {
|
||||
name: "frame_format".into(),
|
||||
help: "png, jpeg, or webp".into(),
|
||||
default: Some("png".into()),
|
||||
},
|
||||
OptionSpec {
|
||||
name: "fps".into(),
|
||||
help: "Optional extraction frame rate".into(),
|
||||
default: None,
|
||||
},
|
||||
];
|
||||
|
||||
let mut from_frames: Vec<_> = VIDEO_OUTPUTS
|
||||
.iter()
|
||||
.map(|to| {
|
||||
let mut conversion = Conversion::file("frames", to, (255, 220, 180));
|
||||
conversion.input_kind = ArtifactKind::Directory;
|
||||
conversion
|
||||
})
|
||||
.collect();
|
||||
plugins.push(metadata(
|
||||
"ffmpeg-frames-to-video",
|
||||
"convertis-ffmpeg-frames-to-video",
|
||||
"Build video from frames",
|
||||
std::mem::take(&mut from_frames),
|
||||
));
|
||||
plugins.last_mut().unwrap().options = vec![OptionSpec {
|
||||
name: "fps".into(),
|
||||
help: "Override frame rate; folders without metadata default to 30".into(),
|
||||
default: None,
|
||||
}];
|
||||
plugins
|
||||
}
|
||||
|
||||
pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
|
||||
let plugins = official_plugins();
|
||||
let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
|
||||
for (index, plugin) in plugins.iter().enumerate() {
|
||||
for conversion in &plugin.conversions {
|
||||
edges
|
||||
.entry(&conversion.from)
|
||||
.or_default()
|
||||
.push((index, &conversion.to));
|
||||
}
|
||||
}
|
||||
let mut queue = VecDeque::from([(from, Vec::<usize>::new())]);
|
||||
let mut visited = HashSet::from([from]);
|
||||
while let Some((current, path)) = queue.pop_front() {
|
||||
if current == to {
|
||||
let mut packages = Vec::new();
|
||||
for index in path {
|
||||
let package = plugins[index].package.clone();
|
||||
if !packages.contains(&package) {
|
||||
packages.push(package);
|
||||
}
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
if let Some(next_edges) = edges.get(current) {
|
||||
for &(plugin, next) in next_edges {
|
||||
if visited.insert(next) {
|
||||
let mut next_path = path.clone();
|
||||
next_path.push(plugin);
|
||||
queue.push_back((next, next_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recommends_a_single_direct_plugin() {
|
||||
assert_eq!(
|
||||
recommend_packages("png", "jpeg"),
|
||||
vec!["convertis-native-image"]
|
||||
);
|
||||
assert_eq!(
|
||||
recommend_packages("mp4", "frames"),
|
||||
vec!["convertis-ffmpeg-video-to-frames"]
|
||||
);
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+199
-160
@@ -12,198 +12,237 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
pub mod args;
|
||||
pub mod identifier;
|
||||
pub mod pathfinder;
|
||||
pub mod plugin;
|
||||
pub mod runner;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/plugins_gen.rs"));
|
||||
mod args;
|
||||
mod catalog;
|
||||
mod identifier;
|
||||
mod pathfinder;
|
||||
mod plugin;
|
||||
mod runner;
|
||||
|
||||
use args::Args;
|
||||
use clap::{CommandFactory, FromArgMatches};
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::process;
|
||||
use convertis_plugin_api::ArtifactKind;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fs,
|
||||
io::{self, IsTerminal, Write},
|
||||
path::Path,
|
||||
process,
|
||||
};
|
||||
|
||||
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
||||
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
||||
|
||||
fn should_overwrite(args: &Args, out_path: &str) -> bool {
|
||||
match (args.yes, args.no || args.quiet) {
|
||||
(true, _) => true,
|
||||
(_, true) => false,
|
||||
(false, false) => {
|
||||
print!("File '{}' already exists. Overwrite? [y/N]: ", out_path);
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
let mut input = String::new();
|
||||
match io::stdin().read_line(&mut input) {
|
||||
Ok(_) => {
|
||||
let input = input.trim().to_lowercase();
|
||||
input == "y" || input == "yes"
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fail(message: impl std::fmt::Display) -> ! {
|
||||
tracing::error!("{message}");
|
||||
process::exit(1)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
fn main() {
|
||||
let mut command = Args::command();
|
||||
command = command.version(VERSION);
|
||||
let matches = command.get_matches();
|
||||
let args = Args::from_arg_matches(&matches).expect("Failed to parse arguments");
|
||||
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut options = BTreeMap::new();
|
||||
for value in values {
|
||||
let (key, value) = value
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?;
|
||||
if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() {
|
||||
return Err(format!("invalid or duplicate option '{key}'"));
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
let level_filter = if args.quiet {
|
||||
fn should_overwrite(args: &Args, output: &Path) -> bool {
|
||||
if !output.exists() {
|
||||
return true;
|
||||
}
|
||||
if args.yes {
|
||||
return true;
|
||||
}
|
||||
if args.no || args.quiet || !io::stdin().is_terminal() {
|
||||
return false;
|
||||
}
|
||||
print!("'{}' exists. Replace it? [y/N]: ", output.display());
|
||||
let _ = io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
io::stdin().read_line(&mut answer).is_ok()
|
||||
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let matches = Args::command().version(VERSION).get_matches();
|
||||
let args = Args::from_arg_matches(&matches).expect("arguments validated by clap");
|
||||
let level = if args.quiet {
|
||||
tracing_subscriber::filter::LevelFilter::OFF
|
||||
} else {
|
||||
tracing::Level::from(args.verbose).into()
|
||||
};
|
||||
tracing_subscriber::fmt().with_max_level(level).init();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(level_filter)
|
||||
.init();
|
||||
let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins);
|
||||
for diagnostic in ®istry.diagnostics {
|
||||
tracing::warn!("{diagnostic}");
|
||||
}
|
||||
|
||||
let plugins = get_plugins();
|
||||
|
||||
if args.list_formats {
|
||||
let mut formats: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for p in &plugins {
|
||||
formats.extend(p.from_formats());
|
||||
formats.extend(p.to_formats());
|
||||
if args.list_plugins {
|
||||
println!("Official plugins:");
|
||||
for metadata in catalog::official_plugins() {
|
||||
let installed = registry
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin.metadata().id == metadata.id);
|
||||
let state = match installed.map(|plugin| plugin.availability()) {
|
||||
Some(Ok(())) => "installed".to_owned(),
|
||||
Some(Err(error)) => format!("unavailable: {error}"),
|
||||
None => "not installed".to_owned(),
|
||||
};
|
||||
println!(
|
||||
" {:32} {:28} {}",
|
||||
metadata.package, state, metadata.description
|
||||
);
|
||||
}
|
||||
let mut formats_vec: Vec<_> = formats.into_iter().collect();
|
||||
formats_vec.sort();
|
||||
|
||||
tracing::info!("Listed {} supported formats", formats_vec.len());
|
||||
println!("Supported Formats:");
|
||||
for chunk in formats_vec.chunks(6) {
|
||||
let row = chunk.iter().map(|s| format!("{:<10}", s)).collect::<Vec<_>>().join(" ");
|
||||
println!(" {}", row);
|
||||
return;
|
||||
}
|
||||
if args.list_formats {
|
||||
let mut formats = HashSet::new();
|
||||
for plugin in ®istry.plugins {
|
||||
for conversion in plugin.metadata().conversions {
|
||||
formats.insert(conversion.from);
|
||||
formats.insert(conversion.to);
|
||||
}
|
||||
}
|
||||
let mut formats: Vec<_> = formats.into_iter().collect();
|
||||
formats.sort();
|
||||
if formats.is_empty() {
|
||||
println!("No formats are available because no plugins are installed.");
|
||||
} else {
|
||||
println!("{}", formats.join("\n"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let input_path = match &args.input_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
tracing::error!("Input file path is required unless --list-formats is used.");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Input path specified: {}", input_path);
|
||||
|
||||
// Read input file
|
||||
let input_bytes = match fs::read(input_path) {
|
||||
Ok(b) => {
|
||||
tracing::trace!("Read {} bytes from {}", b.len(), input_path);
|
||||
b
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error reading input file: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let from_format = match identifier::identify_format(input_path, &input_bytes, &plugins) {
|
||||
Some(f) => {
|
||||
tracing::info!("Identified input format: {}", f);
|
||||
f
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Unknown input format.");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let to_format = match &args.output_path {
|
||||
Some(out) => match identifier::identify_format(out, &[], &plugins) {
|
||||
Some(f) => {
|
||||
tracing::info!("Identified output format from path: {}", f);
|
||||
f
|
||||
}
|
||||
None => {
|
||||
tracing::error!("Unknown output format.");
|
||||
process::exit(1);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// Pick a default target format based on the first available conversion
|
||||
let default_target = plugins.iter().find_map(|p| {
|
||||
if p.from_formats().contains(&from_format) {
|
||||
p.to_formats().first().copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let input = args
|
||||
.input_path
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| fail("an input path is required"));
|
||||
let detected = identifier::identify_path(input, args.from_format.as_deref())
|
||||
.unwrap_or_else(|error| fail(error));
|
||||
tracing::info!("detected {} ({:?})", detected.format, detected.media_kind);
|
||||
let mut target =
|
||||
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
|
||||
.unwrap_or_else(|| {
|
||||
fail("a target is required; provide an output extension or --to FORMAT")
|
||||
});
|
||||
match default_target {
|
||||
Some(t) => {
|
||||
tracing::info!("Auto-selected target format: {}", t);
|
||||
t
|
||||
}
|
||||
None => {
|
||||
tracing::error!("No output path provided, and no available conversions found.");
|
||||
process::exit(1);
|
||||
}
|
||||
if matches!(
|
||||
detected.media_kind,
|
||||
convertis_plugin_api::MediaKind::Video
|
||||
| convertis_plugin_api::MediaKind::Animation
|
||||
| convertis_plugin_api::MediaKind::Frames
|
||||
) {
|
||||
target = match target.as_str() {
|
||||
"gif" => "animated-gif".to_owned(),
|
||||
"webp" => "animated-webp".to_owned(),
|
||||
_ => target,
|
||||
};
|
||||
}
|
||||
let options = parse_options(&args.options).unwrap_or_else(|error| fail(error));
|
||||
let mut banned = Vec::new();
|
||||
|
||||
let result = loop {
|
||||
let Some(route) = pathfinder::find_best_path(
|
||||
®istry.plugins,
|
||||
&detected.format,
|
||||
&target,
|
||||
&args.priority,
|
||||
&banned,
|
||||
) else {
|
||||
let packages = catalog::recommend_packages(&detected.format, &target);
|
||||
if packages.is_empty() {
|
||||
fail(format!(
|
||||
"no conversion path from {} to {} is known",
|
||||
detected.format, target
|
||||
));
|
||||
}
|
||||
let unavailable: Vec<_> = registry
|
||||
.plugins
|
||||
.iter()
|
||||
.filter_map(|plugin| {
|
||||
let metadata = plugin.metadata();
|
||||
if packages.contains(&metadata.package) {
|
||||
plugin
|
||||
.availability()
|
||||
.err()
|
||||
.map(|error| format!("{}: {error}", metadata.package))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !unavailable.is_empty() {
|
||||
fail(format!(
|
||||
"required plugins are installed but unavailable: {}",
|
||||
unavailable.join("; ")
|
||||
));
|
||||
}
|
||||
fail(format!(
|
||||
"no installed conversion path from {} to {}. Install: {}",
|
||||
detected.format,
|
||||
target,
|
||||
packages.join(" ")
|
||||
));
|
||||
};
|
||||
for key in options.keys() {
|
||||
let recognized = route.iter().any(|step| {
|
||||
let metadata = step.plugin.metadata();
|
||||
let option_name = key
|
||||
.strip_prefix(&format!("{}.", metadata.id))
|
||||
.unwrap_or(key);
|
||||
metadata
|
||||
.options
|
||||
.iter()
|
||||
.any(|option| option.name == option_name)
|
||||
});
|
||||
if !recognized {
|
||||
fail(format!(
|
||||
"option '{key}' is not supported by the selected conversion route"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut banned_plugins: Vec<&str> = Vec::new();
|
||||
tracing::debug!("Starting pathfinding loop. from: {}, to: {}, priority: {}", from_format, to_format, args.priority);
|
||||
|
||||
let output_bytes = loop {
|
||||
tracing::trace!("Finding best path with banned plugins: {:?}", banned_plugins);
|
||||
let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority, &banned_plugins) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::error!("No conversion path found from {} to {}.", from_format, to_format);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
|
||||
if args.test {
|
||||
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
|
||||
tracing::info!("Test successful. Path: {}", path_str.join(" -> "));
|
||||
println!("{}", route_names.join(" -> "));
|
||||
return;
|
||||
}
|
||||
|
||||
match runner::run_conversion(&path, &input_bytes, &args.temp_dir) {
|
||||
Ok(output_bytes) => {
|
||||
tracing::info!("Conversion successful. Output size: {} bytes", output_bytes.len());
|
||||
break output_bytes;
|
||||
}
|
||||
Err((e, plugin_name)) => {
|
||||
tracing::warn!("Conversion failed at plugin '{}': {}", plugin_name, e);
|
||||
tracing::warn!("Rerouting and trying alternative paths...");
|
||||
banned_plugins.push(plugin_name);
|
||||
match runner::run_conversion(&route, input, &args.temp_dir, &options) {
|
||||
Ok(result) => break result,
|
||||
Err((error, plugin)) => {
|
||||
tracing::warn!("plugin {plugin} failed: {error}; trying another route");
|
||||
banned.push(plugin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(out_path) = &args.output_path {
|
||||
let out_path_file = std::path::Path::new(out_path);
|
||||
if out_path_file.exists() && !should_overwrite(&args, out_path) {
|
||||
tracing::info!("Not overwriting '{}'. Exiting.", out_path);
|
||||
process::exit(1);
|
||||
if let Some(output) = &args.output_path {
|
||||
if !should_overwrite(&args, output) {
|
||||
fail("output was not replaced");
|
||||
}
|
||||
|
||||
if let Err(e) = fs::write(out_path, &output_bytes) {
|
||||
tracing::error!("Error writing output file: {}", e);
|
||||
process::exit(1);
|
||||
if output.exists() {
|
||||
if output.is_dir() {
|
||||
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
|
||||
} else {
|
||||
fs::remove_file(output).unwrap_or_else(|error| fail(error));
|
||||
}
|
||||
}
|
||||
runner::install_result(&result, output).unwrap_or_else(|error| fail(error));
|
||||
} else if result.kind == ArtifactKind::Directory {
|
||||
fail("directory output requires an output path");
|
||||
} else {
|
||||
let is_piped = !io::stdout().is_terminal();
|
||||
if !is_piped && !args.write_to_console {
|
||||
tracing::warn!("Outputting directly to console. Use -c to suppress this warning, or redirect to a file.");
|
||||
}
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
if let Err(e) = stdout.write_all(&output_bytes) {
|
||||
tracing::error!("Error writing to console: {}", e);
|
||||
process::exit(1);
|
||||
let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error));
|
||||
if io::stdout().is_terminal() && !args.write_to_console {
|
||||
tracing::warn!(
|
||||
"writing conversion bytes to the terminal; use -c to suppress this warning"
|
||||
);
|
||||
}
|
||||
io::stdout()
|
||||
.write_all(&bytes)
|
||||
.unwrap_or_else(|error| fail(error));
|
||||
}
|
||||
}
|
||||
+59
-412
@@ -12,440 +12,87 @@
|
||||
// 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 convertis_plugin_api::{Conversion, Plugin};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PathNode<'a> {
|
||||
pub struct RouteStep<'a> {
|
||||
pub plugin: &'a dyn Plugin,
|
||||
pub from_format: &'a str,
|
||||
pub to_format: &'a str,
|
||||
pub prev: Option<Rc<PathNode<'a>>>,
|
||||
pub conversion: Conversion,
|
||||
}
|
||||
|
||||
impl<'a> PathNode<'a> {
|
||||
pub fn get_path(&self) -> Vec<(&'a dyn Plugin, &'a str, &'a str)> {
|
||||
let mut path = Vec::new();
|
||||
path.push((self.plugin, self.from_format, self.to_format));
|
||||
|
||||
let mut curr = self.prev.clone();
|
||||
while let Some(node) = curr {
|
||||
path.push((node.plugin, node.from_format, node.to_format));
|
||||
curr = node.prev.clone();
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
path
|
||||
}
|
||||
fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
|
||||
priority
|
||||
.chars()
|
||||
.map(|criterion| {
|
||||
route
|
||||
.iter()
|
||||
.map(|step| match criterion.to_ascii_lowercase() {
|
||||
'f' => step.conversion.familiarity as u32,
|
||||
'q' => step.conversion.quality as u32,
|
||||
's' => step.conversion.speed as u32,
|
||||
_ => 0,
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(plugins))]
|
||||
pub fn find_best_path<'a>(
|
||||
plugins: &'a [Box<dyn Plugin>],
|
||||
from_format: &'a str,
|
||||
to_format: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
priority: &str,
|
||||
banned_plugins: &[&str],
|
||||
) -> Option<Vec<(&'a dyn Plugin, &'a str, &'a str)>> {
|
||||
let mut adj_list: HashMap<&str, Vec<&'a dyn Plugin>> = HashMap::new();
|
||||
for p in plugins {
|
||||
if banned_plugins.contains(&p.name()) {
|
||||
tracing::trace!("Skipping banned plugin: {}", p.name());
|
||||
banned: &[String],
|
||||
) -> Option<Vec<RouteStep<'a>>> {
|
||||
let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new();
|
||||
for plugin in plugins {
|
||||
let metadata = plugin.metadata();
|
||||
if banned.contains(&metadata.id) || plugin.availability().is_err() {
|
||||
continue;
|
||||
}
|
||||
if !p.is_available() {
|
||||
tracing::trace!("Skipping unavailable plugin: {}", p.name());
|
||||
continue;
|
||||
}
|
||||
for &from in &p.from_formats() {
|
||||
adj_list.entry(from).or_default().push(p.as_ref());
|
||||
for conversion in metadata.conversions {
|
||||
edges
|
||||
.entry(conversion.from.clone())
|
||||
.or_default()
|
||||
.push(RouteStep {
|
||||
plugin: plugin.as_ref(),
|
||||
conversion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// BFS to find paths with least amount of conversions
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back((from_format, None::<Rc<PathNode<'a>>>));
|
||||
|
||||
let mut min_length = None;
|
||||
let mut best_paths: Vec<Vec<(&'a dyn Plugin, &'a str, &'a str)>> = Vec::new();
|
||||
|
||||
let mut visited_depth = HashMap::new();
|
||||
visited_depth.insert(from_format, 0);
|
||||
|
||||
while let Some((curr_format, prev_node)) = queue.pop_front() {
|
||||
let current_depth = visited_depth.get(curr_format).copied().unwrap_or(0);
|
||||
tracing::trace!("Visiting format node: {} at depth {}", curr_format, current_depth);
|
||||
|
||||
if let Some(min_len) = min_length {
|
||||
if current_depth > min_len {
|
||||
tracing::trace!("Pruning path exploration at depth {} (min_len={})", current_depth, min_len);
|
||||
break; // We've moved beyond the shortest paths
|
||||
}
|
||||
}
|
||||
|
||||
if curr_format == to_format && prev_node.is_some() {
|
||||
if min_length.is_none() {
|
||||
min_length = Some(current_depth);
|
||||
}
|
||||
if min_length == Some(current_depth) {
|
||||
best_paths.push(prev_node.unwrap().get_path());
|
||||
}
|
||||
let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]);
|
||||
let mut depths = HashMap::from([(from.to_owned(), 0usize)]);
|
||||
let mut solutions = Vec::new();
|
||||
let mut minimum = None;
|
||||
while let Some((current, route)) = queue.pop_front() {
|
||||
if minimum.is_some_and(|depth| route.len() > depth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(neighbors) = adj_list.get(curr_format) {
|
||||
for plugin in neighbors {
|
||||
for &next_format in &plugin.to_formats() {
|
||||
let next_depth = current_depth + 1;
|
||||
|
||||
let prev_depth = visited_depth
|
||||
.get(next_format)
|
||||
if current == to && !route.is_empty() {
|
||||
minimum = Some(route.len());
|
||||
solutions.push(route);
|
||||
continue;
|
||||
}
|
||||
if let Some(next_steps) = edges.get(¤t) {
|
||||
for step in next_steps {
|
||||
let next_depth = route.len() + 1;
|
||||
if next_depth
|
||||
<= depths
|
||||
.get(&step.conversion.to)
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
if next_depth <= prev_depth {
|
||||
visited_depth.insert(next_format, next_depth);
|
||||
|
||||
let new_node = Rc::new(PathNode {
|
||||
plugin: *plugin,
|
||||
from_format: curr_format,
|
||||
to_format: next_format,
|
||||
prev: prev_node.clone(),
|
||||
});
|
||||
|
||||
queue.push_back((next_format, Some(new_node)));
|
||||
}
|
||||
.unwrap_or(usize::MAX)
|
||||
{
|
||||
depths.insert(step.conversion.to.clone(), next_depth);
|
||||
let mut next_route = route.clone();
|
||||
next_route.push(step.clone());
|
||||
queue.push_back((step.conversion.to.clone(), next_route));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_paths.is_empty() {
|
||||
tracing::debug!("No valid paths found from {} to {}.", from_format, to_format);
|
||||
return None;
|
||||
}
|
||||
|
||||
tracing::debug!("Found {} path(s) of minimum length {}. Evaluating priority: '{}'", best_paths.len(), min_length.unwrap_or(0), priority);
|
||||
|
||||
// Evaluate based on priority (maximize score)
|
||||
let best = best_paths
|
||||
solutions
|
||||
.into_iter()
|
||||
.max_by(|path_a, path_b| compare_paths(path_a, path_b, priority));
|
||||
|
||||
if let Some(ref path) = best {
|
||||
tracing::debug!("Selected path with length {}: {:?}", path.len(), path.iter().map(|p| p.0.name()).collect::<Vec<_>>());
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn compare_paths(
|
||||
path_a: &[(&dyn Plugin, &str, &str)],
|
||||
path_b: &[(&dyn Plugin, &str, &str)],
|
||||
priority: &str,
|
||||
) -> std::cmp::Ordering {
|
||||
for ch in priority.chars() {
|
||||
match ch {
|
||||
'f' | 'F' => {
|
||||
let score_a: u32 = path_a
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.familiarity(*from, *to) as u32)
|
||||
.sum();
|
||||
let score_b: u32 = path_b
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.familiarity(*from, *to) as u32)
|
||||
.sum();
|
||||
if score_a != score_b {
|
||||
return score_a.cmp(&score_b);
|
||||
}
|
||||
}
|
||||
'q' | 'Q' => {
|
||||
let score_a: u32 = path_a
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.quality(*from, *to) as u32)
|
||||
.sum();
|
||||
let score_b: u32 = path_b
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.quality(*from, *to) as u32)
|
||||
.sum();
|
||||
if score_a != score_b {
|
||||
return score_a.cmp(&score_b);
|
||||
}
|
||||
}
|
||||
's' | 'S' => {
|
||||
let score_a: u32 = path_a
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.speed(*from, *to) as u32)
|
||||
.sum();
|
||||
let score_b: u32 = path_b
|
||||
.iter()
|
||||
.map(|(p, from, to)| p.speed(*from, *to) as u32)
|
||||
.sum();
|
||||
if score_a != score_b {
|
||||
return score_a.cmp(&score_b);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockPlugin {
|
||||
name: &'static str,
|
||||
from: Vec<&'static str>,
|
||||
to: Vec<&'static str>,
|
||||
f: u8,
|
||||
q: u8,
|
||||
s: u8,
|
||||
}
|
||||
|
||||
impl Plugin for MockPlugin {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
fn from_formats(&self) -> Vec<&'static str> {
|
||||
self.from.clone()
|
||||
}
|
||||
fn to_formats(&self) -> Vec<&'static str> {
|
||||
self.to.clone()
|
||||
}
|
||||
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.f
|
||||
}
|
||||
fn quality(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.q
|
||||
}
|
||||
fn speed(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.s
|
||||
}
|
||||
fn convert(
|
||||
&self,
|
||||
_input: &[u8],
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
_temp_dir: &std::path::Path,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shortest_path() {
|
||||
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||
Box::new(MockPlugin {
|
||||
name: "A",
|
||||
from: vec!["jpeg"],
|
||||
to: vec!["png"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "B",
|
||||
from: vec!["jpeg"],
|
||||
to: vec!["bmp"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "C",
|
||||
from: vec!["bmp"],
|
||||
to: vec!["png"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
];
|
||||
|
||||
let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
|
||||
assert_eq!(path.len(), 1);
|
||||
assert_eq!(path[0].0.name(), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_tie_break() {
|
||||
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||
Box::new(MockPlugin {
|
||||
name: "A",
|
||||
from: vec!["jpeg"],
|
||||
to: vec!["png"],
|
||||
f: 10,
|
||||
q: 50,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "B",
|
||||
from: vec!["jpeg"],
|
||||
to: vec!["png"],
|
||||
f: 50,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
];
|
||||
|
||||
let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
|
||||
assert_eq!(path[0].0.name(), "B");
|
||||
|
||||
let path = find_best_path(&plugins, "jpeg", "png", "qfs", &[]).unwrap();
|
||||
assert_eq!(path[0].0.name(), "A");
|
||||
}
|
||||
|
||||
macro_rules! test_pathfinder {
|
||||
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr, $expected_first:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||
Box::new(MockPlugin {
|
||||
name: "A",
|
||||
from: vec!["a"],
|
||||
to: vec!["b"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "B",
|
||||
from: vec!["b"],
|
||||
to: vec!["c"],
|
||||
f: 20,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "C",
|
||||
from: vec!["a"],
|
||||
to: vec!["c"],
|
||||
f: 5,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "D",
|
||||
from: vec!["a"],
|
||||
to: vec!["d"],
|
||||
f: 10,
|
||||
q: 20,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "E",
|
||||
from: vec!["d"],
|
||||
to: vec!["c"],
|
||||
f: 10,
|
||||
q: 20,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "F",
|
||||
from: vec!["a"],
|
||||
to: vec!["b"],
|
||||
f: 50,
|
||||
q: 5,
|
||||
s: 5,
|
||||
}), // High familiarity, low q/s
|
||||
Box::new(MockPlugin {
|
||||
name: "G",
|
||||
from: vec!["c"],
|
||||
to: vec!["e"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 50,
|
||||
}),
|
||||
];
|
||||
|
||||
let path = find_best_path(&plugins, $from, $to, $priority, &[]);
|
||||
if $expected_len == 0 {
|
||||
assert!(path.is_none());
|
||||
} else {
|
||||
let p = path.unwrap();
|
||||
assert_eq!(p.len(), $expected_len);
|
||||
assert_eq!(p[0].0.name(), $expected_first);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test_pathfinder!(test_path_1, "a", "b", "fqs", 1, "F"); // F has higher f
|
||||
test_pathfinder!(test_path_2, "a", "b", "qfs", 1, "A"); // A has higher q
|
||||
test_pathfinder!(test_path_3, "a", "c", "fqs", 1, "C"); // Shortest path is length 1 (C)
|
||||
test_pathfinder!(test_path_4, "a", "d", "fqs", 1, "D");
|
||||
test_pathfinder!(test_path_5, "d", "c", "fqs", 1, "E");
|
||||
test_pathfinder!(test_path_6, "a", "e", "fqs", 2, "C"); // Shortest path to e goes through c. So a->c (C), c->e (G)
|
||||
test_pathfinder!(test_path_7, "b", "e", "fqs", 2, "B");
|
||||
test_pathfinder!(test_path_8, "e", "a", "fqs", 0, ""); // No path
|
||||
test_pathfinder!(test_path_9, "c", "b", "fqs", 0, ""); // No path
|
||||
test_pathfinder!(test_path_10, "x", "y", "fqs", 0, ""); // No path
|
||||
|
||||
macro_rules! test_pathfinder_2 {
|
||||
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||
Box::new(MockPlugin {
|
||||
name: "1",
|
||||
from: vec!["1"],
|
||||
to: vec!["2"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "2",
|
||||
from: vec!["2"],
|
||||
to: vec!["3"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "3",
|
||||
from: vec!["3"],
|
||||
to: vec!["4"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "4",
|
||||
from: vec!["4"],
|
||||
to: vec!["5"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
Box::new(MockPlugin {
|
||||
name: "5",
|
||||
from: vec!["5"],
|
||||
to: vec!["6"],
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
}),
|
||||
];
|
||||
let path = find_best_path(&plugins, $from, $to, $priority, &[]);
|
||||
if $expected_len == 0 {
|
||||
assert!(path.is_none());
|
||||
} else {
|
||||
assert_eq!(path.unwrap().len(), $expected_len);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test_pathfinder_2!(test_p2_1, "1", "2", "f", 1);
|
||||
test_pathfinder_2!(test_p2_2, "1", "3", "f", 2);
|
||||
test_pathfinder_2!(test_p2_3, "1", "4", "f", 3);
|
||||
test_pathfinder_2!(test_p2_4, "1", "5", "f", 4);
|
||||
test_pathfinder_2!(test_p2_5, "1", "6", "f", 5);
|
||||
test_pathfinder_2!(test_p2_6, "2", "6", "f", 4);
|
||||
test_pathfinder_2!(test_p2_7, "3", "6", "f", 3);
|
||||
test_pathfinder_2!(test_p2_8, "4", "6", "f", 2);
|
||||
test_pathfinder_2!(test_p2_9, "5", "6", "f", 1);
|
||||
test_pathfinder_2!(test_p2_10, "6", "1", "f", 0);
|
||||
.max_by_key(|route| route_score(route, priority))
|
||||
}
|
||||
+137
-22
@@ -12,30 +12,145 @@
|
||||
// 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 std::path::Path;
|
||||
use convertis_plugin_api::{
|
||||
ENGINE_VERSION, FACTORY_SYMBOL, MANIFEST_SYMBOL, Plugin, PluginFactory, PluginManifest,
|
||||
};
|
||||
use libloading::Library;
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashSet, ffi::CStr, fs, path::PathBuf};
|
||||
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn from_formats(&self) -> Vec<&'static str>;
|
||||
fn to_formats(&self) -> Vec<&'static str>;
|
||||
#[derive(Deserialize)]
|
||||
struct AbiManifest {
|
||||
api_version: u32,
|
||||
engine_version: String,
|
||||
plugin_id: String,
|
||||
rustc_version: String,
|
||||
target: String,
|
||||
}
|
||||
|
||||
/// Checks if the plugin is available to run on this system (e.g. required binaries are installed).
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
pub struct PluginRegistry {
|
||||
// Plugins must be dropped before their backing libraries.
|
||||
pub plugins: Vec<Box<dyn Plugin>>,
|
||||
libraries: Vec<Library>,
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self {
|
||||
let mut directories = extra_dirs.to_vec();
|
||||
if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") {
|
||||
directories.extend(std::env::split_paths(&paths));
|
||||
}
|
||||
if include_defaults {
|
||||
if let Ok(executable) = std::env::current_exe()
|
||||
&& let Some(parent) = executable.parent()
|
||||
{
|
||||
directories.push(parent.to_path_buf());
|
||||
directories.push(parent.join("plugins"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins"));
|
||||
}
|
||||
directories.push(PathBuf::from("/usr/lib/convertis/plugins"));
|
||||
}
|
||||
|
||||
let mut registry = Self {
|
||||
plugins: Vec::new(),
|
||||
libraries: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
};
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut seen_ids = HashSet::new();
|
||||
|
||||
for directory in directories {
|
||||
let Ok(directory) = directory.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if !seen_paths.insert(directory.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(&directory) else {
|
||||
continue;
|
||||
};
|
||||
let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| name.starts_with("libconvertis_"));
|
||||
if !is_plugin {
|
||||
continue;
|
||||
}
|
||||
match unsafe { Self::load_one(&path) } {
|
||||
Ok((library, plugin, id)) => {
|
||||
if seen_ids.insert(id.clone()) {
|
||||
registry.plugins.push(plugin);
|
||||
registry.libraries.push(library);
|
||||
} else {
|
||||
registry.diagnostics.push(format!(
|
||||
"ignored duplicate plugin '{id}' from {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => registry
|
||||
.diagnostics
|
||||
.push(format!("could not load {}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
/// Score from 1 to 255. Higher is better.
|
||||
fn familiarity(&self, from_format: &str, to_format: &str) -> u8;
|
||||
fn quality(&self, from_format: &str, to_format: &str) -> u8;
|
||||
fn speed(&self, from_format: &str, to_format: &str) -> u8;
|
||||
|
||||
/// Converts the given input bytes from `from_format` to `to_format`.
|
||||
/// `temp_dir` is the directory to use for any intermediate scratch files.
|
||||
fn convert(
|
||||
&self,
|
||||
input: &[u8],
|
||||
from_format: &str,
|
||||
to_format: &str,
|
||||
temp_dir: &Path,
|
||||
) -> Result<Vec<u8>, String>;
|
||||
unsafe fn load_one(
|
||||
path: &std::path::Path,
|
||||
) -> Result<(Library, Box<dyn Plugin>, String), String> {
|
||||
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
|
||||
let manifest_fn = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) }
|
||||
.map_err(|error| format!("missing ABI manifest: {error}"))?;
|
||||
let pointer = unsafe { manifest_fn() };
|
||||
if pointer.is_null() {
|
||||
return Err("ABI manifest was null".to_owned());
|
||||
}
|
||||
let json = unsafe { CStr::from_ptr(pointer) }
|
||||
.to_str()
|
||||
.map_err(|error| format!("invalid ABI manifest string: {error}"))?;
|
||||
let manifest: AbiManifest =
|
||||
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))?;
|
||||
if manifest.api_version != convertis_plugin_api::API_VERSION {
|
||||
return Err(format!(
|
||||
"plugin API {} is not supported",
|
||||
manifest.api_version
|
||||
));
|
||||
}
|
||||
if manifest.engine_version != ENGINE_VERSION {
|
||||
return Err(format!(
|
||||
"plugin targets engine {}, but this engine is {}",
|
||||
manifest.engine_version, ENGINE_VERSION
|
||||
));
|
||||
}
|
||||
if manifest.rustc_version != env!("CONVERTIS_RUSTC_VERSION") {
|
||||
return Err(format!(
|
||||
"plugin was built with {}, but the engine uses {}",
|
||||
manifest.rustc_version,
|
||||
env!("CONVERTIS_RUSTC_VERSION")
|
||||
));
|
||||
}
|
||||
if manifest.target != env!("CONVERTIS_TARGET") {
|
||||
return Err(format!(
|
||||
"plugin targets {}, but the engine targets {}",
|
||||
manifest.target,
|
||||
env!("CONVERTIS_TARGET")
|
||||
));
|
||||
}
|
||||
let factory = unsafe { library.get::<PluginFactory>(FACTORY_SYMBOL) }
|
||||
.map_err(|error| format!("missing Rust plugin factory: {error}"))?;
|
||||
let plugin = unsafe { factory() };
|
||||
if plugin.metadata().id != manifest.plugin_id {
|
||||
return Err("manifest and plugin IDs differ".to_owned());
|
||||
}
|
||||
Ok((library, plugin, manifest.plugin_id))
|
||||
}
|
||||
}
|
||||
+112
-113
@@ -12,127 +12,126 @@
|
||||
// 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 std::time::Instant;
|
||||
use crate::pathfinder::RouteStep;
|
||||
use convertis_plugin_api::{ArtifactKind, ConversionRequest};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub struct ConversionResult {
|
||||
pub path: PathBuf,
|
||||
pub kind: ArtifactKind,
|
||||
_workspace: tempfile::TempDir,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(path, input, temp_dir))]
|
||||
pub fn run_conversion(
|
||||
path: &[(&dyn Plugin, &str, &str)],
|
||||
input: &[u8],
|
||||
temp_dir: &Path,
|
||||
) -> Result<Vec<u8>, (String, &'static str)> {
|
||||
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
|
||||
tracing::info!("Path taken: {}", path_str.join(" -> "));
|
||||
route: &[RouteStep<'_>],
|
||||
input: &Path,
|
||||
temp_root: &Path,
|
||||
options: &BTreeMap<String, String>,
|
||||
) -> Result<ConversionResult, (String, String)> {
|
||||
fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||
let workspace = tempfile::Builder::new()
|
||||
.prefix("convertis-")
|
||||
.tempdir_in(temp_root)
|
||||
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||
let mut current = input.to_path_buf();
|
||||
let mut kind = if input.is_dir() {
|
||||
ArtifactKind::Directory
|
||||
} else {
|
||||
ArtifactKind::File
|
||||
};
|
||||
|
||||
let mut current_data = input.to_vec();
|
||||
|
||||
for (plugin, from_format, to_format) in path {
|
||||
tracing::info!(
|
||||
"Converting {} to {} using plugin {}...",
|
||||
from_format,
|
||||
to_format,
|
||||
plugin.name()
|
||||
);
|
||||
|
||||
tracing::debug!("[Plugin Log] Running plugin: {}", plugin.name());
|
||||
tracing::debug!("[Plugin Log] Source format: {}", from_format);
|
||||
tracing::debug!("[Plugin Log] Target format: {}", to_format);
|
||||
tracing::debug!(
|
||||
"[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}",
|
||||
plugin.familiarity(*from_format, *to_format),
|
||||
plugin.quality(*from_format, *to_format),
|
||||
plugin.speed(*from_format, *to_format)
|
||||
);
|
||||
tracing::debug!("[Plugin Log] Input data size: {} bytes", current_data.len());
|
||||
|
||||
let start_time = Instant::now();
|
||||
current_data = match plugin.convert(¤t_data, from_format, to_format, temp_dir) {
|
||||
Ok(data) => data,
|
||||
Err(e) => return Err((e, plugin.name())),
|
||||
};
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
tracing::info!(
|
||||
"Step '{} -> {}' completed in {:?}. Output size: {} bytes",
|
||||
from_format, to_format, elapsed, current_data.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(current_data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockPlugin {
|
||||
name: &'static str,
|
||||
f: u8,
|
||||
q: u8,
|
||||
s: u8,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl Plugin for MockPlugin {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
fn from_formats(&self) -> Vec<&'static str> {
|
||||
vec!["a"]
|
||||
}
|
||||
fn to_formats(&self) -> Vec<&'static str> {
|
||||
vec!["b"]
|
||||
}
|
||||
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.f
|
||||
}
|
||||
fn quality(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.q
|
||||
}
|
||||
fn speed(&self, _from: &str, _to: &str) -> u8 {
|
||||
self.s
|
||||
}
|
||||
fn convert(
|
||||
&self,
|
||||
input: &[u8],
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
_temp_dir: &Path,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if self.fail {
|
||||
return Err("Failed".to_string());
|
||||
for (index, step) in route.iter().enumerate() {
|
||||
let metadata = step.plugin.metadata();
|
||||
let mut plugin_options = BTreeMap::new();
|
||||
for option in &metadata.options {
|
||||
if let Some(default) = &option.default {
|
||||
plugin_options.insert(option.name.clone(), default.clone());
|
||||
}
|
||||
let mut out = input.to_vec();
|
||||
out.push(1);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! test_runner {
|
||||
($name:ident, $fail:expr, $expected_res:expr, $expected_len:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let plugin = MockPlugin {
|
||||
name: "mock",
|
||||
f: 10,
|
||||
q: 10,
|
||||
s: 10,
|
||||
fail: $fail,
|
||||
};
|
||||
let path: Vec<(&dyn Plugin, &str, &str)> = vec![(&plugin, "a", "b")];
|
||||
let input = vec![0];
|
||||
|
||||
let result =
|
||||
run_conversion(&path, &input, std::path::Path::new("/dev/shm"));
|
||||
assert_eq!(result.is_ok(), $expected_res);
|
||||
if let Ok(res) = result {
|
||||
assert_eq!(res.len(), $expected_len);
|
||||
for (key, value) in options {
|
||||
if let Some((plugin_id, option)) = key.split_once('.') {
|
||||
if plugin_id == metadata.id {
|
||||
plugin_options.insert(option.to_owned(), value.clone());
|
||||
}
|
||||
} else if metadata.options.iter().any(|option| option.name == *key) {
|
||||
plugin_options.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
let output = match step.conversion.output_kind {
|
||||
ArtifactKind::File => workspace.path().join(format!(
|
||||
"step-{index}.{}",
|
||||
file_extension(&step.conversion.to)
|
||||
)),
|
||||
ArtifactKind::Directory => workspace.path().join(format!("step-{index}")),
|
||||
};
|
||||
if step.conversion.output_kind == ArtifactKind::Directory {
|
||||
fs::create_dir_all(&output)
|
||||
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||
}
|
||||
let request = ConversionRequest {
|
||||
input: current,
|
||||
output: output.clone(),
|
||||
from: step.conversion.from.clone(),
|
||||
to: step.conversion.to.clone(),
|
||||
options: plugin_options,
|
||||
};
|
||||
step.plugin
|
||||
.convert(&request)
|
||||
.map_err(|error| (error, step.plugin.metadata().id))?;
|
||||
current = output;
|
||||
kind = step.conversion.output_kind;
|
||||
}
|
||||
Ok(ConversionResult {
|
||||
path: current,
|
||||
kind,
|
||||
_workspace: workspace,
|
||||
})
|
||||
}
|
||||
|
||||
test_runner!(test_runner_verb_0, false, true, 2);
|
||||
test_runner!(test_runner_verb_0_fail, true, false, 0);
|
||||
fn file_extension(format: &str) -> &str {
|
||||
match format {
|
||||
"animated-gif" => "gif",
|
||||
"animated-webp" => "webp",
|
||||
"text" => "txt",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
|
||||
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
match result.kind {
|
||||
ArtifactKind::File => {
|
||||
let staging = tempfile::NamedTempFile::new_in(parent)?;
|
||||
fs::copy(&result.path, staging.path())?;
|
||||
staging.persist(destination).map_err(|error| error.error)?;
|
||||
}
|
||||
ArtifactKind::Directory => {
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".convertis-output-")
|
||||
.tempdir_in(parent)?;
|
||||
let payload = staging.path().join("payload");
|
||||
copy_directory(&result.path, &payload)?;
|
||||
fs::rename(payload, destination)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
fs::create_dir_all(destination)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let target = destination.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_directory(&entry.path(), &target)?;
|
||||
} else {
|
||||
fs::copy(entry.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user