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

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

No files matched your search

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