feat: implement protocol v2 for plugin system and add comprehensive tracing support
This commit is contained in:
1 parent
590c616d6f
commit
f07f54d4c1
12 files changed
+968
-128
No files matched your search
Generated
+19
-11
@@ -313,7 +313,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis"
|
name = "convertis"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap_mangen",
|
"clap_mangen",
|
||||||
@@ -321,6 +321,7 @@ dependencies = [
|
|||||||
"gif",
|
"gif",
|
||||||
"infer",
|
"infer",
|
||||||
"libloading",
|
"libloading",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -330,14 +331,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-ffmpeg-audio"
|
name = "convertis-ffmpeg-audio"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-ffmpeg-frames-to-video"
|
name = "convertis-ffmpeg-frames-to-video"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -346,14 +347,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-ffmpeg-video"
|
name = "convertis-ffmpeg-video"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-ffmpeg-video-to-frames"
|
name = "convertis-ffmpeg-video-to-frames"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -362,14 +363,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-graphicsmagick"
|
name = "convertis-graphicsmagick"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-html"
|
name = "convertis-html"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
@@ -378,7 +379,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-image-ascii"
|
name = "convertis-image-ascii"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
"image",
|
"image",
|
||||||
@@ -386,14 +387,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-imagemagick"
|
name = "convertis-imagemagick"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-native-image"
|
name = "convertis-native-image"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convertis-plugin-api",
|
"convertis-plugin-api",
|
||||||
"image",
|
"image",
|
||||||
@@ -401,9 +402,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convertis-plugin-api"
|
name = "convertis-plugin-api"
|
||||||
version = "0.3.0"
|
version = "0.3.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1135,6 +1137,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,7 @@ license = "GPL-3.0-only"
|
|||||||
repository = "https://git.ewenlau.net/ewenlau/convertis"
|
repository = "https://git.ewenlau.net/ewenlau/convertis"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
convertis-plugin-api = { path = "crates/convertis-plugin-api", version = "=0.3.0" }
|
convertis-plugin-api = { path = "crates/convertis-plugin-api", version = "=0.3.0-dev" }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -55,6 +55,7 @@ gif = "0.14"
|
|||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
semver = "1"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
@@ -86,7 +86,9 @@ When no installed route can perform a conversion, Convertis prints the individua
|
|||||||
|
|
||||||
The release page contains a raw `x86_64-unknown-linux-gnu` executable and one ZIP containing all official `.so` plugins. Extract its `plugins/` directory beside the executable. Plugins can also be placed directly beside the executable, in `~/.local/lib/convertis/plugins`, or in `/usr/lib/convertis/plugins`.
|
The release page contains a raw `x86_64-unknown-linux-gnu` executable and one ZIP containing all official `.so` plugins. Extract its `plugins/` directory beside the executable. Plugins can also be placed directly beside the executable, in `~/.local/lib/convertis/plugins`, or in `/usr/lib/convertis/plugins`.
|
||||||
|
|
||||||
Plugins use an exact-version Rust ABI. A plugin must have been built for the same Convertis release; incompatible libraries are rejected before loading.
|
Plugins use a version-negotiated wire protocol. A plugin advertises the protocol versions it speaks and an open-ended engine requirement (for example `>=0.3.0-dev`); the engine selects the newest protocol adapter both sides support. Engine releases are therefore decoupled from plugin releases, and a protocol-v2 plugin built today remains usable by future engines that retain the v2 adapter.
|
||||||
|
|
||||||
|
Plugins built for the original protocol v1 are still recognized by a permanent legacy adapter. Because v1 passed Rust trait objects across the dynamic-library boundary, those old binaries must also match the engine's Rust compiler and target. Protocol v2 uses JSON over a C ABI and has no Rust compiler-version coupling.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -109,6 +111,14 @@ convertis --list-formats
|
|||||||
convertis --help
|
convertis --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Logging is controlled with `--verbose LEVEL` (or `-v LEVEL`). `trace` records plugin-directory discovery, manifest parsing and protocol negotiation, every format-identification rule, graph construction and route-search decision, option resolution, each plugin request, and filesystem staging/install operations:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
convertis -v trace input.png output.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs go to standard error, so converted bytes written to standard output remain clean. `--quiet` disables logging.
|
||||||
|
|
||||||
Use `--no-default-plugins` with explicit `--plugin-dir` arguments to run in an isolated plugin environment.
|
Use `--no-default-plugins` with explicit `--plugin-dir` arguments to run in an isolated plugin environment.
|
||||||
|
|
||||||
Plugin settings use repeatable `--option key=value` arguments. A plugin-qualified key such as `ffmpeg-frames-to-video.fps=24` can disambiguate settings in a multi-plugin route.
|
Plugin settings use repeatable `--option key=value` arguments. A plugin-qualified key such as `ffmpeg-frames-to-video.fps=24` can disambiguate settings in a multi-plugin route.
|
||||||
@@ -125,7 +135,17 @@ The engine is `target/release/convertis`; plugins are `target/release/libconvert
|
|||||||
|
|
||||||
## Plugin API
|
## Plugin API
|
||||||
|
|
||||||
The workspace crate `convertis-plugin-api` defines the exact-version Rust trait used by official plugins. Plugin metadata is checked through a small C-compatible entry point before the Rust factory is called. The official plugin crates are the reference examples; ABI compatibility across Convertis releases is not promised.
|
The workspace crate `convertis-plugin-api` defines the plugin authoring trait and exports it through stable protocol v2. Its unversioned manifest entry point advertises:
|
||||||
|
|
||||||
|
- the manifest schema version;
|
||||||
|
- every wire-protocol version implemented by the plugin;
|
||||||
|
- the plugin ID and release version;
|
||||||
|
- an open-ended semantic-version requirement for the engine;
|
||||||
|
- the target platform.
|
||||||
|
|
||||||
|
Metadata, availability checks, and conversion requests cross the library boundary as owned JSON messages through versioned C entry points. Returned strings are released by the plugin's matching deallocator, and plugin panics are converted to protocol errors instead of unwinding across the ABI boundary.
|
||||||
|
|
||||||
|
Protocol versions are compatibility contracts, not Convertis release numbers. Existing adapters are retained by future engines; incompatible protocol evolution is introduced under a new version and selected through manifest negotiation. New optional JSON fields may be added without requiring a protocol bump. The official plugin crates are the reference implementations through the `export_plugin!` macro.
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -13,12 +13,25 @@
|
|||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::BTreeMap, path::PathBuf};
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
ffi::{CStr, CString, c_char},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
pub const API_VERSION: u32 = 1;
|
/// The newest stable wire protocol implemented by this SDK.
|
||||||
|
pub const API_VERSION: u32 = 2;
|
||||||
pub const ENGINE_VERSION: &str = "0.3.0";
|
pub const ENGINE_VERSION: &str = "0.3.0";
|
||||||
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest_v1\0";
|
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest\0";
|
||||||
pub const FACTORY_SYMBOL: &[u8] = b"convertis_plugin_create_v1\0";
|
pub const METADATA_SYMBOL_V2: &[u8] = b"convertis_plugin_metadata_v2\0";
|
||||||
|
pub const AVAILABILITY_SYMBOL_V2: &[u8] = b"convertis_plugin_availability_v2\0";
|
||||||
|
pub const CONVERT_SYMBOL_V2: &[u8] = b"convertis_plugin_convert_v2\0";
|
||||||
|
pub const FREE_SYMBOL_V2: &[u8] = b"convertis_plugin_free_string_v2\0";
|
||||||
|
|
||||||
|
// Protocol v1 crossed the Rust ABI boundary. These names are kept forever so
|
||||||
|
// engines can retain an adapter for plugins built before the stable protocol.
|
||||||
|
pub const LEGACY_MANIFEST_SYMBOL_V1: &[u8] = b"convertis_plugin_manifest_v1\0";
|
||||||
|
pub const LEGACY_FACTORY_SYMBOL_V1: &[u8] = b"convertis_plugin_create_v1\0";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum ArtifactKind {
|
pub enum ArtifactKind {
|
||||||
@@ -79,7 +92,7 @@ pub struct PluginMetadata {
|
|||||||
pub options: Vec<OptionSpec>,
|
pub options: Vec<OptionSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ConversionRequest {
|
pub struct ConversionRequest {
|
||||||
pub input: PathBuf,
|
pub input: PathBuf,
|
||||||
pub output: PathBuf,
|
pub output: PathBuf,
|
||||||
@@ -98,19 +111,77 @@ pub trait Plugin: Send + Sync {
|
|||||||
|
|
||||||
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
|
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
|
||||||
pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char;
|
pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char;
|
||||||
|
pub type PluginJsonCall = unsafe extern "C" fn() -> *mut c_char;
|
||||||
|
pub type PluginConvertCall = unsafe extern "C" fn(*const c_char) -> *mut c_char;
|
||||||
|
pub type PluginStringFree = unsafe extern "C" fn(*mut c_char);
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct WireResponse<T> {
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<T>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> WireResponse<T> {
|
||||||
|
pub fn success(result: T) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: true,
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failure(error: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn wire_json<T: Serialize>(response: WireResponse<T>) -> *mut c_char {
|
||||||
|
let json = serde_json::to_string(&response).unwrap_or_else(|_| {
|
||||||
|
"{\"ok\":false,\"error\":\"plugin response serialization failed\"}".to_owned()
|
||||||
|
});
|
||||||
|
CString::new(json)
|
||||||
|
.expect("JSON serialization cannot contain a NUL byte")
|
||||||
|
.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub unsafe fn request_from_json(pointer: *const c_char) -> Result<ConversionRequest, String> {
|
||||||
|
if pointer.is_null() {
|
||||||
|
return Err("conversion request was null".to_owned());
|
||||||
|
}
|
||||||
|
let json = unsafe { CStr::from_ptr(pointer) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|error| format!("conversion request was not UTF-8: {error}"))?;
|
||||||
|
serde_json::from_str(json).map_err(|error| format!("invalid conversion request: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub unsafe fn free_wire_string(pointer: *mut c_char) {
|
||||||
|
if !pointer.is_null() {
|
||||||
|
drop(unsafe { CString::from_raw(pointer) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! export_plugin {
|
macro_rules! export_plugin {
|
||||||
($plugin:expr, $id:literal) => {
|
($plugin:expr, $id:literal) => {
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub extern "C" fn convertis_plugin_manifest_v1() -> *const std::ffi::c_char {
|
pub extern "C" fn convertis_plugin_manifest() -> *const std::ffi::c_char {
|
||||||
static MANIFEST: &str = concat!(
|
static MANIFEST: &str = concat!(
|
||||||
"{\"api_version\":1,\"engine_version\":\"",
|
"{\"manifest_version\":1,\"protocol_versions\":[2],\"engine_requirement\":\">=",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
"\",\"plugin_id\":\"",
|
"\",\"plugin_id\":\"",
|
||||||
$id,
|
$id,
|
||||||
"\",\"rustc_version\":\"",
|
"\",\"plugin_version\":\"",
|
||||||
env!("CONVERTIS_RUSTC_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
"\",\"target\":\"",
|
"\",\"target\":\"",
|
||||||
env!("CONVERTIS_TARGET"),
|
env!("CONVERTIS_TARGET"),
|
||||||
"\"}\0"
|
"\"}\0"
|
||||||
@@ -119,8 +190,79 @@ macro_rules! export_plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub fn convertis_plugin_create_v1() -> Box<dyn $crate::Plugin> {
|
pub extern "C" fn convertis_plugin_metadata_v2() -> *mut std::ffi::c_char {
|
||||||
Box::new($plugin)
|
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
$crate::Plugin::metadata(&$plugin)
|
||||||
|
}))
|
||||||
|
.map($crate::WireResponse::success)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
$crate::WireResponse::failure("plugin panicked while returning metadata")
|
||||||
|
});
|
||||||
|
$crate::wire_json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn convertis_plugin_availability_v2() -> *mut std::ffi::c_char {
|
||||||
|
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
$crate::Plugin::availability(&$plugin)
|
||||||
|
}));
|
||||||
|
let response = match response {
|
||||||
|
Ok(Ok(())) => $crate::WireResponse::success(()),
|
||||||
|
Ok(Err(error)) => $crate::WireResponse::failure(error),
|
||||||
|
Err(_) => {
|
||||||
|
$crate::WireResponse::failure("plugin panicked while checking availability")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$crate::wire_json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn convertis_plugin_convert_v2(
|
||||||
|
request: *const std::ffi::c_char,
|
||||||
|
) -> *mut std::ffi::c_char {
|
||||||
|
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe {
|
||||||
|
$crate::request_from_json(request)
|
||||||
|
.and_then(|request| $crate::Plugin::convert(&$plugin, &request))
|
||||||
|
}));
|
||||||
|
let response = match response {
|
||||||
|
Ok(Ok(())) => $crate::WireResponse::success(()),
|
||||||
|
Ok(Err(error)) => $crate::WireResponse::failure(error),
|
||||||
|
Err(_) => $crate::WireResponse::failure("plugin panicked during conversion"),
|
||||||
|
};
|
||||||
|
$crate::wire_json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn convertis_plugin_free_string_v2(pointer: *mut std::ffi::c_char) {
|
||||||
|
unsafe { $crate::free_wire_string(pointer) }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversion_requests_round_trip_through_json() {
|
||||||
|
let request = ConversionRequest {
|
||||||
|
input: "/tmp/input.png".into(),
|
||||||
|
output: "/tmp/output.jpg".into(),
|
||||||
|
from: "png".into(),
|
||||||
|
to: "jpeg".into(),
|
||||||
|
options: BTreeMap::from([("quality".into(), "90".into())]),
|
||||||
|
};
|
||||||
|
let json = CString::new(serde_json::to_string(&request).unwrap()).unwrap();
|
||||||
|
let decoded = unsafe { request_from_json(json.as_ptr()) }.unwrap();
|
||||||
|
assert_eq!(decoded.input, request.input);
|
||||||
|
assert_eq!(decoded.options, request.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_success_is_explicit_even_though_json_result_is_null() {
|
||||||
|
let json = serde_json::to_string(&WireResponse::success(())).unwrap();
|
||||||
|
let decoded: WireResponse<()> = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(decoded.ok);
|
||||||
|
assert!(decoded.error.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -21,14 +21,14 @@ pub struct Args {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
help = "List formats supported by installed plugins.",
|
help = "List formats supported by installed plugins.",
|
||||||
exclusive = true
|
conflicts_with = "list_plugins"
|
||||||
)]
|
)]
|
||||||
pub list_formats: bool,
|
pub list_formats: bool,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
help = "List installed and known official plugins.",
|
help = "List installed and known official plugins.",
|
||||||
exclusive = true
|
conflicts_with = "list_formats"
|
||||||
)]
|
)]
|
||||||
pub list_plugins: bool,
|
pub list_plugins: bool,
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const MAGICK_IMAGES: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
||||||
|
tracing::trace!(
|
||||||
|
format_count = formats.len(),
|
||||||
|
?scores,
|
||||||
|
"building all-pairs conversion catalog"
|
||||||
|
);
|
||||||
formats
|
formats
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|from| {
|
.flat_map(|from| {
|
||||||
@@ -68,6 +73,12 @@ fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
||||||
|
tracing::trace!(
|
||||||
|
input_count = inputs.len(),
|
||||||
|
output_count = outputs.len(),
|
||||||
|
?scores,
|
||||||
|
"building cross-pair conversion catalog"
|
||||||
|
);
|
||||||
inputs
|
inputs
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|from| {
|
.flat_map(|from| {
|
||||||
@@ -85,6 +96,12 @@ fn metadata(
|
|||||||
description: &str,
|
description: &str,
|
||||||
conversions: Vec<Conversion>,
|
conversions: Vec<Conversion>,
|
||||||
) -> PluginMetadata {
|
) -> PluginMetadata {
|
||||||
|
tracing::trace!(
|
||||||
|
id,
|
||||||
|
package,
|
||||||
|
conversion_count = conversions.len(),
|
||||||
|
"building official plugin metadata"
|
||||||
|
);
|
||||||
PluginMetadata {
|
PluginMetadata {
|
||||||
id: id.to_owned(),
|
id: id.to_owned(),
|
||||||
package: package.to_owned(),
|
package: package.to_owned(),
|
||||||
@@ -95,6 +112,7 @@ fn metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn official_plugins() -> Vec<PluginMetadata> {
|
pub fn official_plugins() -> Vec<PluginMetadata> {
|
||||||
|
tracing::trace!("building official plugin catalog");
|
||||||
let mut plugins = vec![
|
let mut plugins = vec![
|
||||||
metadata(
|
metadata(
|
||||||
"ffmpeg-audio",
|
"ffmpeg-audio",
|
||||||
@@ -195,13 +213,23 @@ pub fn official_plugins() -> Vec<PluginMetadata> {
|
|||||||
help: "Override frame rate; folders without metadata default to 30".into(),
|
help: "Override frame rate; folders without metadata default to 30".into(),
|
||||||
default: None,
|
default: None,
|
||||||
}];
|
}];
|
||||||
|
tracing::trace!(
|
||||||
|
plugin_count = plugins.len(),
|
||||||
|
"official plugin catalog built"
|
||||||
|
);
|
||||||
plugins
|
plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
|
pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
|
||||||
|
tracing::debug!(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
"searching official catalog for package recommendation"
|
||||||
|
);
|
||||||
let plugins = official_plugins();
|
let plugins = official_plugins();
|
||||||
let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
|
let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
|
||||||
for (index, plugin) in plugins.iter().enumerate() {
|
for (index, plugin) in plugins.iter().enumerate() {
|
||||||
|
tracing::trace!(plugin = %plugin.id, conversion_count = plugin.conversions.len(), "adding official plugin to recommendation graph");
|
||||||
for conversion in &plugin.conversions {
|
for conversion in &plugin.conversions {
|
||||||
edges
|
edges
|
||||||
.entry(&conversion.from)
|
.entry(&conversion.from)
|
||||||
@@ -212,26 +240,41 @@ pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
|
|||||||
let mut queue = VecDeque::from([(from, Vec::<usize>::new())]);
|
let mut queue = VecDeque::from([(from, Vec::<usize>::new())]);
|
||||||
let mut visited = HashSet::from([from]);
|
let mut visited = HashSet::from([from]);
|
||||||
while let Some((current, path)) = queue.pop_front() {
|
while let Some((current, path)) = queue.pop_front() {
|
||||||
|
tracing::trace!(
|
||||||
|
current,
|
||||||
|
depth = path.len(),
|
||||||
|
queue_length = queue.len(),
|
||||||
|
"visiting package recommendation state"
|
||||||
|
);
|
||||||
if current == to {
|
if current == to {
|
||||||
let mut packages = Vec::new();
|
let mut packages = Vec::new();
|
||||||
for index in path {
|
for index in path {
|
||||||
let package = plugins[index].package.clone();
|
let package = plugins[index].package.clone();
|
||||||
if !packages.contains(&package) {
|
if !packages.contains(&package) {
|
||||||
|
tracing::trace!(package, "adding package to recommendation");
|
||||||
packages.push(package);
|
packages.push(package);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::debug!(?packages, "found package recommendation");
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
if let Some(next_edges) = edges.get(current) {
|
if let Some(next_edges) = edges.get(current) {
|
||||||
for &(plugin, next) in next_edges {
|
for &(plugin, next) in next_edges {
|
||||||
|
tracing::trace!(current, next, plugin = %plugins[plugin].id, "considering recommendation graph edge");
|
||||||
if visited.insert(next) {
|
if visited.insert(next) {
|
||||||
let mut next_path = path.clone();
|
let mut next_path = path.clone();
|
||||||
next_path.push(plugin);
|
next_path.push(plugin);
|
||||||
queue.push_back((next, next_path));
|
queue.push_back((next, next_path));
|
||||||
|
tracing::trace!(
|
||||||
|
next,
|
||||||
|
queue_length = queue.len(),
|
||||||
|
"queued package recommendation state"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::debug!(from, to, "official catalog has no package recommendation");
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-20
@@ -24,55 +24,78 @@ pub struct DetectedFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalized(format: &str) -> String {
|
fn normalized(format: &str) -> String {
|
||||||
match format.to_ascii_lowercase().as_str() {
|
let normalized = match format.to_ascii_lowercase().as_str() {
|
||||||
"jpg" => "jpeg".to_owned(),
|
"jpg" => "jpeg".to_owned(),
|
||||||
"htm" => "html".to_owned(),
|
"htm" => "html".to_owned(),
|
||||||
"txt" | "ascii" => "text".to_owned(),
|
"txt" | "ascii" => "text".to_owned(),
|
||||||
other => other.to_owned(),
|
other => other.to_owned(),
|
||||||
}
|
};
|
||||||
|
tracing::trace!(
|
||||||
|
input = format,
|
||||||
|
output = normalized,
|
||||||
|
"normalized format name"
|
||||||
|
);
|
||||||
|
normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
|
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
|
||||||
explicit.map(normalized).or_else(|| {
|
tracing::trace!(path = ?path, explicit, "resolving requested format");
|
||||||
|
let requested = explicit.map(normalized).or_else(|| {
|
||||||
path.and_then(Path::extension)
|
path.and_then(Path::extension)
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.map(normalized)
|
.map(normalized)
|
||||||
})
|
});
|
||||||
|
tracing::trace!(?requested, "resolved requested format");
|
||||||
|
requested
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
|
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
|
||||||
|
tracing::debug!(path = %path.display(), explicit, is_directory = path.is_dir(), "starting path identification");
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
|
tracing::trace!(path = %path.display(), "identifying directory artifact");
|
||||||
if explicit.is_some_and(|format| normalized(format) != "frames") {
|
if explicit.is_some_and(|format| normalized(format) != "frames") {
|
||||||
return Err("directory inputs currently support only the 'frames' format".to_owned());
|
return Err("directory inputs currently support only the 'frames' format".to_owned());
|
||||||
}
|
}
|
||||||
let manifest = path.join(".convertis-frames.json");
|
let manifest = path.join(".convertis-frames.json");
|
||||||
|
tracing::trace!(manifest = %manifest.display(), exists = manifest.exists(), "checking frame manifest");
|
||||||
if manifest.exists() {
|
if manifest.exists() {
|
||||||
let value: serde_json::Value = serde_json::from_slice(
|
tracing::trace!(manifest = %manifest.display(), "reading frame manifest");
|
||||||
&fs::read(&manifest)
|
let bytes = fs::read(&manifest)
|
||||||
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?,
|
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?;
|
||||||
)
|
tracing::trace!(manifest = %manifest.display(), byte_count = bytes.len(), "parsing frame manifest");
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||||
.map_err(|error| format!("invalid frame manifest: {error}"))?;
|
.map_err(|error| format!("invalid frame manifest: {error}"))?;
|
||||||
if value
|
let schema_version = value
|
||||||
.get("schema_version")
|
.get("schema_version")
|
||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64);
|
||||||
!= Some(1)
|
tracing::trace!(?schema_version, "read frame manifest schema version");
|
||||||
{
|
if schema_version != Some(1) {
|
||||||
return Err("unsupported frame manifest schema".to_owned());
|
return Err("unsupported frame manifest schema".to_owned());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
tracing::trace!(path = %path.display(), "scanning directory for recognizable frames");
|
||||||
let mut detected = None;
|
let mut detected = None;
|
||||||
for entry in fs::read_dir(path)
|
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||||
.map_err(|error| error.to_string())?
|
let entry = match entry {
|
||||||
.flatten()
|
Ok(entry) => entry,
|
||||||
{
|
Err(error) => {
|
||||||
|
tracing::trace!(%error, "could not inspect frame-directory entry");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::trace!(entry = %entry.path().display(), "inspecting frame-directory entry");
|
||||||
if !entry.path().is_file() {
|
if !entry.path().is_file() {
|
||||||
|
tracing::trace!(entry = %entry.path().display(), "skipping non-file frame-directory entry");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
|
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
|
||||||
|
tracing::trace!(entry = %entry.path().display(), byte_count = bytes.len(), "read potential frame file");
|
||||||
let Some(format) = identify_bytes(&bytes) else {
|
let Some(format) = identify_bytes(&bytes) else {
|
||||||
|
tracing::trace!(entry = %entry.path().display(), "frame candidate was not recognizable");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if format.media_kind != MediaKind::Image {
|
if format.media_kind != MediaKind::Image {
|
||||||
|
tracing::trace!(entry = %entry.path().display(), media_kind = ?format.media_kind, "frame candidate was not a still image");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if detected
|
if detected
|
||||||
@@ -83,6 +106,7 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
|
|||||||
"frame directories without metadata must use one image format".to_owned(),
|
"frame directories without metadata must use one image format".to_owned(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
tracing::trace!(entry = %entry.path().display(), format = %format.format, "accepted frame candidate");
|
||||||
detected = Some(format.format);
|
detected = Some(format.format);
|
||||||
}
|
}
|
||||||
if detected.is_none() {
|
if detected.is_none() {
|
||||||
@@ -91,6 +115,7 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::debug!(path = %path.display(), "identified frames directory");
|
||||||
return Ok(DetectedFormat {
|
return Ok(DetectedFormat {
|
||||||
format: "frames".to_owned(),
|
format: "frames".to_owned(),
|
||||||
mime: "application/vnd.convertis.frames+json".to_owned(),
|
mime: "application/vnd.convertis.frames+json".to_owned(),
|
||||||
@@ -98,51 +123,109 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
|
|||||||
artifact_kind: ArtifactKind::Directory,
|
artifact_kind: ArtifactKind::Directory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
tracing::trace!(path = %path.display(), "reading file for content identification");
|
||||||
let bytes =
|
let bytes =
|
||||||
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
|
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
|
||||||
|
tracing::trace!(path = %path.display(), byte_count = bytes.len(), "read file for identification");
|
||||||
if let Some(format) = explicit {
|
if let Some(format) = explicit {
|
||||||
return Ok(from_format(&normalized(format), &bytes));
|
let format = normalized(format);
|
||||||
|
tracing::debug!(path = %path.display(), format, "using explicit input format");
|
||||||
|
return Ok(from_format(&format, &bytes));
|
||||||
}
|
}
|
||||||
identify_bytes(&bytes)
|
let identified = identify_bytes(&bytes)
|
||||||
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))
|
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))?;
|
||||||
|
tracing::debug!(path = %path.display(), format = %identified.format, "identified file contents");
|
||||||
|
Ok(identified)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
|
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
|
||||||
|
tracing::trace!(
|
||||||
|
byte_count = bytes.len(),
|
||||||
|
"starting content signature identification"
|
||||||
|
);
|
||||||
if bytes.starts_with(&[0, 0, 1, 0]) {
|
if bytes.starts_with(&[0, 0, 1, 0]) {
|
||||||
|
tracing::trace!(rule = "ico-header", "matched content signature");
|
||||||
return Some(from_format("ico", bytes));
|
return Some(from_format("ico", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
rule = "ico-header",
|
||||||
|
matched = false,
|
||||||
|
"checked content signature"
|
||||||
|
);
|
||||||
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
|
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
|
||||||
|
tracing::trace!(rule = "html-prefix", "matched content signature");
|
||||||
return Some(from_format("html", bytes));
|
return Some(from_format("html", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
rule = "html-prefix",
|
||||||
|
matched = false,
|
||||||
|
"checked content signature"
|
||||||
|
);
|
||||||
if bytes.windows(4).any(|window| window == b"M4A ") {
|
if bytes.windows(4).any(|window| window == b"M4A ") {
|
||||||
|
tracing::trace!(rule = "m4a-brand", "matched content signature");
|
||||||
return Some(from_format("m4a", bytes));
|
return Some(from_format("m4a", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
rule = "m4a-brand",
|
||||||
|
matched = false,
|
||||||
|
"checked content signature"
|
||||||
|
);
|
||||||
if bytes.starts_with(b"OggS") {
|
if bytes.starts_with(b"OggS") {
|
||||||
|
tracing::trace!(rule = "ogg-header", "matched content signature");
|
||||||
if bytes.windows(8).any(|window| window == b"OpusHead") {
|
if bytes.windows(8).any(|window| window == b"OpusHead") {
|
||||||
|
tracing::trace!(rule = "opus-header", "matched Ogg subtype signature");
|
||||||
return Some(from_format("opus", bytes));
|
return Some(from_format("opus", bytes));
|
||||||
}
|
}
|
||||||
if bytes.windows(6).any(|window| window == b"theora") {
|
if bytes.windows(6).any(|window| window == b"theora") {
|
||||||
|
tracing::trace!(rule = "theora-header", "matched Ogg subtype signature");
|
||||||
return Some(from_format("ogv", bytes));
|
return Some(from_format("ogv", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!("no Ogg subtype signature matched; using generic Ogg format");
|
||||||
return Some(from_format("ogg", bytes));
|
return Some(from_format("ogg", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
rule = "ogg-header",
|
||||||
|
matched = false,
|
||||||
|
"checked content signature"
|
||||||
|
);
|
||||||
if let Some(kind) = infer::get(bytes) {
|
if let Some(kind) = infer::get(bytes) {
|
||||||
return Some(from_format(&normalized(kind.extension()), bytes));
|
let format = normalized(kind.extension());
|
||||||
|
tracing::trace!(
|
||||||
|
infer_extension = kind.extension(),
|
||||||
|
mime = kind.mime_type(),
|
||||||
|
format,
|
||||||
|
"matched infer signature database"
|
||||||
|
);
|
||||||
|
return Some(from_format(&format, bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!("infer signature database did not match");
|
||||||
let text = std::str::from_utf8(bytes).ok()?;
|
let text = std::str::from_utf8(bytes).ok()?;
|
||||||
|
tracing::trace!(
|
||||||
|
utf8 = true,
|
||||||
|
character_count = text.chars().count(),
|
||||||
|
"content is valid UTF-8"
|
||||||
|
);
|
||||||
if !text.contains('\0') {
|
if !text.contains('\0') {
|
||||||
|
tracing::trace!(rule = "utf8-without-nul", "classified content as text");
|
||||||
return Some(from_format("text", bytes));
|
return Some(from_format("text", bytes));
|
||||||
}
|
}
|
||||||
|
tracing::trace!("content did not match any supported identification rule");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
||||||
|
tracing::trace!(
|
||||||
|
format,
|
||||||
|
byte_count = bytes.len(),
|
||||||
|
"deriving media details from format"
|
||||||
|
);
|
||||||
let animation = match format {
|
let animation = match format {
|
||||||
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
|
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
|
||||||
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
|
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
|
||||||
"gif" => gif_is_animated(bytes),
|
"gif" => gif_is_animated(bytes),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
tracing::trace!(format, animation, "determined animation state");
|
||||||
let media_kind = if animation {
|
let media_kind = if animation {
|
||||||
MediaKind::Animation
|
MediaKind::Animation
|
||||||
} else if matches!(
|
} else if matches!(
|
||||||
@@ -194,6 +277,7 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
|||||||
} else {
|
} else {
|
||||||
format.to_owned()
|
format.to_owned()
|
||||||
};
|
};
|
||||||
|
tracing::trace!(format, mime, ?media_kind, "derived media details");
|
||||||
DetectedFormat {
|
DetectedFormat {
|
||||||
format,
|
format,
|
||||||
mime: mime.to_owned(),
|
mime: mime.to_owned(),
|
||||||
@@ -203,18 +287,27 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn gif_is_animated(bytes: &[u8]) -> bool {
|
fn gif_is_animated(bytes: &[u8]) -> bool {
|
||||||
|
tracing::trace!(byte_count = bytes.len(), "checking GIF animation state");
|
||||||
let mut options = gif::DecodeOptions::new();
|
let mut options = gif::DecodeOptions::new();
|
||||||
options.set_color_output(gif::ColorOutput::Indexed);
|
options.set_color_output(gif::ColorOutput::Indexed);
|
||||||
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
|
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
|
||||||
|
tracing::trace!("GIF decoder rejected content while checking animation");
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut frames = 0;
|
let mut frames = 0;
|
||||||
while let Ok(Some(_)) = decoder.read_next_frame() {
|
while let Ok(Some(_)) = decoder.read_next_frame() {
|
||||||
frames += 1;
|
frames += 1;
|
||||||
|
tracing::trace!(frames, "decoded GIF frame while checking animation");
|
||||||
if frames > 1 {
|
if frames > 1 {
|
||||||
|
tracing::trace!(frames, animated = true, "GIF has multiple frames");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
frames,
|
||||||
|
animated = false,
|
||||||
|
"GIF has fewer than two decodable frames"
|
||||||
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+110
-7
@@ -33,38 +33,70 @@ use std::{
|
|||||||
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
||||||
|
|
||||||
fn fail(message: impl std::fmt::Display) -> ! {
|
fn fail(message: impl std::fmt::Display) -> ! {
|
||||||
|
tracing::trace!("entering fatal error path");
|
||||||
tracing::error!("{message}");
|
tracing::error!("{message}");
|
||||||
|
tracing::trace!(exit_code = 1, "terminating process");
|
||||||
process::exit(1)
|
process::exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
|
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
|
||||||
|
tracing::trace!(value_count = values.len(), "parsing plugin options");
|
||||||
let mut options = BTreeMap::new();
|
let mut options = BTreeMap::new();
|
||||||
for value in values {
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
tracing::trace!(index, raw_length = value.len(), "parsing plugin option");
|
||||||
let (key, value) = value
|
let (key, value) = value
|
||||||
.split_once('=')
|
.split_once('=')
|
||||||
.ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?;
|
.ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?;
|
||||||
|
tracing::trace!(
|
||||||
|
index,
|
||||||
|
key,
|
||||||
|
value_length = value.len(),
|
||||||
|
"split plugin option"
|
||||||
|
);
|
||||||
if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() {
|
if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() {
|
||||||
return Err(format!("invalid or duplicate option '{key}'"));
|
return Err(format!("invalid or duplicate option '{key}'"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::trace!(option_count = options.len(), "plugin options parsed");
|
||||||
Ok(options)
|
Ok(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_overwrite(args: &Args, output: &Path) -> bool {
|
fn should_overwrite(args: &Args, output: &Path) -> bool {
|
||||||
if !output.exists() {
|
let exists = output.exists();
|
||||||
|
tracing::trace!(
|
||||||
|
output = %output.display(),
|
||||||
|
exists,
|
||||||
|
yes = args.yes,
|
||||||
|
no = args.no,
|
||||||
|
quiet = args.quiet,
|
||||||
|
stdin_terminal = io::stdin().is_terminal(),
|
||||||
|
"evaluating output overwrite policy"
|
||||||
|
);
|
||||||
|
if !exists {
|
||||||
|
tracing::trace!("output is absent; overwrite is allowed");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if args.yes {
|
if args.yes {
|
||||||
|
tracing::trace!("overwrite accepted by --yes");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if args.no || args.quiet || !io::stdin().is_terminal() {
|
if args.no || args.quiet || !io::stdin().is_terminal() {
|
||||||
|
tracing::trace!("overwrite declined without prompting");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
tracing::trace!("prompting for overwrite confirmation");
|
||||||
print!("'{}' exists. Replace it? [y/N]: ", output.display());
|
print!("'{}' exists. Replace it? [y/N]: ", output.display());
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
let mut answer = String::new();
|
let mut answer = String::new();
|
||||||
io::stdin().read_line(&mut answer).is_ok()
|
let read = io::stdin().read_line(&mut answer);
|
||||||
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
let accepted =
|
||||||
|
read.is_ok() && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes");
|
||||||
|
tracing::trace!(
|
||||||
|
read_success = read.is_ok(),
|
||||||
|
accepted,
|
||||||
|
"processed overwrite response"
|
||||||
|
);
|
||||||
|
accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -76,15 +108,30 @@ fn main() {
|
|||||||
tracing::Level::from(args.verbose).into()
|
tracing::Level::from(args.verbose).into()
|
||||||
};
|
};
|
||||||
tracing_subscriber::fmt().with_max_level(level).init();
|
tracing_subscriber::fmt().with_max_level(level).init();
|
||||||
|
tracing::debug!(
|
||||||
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
|
target = env!("CONVERTIS_TARGET"),
|
||||||
|
rustc = env!("CONVERTIS_RUSTC_VERSION"),
|
||||||
|
?level,
|
||||||
|
"Convertis engine started"
|
||||||
|
);
|
||||||
|
tracing::trace!(?args, "parsed command-line arguments");
|
||||||
|
|
||||||
let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins);
|
let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins);
|
||||||
|
tracing::trace!(
|
||||||
|
plugin_count = registry.plugins.len(),
|
||||||
|
diagnostic_count = registry.diagnostics.len(),
|
||||||
|
"plugin registry is ready"
|
||||||
|
);
|
||||||
for diagnostic in ®istry.diagnostics {
|
for diagnostic in ®istry.diagnostics {
|
||||||
tracing::warn!("{diagnostic}");
|
tracing::warn!("{diagnostic}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if args.list_plugins {
|
if args.list_plugins {
|
||||||
|
tracing::debug!("listing official plugins");
|
||||||
println!("Official plugins:");
|
println!("Official plugins:");
|
||||||
for metadata in catalog::official_plugins() {
|
for metadata in catalog::official_plugins() {
|
||||||
|
tracing::trace!(plugin = %metadata.id, package = %metadata.package, "checking official plugin state");
|
||||||
let installed = registry
|
let installed = registry
|
||||||
.plugins
|
.plugins
|
||||||
.iter()
|
.iter()
|
||||||
@@ -94,17 +141,23 @@ fn main() {
|
|||||||
Some(Err(error)) => format!("unavailable: {error}"),
|
Some(Err(error)) => format!("unavailable: {error}"),
|
||||||
None => "not installed".to_owned(),
|
None => "not installed".to_owned(),
|
||||||
};
|
};
|
||||||
|
tracing::trace!(plugin = %metadata.id, state, "resolved official plugin state");
|
||||||
println!(
|
println!(
|
||||||
" {:32} {:28} {}",
|
" {:32} {:28} {}",
|
||||||
metadata.package, state, metadata.description
|
metadata.package, state, metadata.description
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
tracing::debug!("finished listing official plugins");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if args.list_formats {
|
if args.list_formats {
|
||||||
|
tracing::debug!("collecting formats from installed plugins");
|
||||||
let mut formats = HashSet::new();
|
let mut formats = HashSet::new();
|
||||||
for plugin in ®istry.plugins {
|
for plugin in ®istry.plugins {
|
||||||
for conversion in plugin.metadata().conversions {
|
let metadata = plugin.metadata();
|
||||||
|
tracing::trace!(plugin = %metadata.id, conversion_count = metadata.conversions.len(), "collecting plugin formats");
|
||||||
|
for conversion in metadata.conversions {
|
||||||
|
tracing::trace!(plugin = %metadata.id, from = %conversion.from, to = %conversion.to, "collecting conversion formats");
|
||||||
formats.insert(conversion.from);
|
formats.insert(conversion.from);
|
||||||
formats.insert(conversion.to);
|
formats.insert(conversion.to);
|
||||||
}
|
}
|
||||||
@@ -116,6 +169,7 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
println!("{}", formats.join("\n"));
|
println!("{}", formats.join("\n"));
|
||||||
}
|
}
|
||||||
|
tracing::debug!(format_count = formats.len(), "finished listing formats");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,30 +177,52 @@ fn main() {
|
|||||||
.input_path
|
.input_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or_else(|| fail("an input path is required"));
|
.unwrap_or_else(|| fail("an input path is required"));
|
||||||
|
tracing::debug!(input = %input.display(), explicit_format = ?args.from_format, "identifying input");
|
||||||
let detected = identifier::identify_path(input, args.from_format.as_deref())
|
let detected = identifier::identify_path(input, args.from_format.as_deref())
|
||||||
.unwrap_or_else(|error| fail(error));
|
.unwrap_or_else(|error| fail(error));
|
||||||
tracing::info!("detected {} ({:?})", detected.format, detected.media_kind);
|
tracing::info!(
|
||||||
|
format = %detected.format,
|
||||||
|
mime = %detected.mime,
|
||||||
|
media_kind = ?detected.media_kind,
|
||||||
|
artifact_kind = ?detected.artifact_kind,
|
||||||
|
"input identified"
|
||||||
|
);
|
||||||
|
tracing::trace!(output = ?args.output_path, explicit_target = ?args.to_format, "resolving requested target format");
|
||||||
let mut target =
|
let mut target =
|
||||||
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
|
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
fail("a target is required; provide an output extension or --to FORMAT")
|
fail("a target is required; provide an output extension or --to FORMAT")
|
||||||
});
|
});
|
||||||
|
tracing::trace!(target, "resolved initial target format");
|
||||||
if matches!(
|
if matches!(
|
||||||
detected.media_kind,
|
detected.media_kind,
|
||||||
convertis_plugin_api::MediaKind::Video
|
convertis_plugin_api::MediaKind::Video
|
||||||
| convertis_plugin_api::MediaKind::Animation
|
| convertis_plugin_api::MediaKind::Animation
|
||||||
| convertis_plugin_api::MediaKind::Frames
|
| convertis_plugin_api::MediaKind::Frames
|
||||||
) {
|
) {
|
||||||
|
let original_target = target.clone();
|
||||||
target = match target.as_str() {
|
target = match target.as_str() {
|
||||||
"gif" => "animated-gif".to_owned(),
|
"gif" => "animated-gif".to_owned(),
|
||||||
"webp" => "animated-webp".to_owned(),
|
"webp" => "animated-webp".to_owned(),
|
||||||
_ => target,
|
_ => target,
|
||||||
};
|
};
|
||||||
|
tracing::trace!(
|
||||||
|
original_target,
|
||||||
|
normalized_target = target,
|
||||||
|
"normalized animated target format"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let options = parse_options(&args.options).unwrap_or_else(|error| fail(error));
|
let options = parse_options(&args.options).unwrap_or_else(|error| fail(error));
|
||||||
let mut banned = Vec::new();
|
let mut banned = Vec::new();
|
||||||
|
|
||||||
let result = loop {
|
let result = loop {
|
||||||
|
tracing::debug!(
|
||||||
|
from = %detected.format,
|
||||||
|
to = %target,
|
||||||
|
priority = %args.priority,
|
||||||
|
banned = ?banned,
|
||||||
|
"searching for a conversion route"
|
||||||
|
);
|
||||||
let Some(route) = pathfinder::find_best_path(
|
let Some(route) = pathfinder::find_best_path(
|
||||||
®istry.plugins,
|
®istry.plugins,
|
||||||
&detected.format,
|
&detected.format,
|
||||||
@@ -154,7 +230,9 @@ fn main() {
|
|||||||
&args.priority,
|
&args.priority,
|
||||||
&banned,
|
&banned,
|
||||||
) else {
|
) else {
|
||||||
|
tracing::debug!("no installed conversion route was found");
|
||||||
let packages = catalog::recommend_packages(&detected.format, &target);
|
let packages = catalog::recommend_packages(&detected.format, &target);
|
||||||
|
tracing::trace!(?packages, "resolved package recommendations");
|
||||||
if packages.is_empty() {
|
if packages.is_empty() {
|
||||||
fail(format!(
|
fail(format!(
|
||||||
"no conversion path from {} to {} is known",
|
"no conversion path from {} to {} is known",
|
||||||
@@ -189,7 +267,9 @@ fn main() {
|
|||||||
packages.join(" ")
|
packages.join(" ")
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
tracing::debug!(step_count = route.len(), "selected conversion route");
|
||||||
for key in options.keys() {
|
for key in options.keys() {
|
||||||
|
tracing::trace!(key, "validating option against selected route");
|
||||||
let recognized = route.iter().any(|step| {
|
let recognized = route.iter().any(|step| {
|
||||||
let metadata = step.plugin.metadata();
|
let metadata = step.plugin.metadata();
|
||||||
let option_name = key
|
let option_name = key
|
||||||
@@ -205,37 +285,55 @@ fn main() {
|
|||||||
"option '{key}' is not supported by the selected conversion route"
|
"option '{key}' is not supported by the selected conversion route"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
tracing::trace!(key, "option is supported by selected route");
|
||||||
}
|
}
|
||||||
let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
|
let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
|
||||||
|
tracing::info!(route = %route_names.join(" -> "), "conversion route selected");
|
||||||
if args.test {
|
if args.test {
|
||||||
|
tracing::debug!("test mode requested; skipping conversion");
|
||||||
println!("{}", route_names.join(" -> "));
|
println!("{}", route_names.join(" -> "));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tracing::debug!("executing conversion route");
|
||||||
match runner::run_conversion(&route, input, &args.temp_dir, &options) {
|
match runner::run_conversion(&route, input, &args.temp_dir, &options) {
|
||||||
Ok(result) => break result,
|
Ok(result) => {
|
||||||
|
tracing::debug!(result = %result.path.display(), kind = ?result.kind, "conversion route succeeded");
|
||||||
|
break result;
|
||||||
|
}
|
||||||
Err((error, plugin)) => {
|
Err((error, plugin)) => {
|
||||||
tracing::warn!("plugin {plugin} failed: {error}; trying another route");
|
tracing::warn!("plugin {plugin} failed: {error}; trying another route");
|
||||||
banned.push(plugin);
|
banned.push(plugin);
|
||||||
|
tracing::trace!(?banned, "updated failed-plugin exclusion list");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(output) = &args.output_path {
|
if let Some(output) = &args.output_path {
|
||||||
|
tracing::debug!(output = %output.display(), "installing conversion result");
|
||||||
if !should_overwrite(&args, output) {
|
if !should_overwrite(&args, output) {
|
||||||
fail("output was not replaced");
|
fail("output was not replaced");
|
||||||
}
|
}
|
||||||
if output.exists() {
|
if output.exists() {
|
||||||
if output.is_dir() {
|
if output.is_dir() {
|
||||||
|
tracing::trace!(output = %output.display(), "removing existing output directory");
|
||||||
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
|
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
|
||||||
} else {
|
} else {
|
||||||
|
tracing::trace!(output = %output.display(), "removing existing output file");
|
||||||
fs::remove_file(output).unwrap_or_else(|error| fail(error));
|
fs::remove_file(output).unwrap_or_else(|error| fail(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runner::install_result(&result, output).unwrap_or_else(|error| fail(error));
|
runner::install_result(&result, output).unwrap_or_else(|error| fail(error));
|
||||||
|
tracing::info!(output = %output.display(), "conversion result installed");
|
||||||
} else if result.kind == ArtifactKind::Directory {
|
} else if result.kind == ArtifactKind::Directory {
|
||||||
fail("directory output requires an output path");
|
fail("directory output requires an output path");
|
||||||
} else {
|
} else {
|
||||||
|
tracing::trace!(path = %result.path.display(), "reading result for standard output");
|
||||||
let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error));
|
let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error));
|
||||||
|
tracing::trace!(
|
||||||
|
byte_count = bytes.len(),
|
||||||
|
stdout_terminal = io::stdout().is_terminal(),
|
||||||
|
"prepared result bytes for standard output"
|
||||||
|
);
|
||||||
if io::stdout().is_terminal() && !args.write_to_console {
|
if io::stdout().is_terminal() && !args.write_to_console {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"writing conversion bytes to the terminal; use -c to suppress this warning"
|
"writing conversion bytes to the terminal; use -c to suppress this warning"
|
||||||
@@ -244,5 +342,10 @@ fn main() {
|
|||||||
io::stdout()
|
io::stdout()
|
||||||
.write_all(&bytes)
|
.write_all(&bytes)
|
||||||
.unwrap_or_else(|error| fail(error));
|
.unwrap_or_else(|error| fail(error));
|
||||||
|
tracing::trace!(
|
||||||
|
byte_count = bytes.len(),
|
||||||
|
"wrote result bytes to standard output"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
tracing::debug!("Convertis engine completed successfully");
|
||||||
}
|
}
|
||||||
+80
-9
@@ -22,7 +22,12 @@ pub struct RouteStep<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
|
fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
|
||||||
priority
|
tracing::trace!(
|
||||||
|
step_count = route.len(),
|
||||||
|
priority,
|
||||||
|
"scoring conversion route"
|
||||||
|
);
|
||||||
|
let score: Vec<u32> = priority
|
||||||
.chars()
|
.chars()
|
||||||
.map(|criterion| {
|
.map(|criterion| {
|
||||||
route
|
route
|
||||||
@@ -35,7 +40,9 @@ fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
|
|||||||
})
|
})
|
||||||
.sum()
|
.sum()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
tracing::trace!(?score, "calculated conversion route score");
|
||||||
|
score
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_best_path<'a>(
|
pub fn find_best_path<'a>(
|
||||||
@@ -45,13 +52,29 @@ pub fn find_best_path<'a>(
|
|||||||
priority: &str,
|
priority: &str,
|
||||||
banned: &[String],
|
banned: &[String],
|
||||||
) -> Option<Vec<RouteStep<'a>>> {
|
) -> Option<Vec<RouteStep<'a>>> {
|
||||||
|
tracing::debug!(
|
||||||
|
plugin_count = plugins.len(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
priority,
|
||||||
|
?banned,
|
||||||
|
"building conversion graph"
|
||||||
|
);
|
||||||
let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new();
|
let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new();
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
let metadata = plugin.metadata();
|
let metadata = plugin.metadata();
|
||||||
if banned.contains(&metadata.id) || plugin.availability().is_err() {
|
if banned.contains(&metadata.id) {
|
||||||
|
tracing::trace!(plugin = %metadata.id, "excluded banned plugin from conversion graph");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
tracing::trace!(plugin = %metadata.id, "checking plugin availability for conversion graph");
|
||||||
|
if let Err(error) = plugin.availability() {
|
||||||
|
tracing::debug!(plugin = %metadata.id, %error, "excluded unavailable plugin from conversion graph");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tracing::trace!(plugin = %metadata.id, conversion_count = metadata.conversions.len(), "adding available plugin conversions to graph");
|
||||||
for conversion in metadata.conversions {
|
for conversion in metadata.conversions {
|
||||||
|
tracing::trace!(plugin = %metadata.id, from = %conversion.from, to = %conversion.to, "adding conversion graph edge");
|
||||||
edges
|
edges
|
||||||
.entry(conversion.from.clone())
|
.entry(conversion.from.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -61,38 +84,86 @@ pub fn find_best_path<'a>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let edge_count: usize = edges.values().map(Vec::len).sum();
|
||||||
|
tracing::debug!(
|
||||||
|
node_count = edges.len(),
|
||||||
|
edge_count,
|
||||||
|
"conversion graph built"
|
||||||
|
);
|
||||||
|
|
||||||
let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]);
|
let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]);
|
||||||
let mut depths = HashMap::from([(from.to_owned(), 0usize)]);
|
let mut depths = HashMap::from([(from.to_owned(), 0usize)]);
|
||||||
let mut solutions = Vec::new();
|
let mut solutions = Vec::new();
|
||||||
let mut minimum = None;
|
let mut minimum = None;
|
||||||
while let Some((current, route)) = queue.pop_front() {
|
while let Some((current, route)) = queue.pop_front() {
|
||||||
|
tracing::trace!(
|
||||||
|
current,
|
||||||
|
depth = route.len(),
|
||||||
|
queue_length = queue.len(),
|
||||||
|
known_minimum = ?minimum,
|
||||||
|
"visiting conversion graph state"
|
||||||
|
);
|
||||||
if minimum.is_some_and(|depth| route.len() > depth) {
|
if minimum.is_some_and(|depth| route.len() > depth) {
|
||||||
|
tracing::trace!(
|
||||||
|
current,
|
||||||
|
depth = route.len(),
|
||||||
|
"pruned state deeper than shortest solution"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if current == to && !route.is_empty() {
|
if current == to && !route.is_empty() {
|
||||||
minimum = Some(route.len());
|
minimum = Some(route.len());
|
||||||
|
tracing::trace!(
|
||||||
|
depth = route.len(),
|
||||||
|
solution_count = solutions.len() + 1,
|
||||||
|
"found shortest conversion route candidate"
|
||||||
|
);
|
||||||
solutions.push(route);
|
solutions.push(route);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(next_steps) = edges.get(¤t) {
|
if let Some(next_steps) = edges.get(¤t) {
|
||||||
|
tracing::trace!(
|
||||||
|
current,
|
||||||
|
next_step_count = next_steps.len(),
|
||||||
|
"expanding conversion graph state"
|
||||||
|
);
|
||||||
for step in next_steps {
|
for step in next_steps {
|
||||||
let next_depth = route.len() + 1;
|
let next_depth = route.len() + 1;
|
||||||
if next_depth
|
let previous_depth = depths
|
||||||
<= depths
|
|
||||||
.get(&step.conversion.to)
|
.get(&step.conversion.to)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(usize::MAX)
|
.unwrap_or(usize::MAX);
|
||||||
{
|
tracing::trace!(
|
||||||
|
plugin = %step.plugin.metadata().id,
|
||||||
|
from = %step.conversion.from,
|
||||||
|
to = %step.conversion.to,
|
||||||
|
next_depth,
|
||||||
|
previous_depth,
|
||||||
|
"considering conversion graph edge"
|
||||||
|
);
|
||||||
|
if next_depth <= previous_depth {
|
||||||
depths.insert(step.conversion.to.clone(), next_depth);
|
depths.insert(step.conversion.to.clone(), next_depth);
|
||||||
let mut next_route = route.clone();
|
let mut next_route = route.clone();
|
||||||
next_route.push(step.clone());
|
next_route.push(step.clone());
|
||||||
queue.push_back((step.conversion.to.clone(), next_route));
|
queue.push_back((step.conversion.to.clone(), next_route));
|
||||||
|
tracing::trace!(next = %step.conversion.to, queue_length = queue.len(), "queued conversion graph state");
|
||||||
|
} else {
|
||||||
|
tracing::trace!(next = %step.conversion.to, "skipped conversion graph state with longer path");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tracing::trace!(current, "conversion graph state has no outgoing edges");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
solutions
|
tracing::debug!(solution_count = solutions.len(), shortest_depth = ?minimum, "conversion graph search completed");
|
||||||
|
let selected = solutions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.max_by_key(|route| route_score(route, priority))
|
.max_by_key(|route| route_score(route, priority));
|
||||||
|
if let Some(route) = &selected {
|
||||||
|
let plugins: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
|
||||||
|
tracing::debug!(?plugins, score = ?route_score(route, priority), "selected highest-scoring shortest route");
|
||||||
|
} else {
|
||||||
|
tracing::debug!("no conversion route selected");
|
||||||
|
}
|
||||||
|
selected
|
||||||
}
|
}
|
||||||
+365
-59
@@ -3,24 +3,37 @@
|
|||||||
// This program is free software: you can redistribute it and/or modify
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
// the Free Software Foundation, version 3 exclusively.
|
// 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::{
|
use convertis_plugin_api::{
|
||||||
ENGINE_VERSION, FACTORY_SYMBOL, MANIFEST_SYMBOL, Plugin, PluginFactory, PluginManifest,
|
AVAILABILITY_SYMBOL_V2, CONVERT_SYMBOL_V2, ConversionRequest, FREE_SYMBOL_V2,
|
||||||
|
LEGACY_FACTORY_SYMBOL_V1, LEGACY_MANIFEST_SYMBOL_V1, MANIFEST_SYMBOL, METADATA_SYMBOL_V2,
|
||||||
|
Plugin, PluginConvertCall, PluginFactory, PluginJsonCall, PluginManifest, PluginMetadata,
|
||||||
|
PluginStringFree, WireResponse,
|
||||||
};
|
};
|
||||||
use libloading::Library;
|
use libloading::Library;
|
||||||
use serde::Deserialize;
|
use semver::{Version, VersionReq};
|
||||||
use std::{collections::HashSet, ffi::CStr, fs, path::PathBuf};
|
use serde::{Deserialize, de::DeserializeOwned};
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
ffi::{CStr, CString, c_char},
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
const SUPPORTED_PROTOCOLS: &[u32] = &[2];
|
||||||
struct AbiManifest {
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StableManifest {
|
||||||
|
manifest_version: u32,
|
||||||
|
protocol_versions: Vec<u32>,
|
||||||
|
engine_requirement: String,
|
||||||
|
plugin_id: String,
|
||||||
|
plugin_version: String,
|
||||||
|
target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LegacyManifestV1 {
|
||||||
api_version: u32,
|
api_version: u32,
|
||||||
engine_version: String,
|
engine_version: String,
|
||||||
plugin_id: String,
|
plugin_id: String,
|
||||||
@@ -28,6 +41,67 @@ struct AbiManifest {
|
|||||||
target: String,
|
target: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ProtocolV2Plugin {
|
||||||
|
metadata: PluginMetadata,
|
||||||
|
availability: PluginJsonCall,
|
||||||
|
convert: PluginConvertCall,
|
||||||
|
free: PluginStringFree,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolV2Plugin {
|
||||||
|
unsafe fn call<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
operation: &str,
|
||||||
|
call: PluginJsonCall,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
tracing::trace!(plugin = %self.metadata.id, operation, "calling plugin protocol operation");
|
||||||
|
let pointer = unsafe { call() };
|
||||||
|
let result = unsafe { decode_response(pointer, self.free, operation) };
|
||||||
|
tracing::trace!(
|
||||||
|
plugin = %self.metadata.id,
|
||||||
|
operation,
|
||||||
|
success = result.is_ok(),
|
||||||
|
"plugin protocol operation completed"
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for ProtocolV2Plugin {
|
||||||
|
fn metadata(&self) -> PluginMetadata {
|
||||||
|
tracing::trace!(plugin = %self.metadata.id, "returning cached plugin metadata");
|
||||||
|
self.metadata.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn availability(&self) -> Result<(), String> {
|
||||||
|
unsafe { self.call("availability", self.availability) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
|
||||||
|
tracing::trace!(
|
||||||
|
plugin = %self.metadata.id,
|
||||||
|
input = %request.input.display(),
|
||||||
|
output = %request.output.display(),
|
||||||
|
from = %request.from,
|
||||||
|
to = %request.to,
|
||||||
|
option_count = request.options.len(),
|
||||||
|
"serializing conversion request"
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(request)
|
||||||
|
.map_err(|error| format!("could not serialize conversion request: {error}"))?;
|
||||||
|
let json =
|
||||||
|
CString::new(json).map_err(|_| "conversion request contained a NUL byte".to_owned())?;
|
||||||
|
let pointer = unsafe { (self.convert)(json.as_ptr()) };
|
||||||
|
let result = unsafe { decode_response(pointer, self.free, "convert") };
|
||||||
|
tracing::trace!(
|
||||||
|
plugin = %self.metadata.id,
|
||||||
|
success = result.is_ok(),
|
||||||
|
"conversion protocol call completed"
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
// Plugins must be dropped before their backing libraries.
|
// Plugins must be dropped before their backing libraries.
|
||||||
pub plugins: Vec<Box<dyn Plugin>>,
|
pub plugins: Vec<Box<dyn Plugin>>,
|
||||||
@@ -37,19 +111,38 @@ pub struct PluginRegistry {
|
|||||||
|
|
||||||
impl PluginRegistry {
|
impl PluginRegistry {
|
||||||
pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self {
|
pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self {
|
||||||
|
tracing::debug!(
|
||||||
|
extra_directory_count = extra_dirs.len(),
|
||||||
|
include_defaults,
|
||||||
|
"starting plugin discovery"
|
||||||
|
);
|
||||||
let mut directories = extra_dirs.to_vec();
|
let mut directories = extra_dirs.to_vec();
|
||||||
|
for directory in extra_dirs {
|
||||||
|
tracing::trace!(directory = %directory.display(), source = "command-line", "queued plugin directory");
|
||||||
|
}
|
||||||
if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") {
|
if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") {
|
||||||
directories.extend(std::env::split_paths(&paths));
|
for directory in std::env::split_paths(&paths) {
|
||||||
|
tracing::trace!(directory = %directory.display(), source = "environment", "queued plugin directory");
|
||||||
|
directories.push(directory);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::trace!("CONVERTIS_PLUGIN_PATH is not set");
|
||||||
}
|
}
|
||||||
if include_defaults {
|
if include_defaults {
|
||||||
if let Ok(executable) = std::env::current_exe()
|
match std::env::current_exe() {
|
||||||
&& let Some(parent) = executable.parent()
|
Ok(executable) => {
|
||||||
{
|
tracing::trace!(executable = %executable.display(), "resolved current executable");
|
||||||
|
if let Some(parent) = executable.parent() {
|
||||||
directories.push(parent.to_path_buf());
|
directories.push(parent.to_path_buf());
|
||||||
directories.push(parent.join("plugins"));
|
directories.push(parent.join("plugins"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(error) => tracing::debug!(%error, "could not resolve current executable"),
|
||||||
|
}
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins"));
|
directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins"));
|
||||||
|
} else {
|
||||||
|
tracing::trace!("HOME is not set; skipping per-user plugin directory");
|
||||||
}
|
}
|
||||||
directories.push(PathBuf::from("/usr/lib/convertis/plugins"));
|
directories.push(PathBuf::from("/usr/lib/convertis/plugins"));
|
||||||
}
|
}
|
||||||
@@ -63,79 +156,168 @@ impl PluginRegistry {
|
|||||||
let mut seen_ids = HashSet::new();
|
let mut seen_ids = HashSet::new();
|
||||||
|
|
||||||
for directory in directories {
|
for directory in directories {
|
||||||
let Ok(directory) = directory.canonicalize() else {
|
tracing::trace!(directory = %directory.display(), "examining plugin directory");
|
||||||
continue;
|
let directory = match directory.canonicalize() {
|
||||||
};
|
Ok(directory) => directory,
|
||||||
if !seen_paths.insert(directory.clone()) {
|
Err(error) => {
|
||||||
|
tracing::trace!(directory = %directory.display(), %error, "plugin directory is unavailable");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(entries) = fs::read_dir(&directory) else {
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
|
if !seen_paths.insert(directory.clone()) {
|
||||||
|
tracing::trace!(directory = %directory.display(), "skipping duplicate plugin directory");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entries = match fs::read_dir(&directory) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!(directory = %directory.display(), %error, "could not read plugin directory");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut paths: Vec<_> = entries
|
||||||
|
.filter_map(|entry| match entry {
|
||||||
|
Ok(entry) => Some(entry.path()),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::trace!(directory = %directory.display(), %error, "could not read directory entry");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
paths.sort();
|
paths.sort();
|
||||||
|
tracing::trace!(directory = %directory.display(), entry_count = paths.len(), "sorted plugin directory entries");
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
|
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
|
||||||
&& path
|
&& path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.is_some_and(|name| name.starts_with("libconvertis_"));
|
.is_some_and(|name| name.starts_with("libconvertis_"));
|
||||||
|
tracing::trace!(path = %path.display(), is_plugin, "classified plugin candidate");
|
||||||
if !is_plugin {
|
if !is_plugin {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
tracing::debug!(path = %path.display(), "loading plugin candidate");
|
||||||
match unsafe { Self::load_one(&path) } {
|
match unsafe { Self::load_one(&path) } {
|
||||||
Ok((library, plugin, id)) => {
|
Ok((library, plugin, id, protocol)) => {
|
||||||
if seen_ids.insert(id.clone()) {
|
if seen_ids.insert(id.clone()) {
|
||||||
|
tracing::info!(plugin = %id, protocol, path = %path.display(), "loaded plugin");
|
||||||
registry.plugins.push(plugin);
|
registry.plugins.push(plugin);
|
||||||
registry.libraries.push(library);
|
registry.libraries.push(library);
|
||||||
} else {
|
} else {
|
||||||
|
tracing::debug!(plugin = %id, path = %path.display(), "ignored duplicate plugin");
|
||||||
registry.diagnostics.push(format!(
|
registry.diagnostics.push(format!(
|
||||||
"ignored duplicate plugin '{id}' from {}",
|
"ignored duplicate plugin '{id}' from {}",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => registry
|
Err(error) => {
|
||||||
|
tracing::debug!(path = %path.display(), %error, "plugin candidate was rejected");
|
||||||
|
registry
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.push(format!("could not load {}: {error}", path.display())),
|
.push(format!("could not load {}: {error}", path.display()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
plugin_count = registry.plugins.len(),
|
||||||
|
diagnostic_count = registry.diagnostics.len(),
|
||||||
|
"plugin discovery completed"
|
||||||
|
);
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn load_one(
|
unsafe fn load_one(path: &Path) -> Result<(Library, Box<dyn Plugin>, String, u32), String> {
|
||||||
path: &std::path::Path,
|
tracing::trace!(path = %path.display(), "opening dynamic library");
|
||||||
) -> Result<(Library, Box<dyn Plugin>, String), String> {
|
|
||||||
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
|
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
|
||||||
let manifest_fn = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) }
|
tracing::trace!(path = %path.display(), "dynamic library opened");
|
||||||
.map_err(|error| format!("missing ABI manifest: {error}"))?;
|
|
||||||
let pointer = unsafe { manifest_fn() };
|
if let Ok(manifest_fn) = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) } {
|
||||||
if pointer.is_null() {
|
tracing::trace!(path = %path.display(), "found stable plugin manifest entry point");
|
||||||
return Err("ABI manifest was null".to_owned());
|
let manifest: StableManifest = unsafe { read_manifest(*manifest_fn) }?;
|
||||||
|
let protocol = validate_stable_manifest(&manifest, env!("CARGO_PKG_VERSION"))?;
|
||||||
|
tracing::debug!(
|
||||||
|
plugin = %manifest.plugin_id,
|
||||||
|
plugin_version = %manifest.plugin_version,
|
||||||
|
protocol,
|
||||||
|
engine_requirement = %manifest.engine_requirement,
|
||||||
|
"negotiated plugin protocol"
|
||||||
|
);
|
||||||
|
let metadata_call = *unsafe { library.get::<PluginJsonCall>(METADATA_SYMBOL_V2) }
|
||||||
|
.map_err(|error| format!("protocol v2 metadata entry point is missing: {error}"))?;
|
||||||
|
let availability = *unsafe { library.get::<PluginJsonCall>(AVAILABILITY_SYMBOL_V2) }
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("protocol v2 availability entry point is missing: {error}")
|
||||||
|
})?;
|
||||||
|
let convert = *unsafe { library.get::<PluginConvertCall>(CONVERT_SYMBOL_V2) }.map_err(
|
||||||
|
|error| format!("protocol v2 conversion entry point is missing: {error}"),
|
||||||
|
)?;
|
||||||
|
let free =
|
||||||
|
*unsafe { library.get::<PluginStringFree>(FREE_SYMBOL_V2) }.map_err(|error| {
|
||||||
|
format!("protocol v2 string-free entry point is missing: {error}")
|
||||||
|
})?;
|
||||||
|
tracing::trace!(plugin = %manifest.plugin_id, "resolved all protocol v2 entry points");
|
||||||
|
let metadata: PluginMetadata = unsafe {
|
||||||
|
let pointer = metadata_call();
|
||||||
|
decode_response(pointer, free, "metadata")
|
||||||
|
}?;
|
||||||
|
if metadata.id != manifest.plugin_id {
|
||||||
|
return Err("manifest and plugin metadata IDs differ".to_owned());
|
||||||
}
|
}
|
||||||
let json = unsafe { CStr::from_ptr(pointer) }
|
let id = manifest.plugin_id;
|
||||||
.to_str()
|
let plugin = ProtocolV2Plugin {
|
||||||
.map_err(|error| format!("invalid ABI manifest string: {error}"))?;
|
metadata,
|
||||||
let manifest: AbiManifest =
|
availability,
|
||||||
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))?;
|
convert,
|
||||||
if manifest.api_version != convertis_plugin_api::API_VERSION {
|
free,
|
||||||
|
};
|
||||||
|
return Ok((library, Box::new(plugin), id, protocol));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::trace!(path = %path.display(), "stable manifest absent; trying legacy protocol v1 adapter");
|
||||||
|
let manifest_fn = unsafe { library.get::<PluginManifest>(LEGACY_MANIFEST_SYMBOL_V1) }
|
||||||
|
.map_err(|error| format!("missing stable or legacy ABI manifest: {error}"))?;
|
||||||
|
let manifest: LegacyManifestV1 = unsafe { read_manifest(*manifest_fn) }?;
|
||||||
|
validate_legacy_manifest(&manifest)?;
|
||||||
|
let factory = unsafe { library.get::<PluginFactory>(LEGACY_FACTORY_SYMBOL_V1) }
|
||||||
|
.map_err(|error| format!("missing legacy Rust plugin factory: {error}"))?;
|
||||||
|
let plugin = unsafe { factory() };
|
||||||
|
if plugin.metadata().id != manifest.plugin_id {
|
||||||
|
return Err("legacy manifest and plugin IDs differ".to_owned());
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
plugin = %manifest.plugin_id,
|
||||||
|
built_for_engine = %manifest.engine_version,
|
||||||
|
"loaded plugin through legacy protocol v1 adapter"
|
||||||
|
);
|
||||||
|
Ok((library, plugin, manifest.plugin_id, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_stable_manifest(
|
||||||
|
manifest: &StableManifest,
|
||||||
|
engine_version: &str,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
tracing::trace!(
|
||||||
|
?manifest,
|
||||||
|
engine_version,
|
||||||
|
"validating stable plugin manifest"
|
||||||
|
);
|
||||||
|
if manifest.manifest_version != 1 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"plugin API {} is not supported",
|
"manifest format {} is not supported",
|
||||||
manifest.api_version
|
manifest.manifest_version
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if manifest.engine_version != ENGINE_VERSION {
|
let engine = Version::parse(engine_version)
|
||||||
|
.map_err(|error| format!("engine has an invalid version: {error}"))?;
|
||||||
|
let requirement = VersionReq::parse(&manifest.engine_requirement)
|
||||||
|
.map_err(|error| format!("plugin has an invalid engine requirement: {error}"))?;
|
||||||
|
if !requirement.matches(&engine) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"plugin targets engine {}, but this engine is {}",
|
"plugin requires engine {}, but this engine is {}",
|
||||||
manifest.engine_version, ENGINE_VERSION
|
manifest.engine_requirement, 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") {
|
if manifest.target != env!("CONVERTIS_TARGET") {
|
||||||
@@ -145,12 +327,136 @@ impl PluginRegistry {
|
|||||||
env!("CONVERTIS_TARGET")
|
env!("CONVERTIS_TARGET")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let factory = unsafe { library.get::<PluginFactory>(FACTORY_SYMBOL) }
|
SUPPORTED_PROTOCOLS
|
||||||
.map_err(|error| format!("missing Rust plugin factory: {error}"))?;
|
.iter()
|
||||||
let plugin = unsafe { factory() };
|
.rev()
|
||||||
if plugin.metadata().id != manifest.plugin_id {
|
.find(|version| manifest.protocol_versions.contains(version))
|
||||||
return Err("manifest and plugin IDs differ".to_owned());
|
.copied()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"plugin protocols {:?} are not supported; engine supports {:?}",
|
||||||
|
manifest.protocol_versions, SUPPORTED_PROTOCOLS
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_legacy_manifest(manifest: &LegacyManifestV1) -> Result<(), String> {
|
||||||
|
tracing::trace!(?manifest, "validating legacy plugin manifest");
|
||||||
|
if manifest.api_version != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"legacy plugin API {} is not supported",
|
||||||
|
manifest.api_version
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok((library, plugin, manifest.plugin_id))
|
if manifest.engine_version != convertis_plugin_api::ENGINE_VERSION {
|
||||||
|
tracing::debug!(
|
||||||
|
plugin = %manifest.plugin_id,
|
||||||
|
built_for_engine = %manifest.engine_version,
|
||||||
|
current_engine = convertis_plugin_api::ENGINE_VERSION,
|
||||||
|
"legacy engine release differs; continuing because protocol v1 is retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if manifest.rustc_version != env!("CONVERTIS_RUSTC_VERSION") {
|
||||||
|
return Err(format!(
|
||||||
|
"legacy plugin was built with {}, but the engine uses {}; protocol v1 uses the unstable Rust ABI",
|
||||||
|
manifest.rustc_version,
|
||||||
|
env!("CONVERTIS_RUSTC_VERSION")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if manifest.target != env!("CONVERTIS_TARGET") {
|
||||||
|
return Err(format!(
|
||||||
|
"legacy plugin targets {}, but the engine targets {}",
|
||||||
|
manifest.target,
|
||||||
|
env!("CONVERTIS_TARGET")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn read_manifest<T: DeserializeOwned>(manifest: PluginManifest) -> Result<T, String> {
|
||||||
|
tracing::trace!("calling plugin manifest entry point");
|
||||||
|
let pointer = unsafe { manifest() };
|
||||||
|
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}"))?;
|
||||||
|
tracing::trace!(
|
||||||
|
manifest_bytes = json.len(),
|
||||||
|
manifest = json,
|
||||||
|
"received plugin manifest"
|
||||||
|
);
|
||||||
|
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn decode_response<T: DeserializeOwned>(
|
||||||
|
pointer: *mut c_char,
|
||||||
|
free: PluginStringFree,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
if pointer.is_null() {
|
||||||
|
return Err(format!("plugin returned null from {operation}"));
|
||||||
|
}
|
||||||
|
let json_result = unsafe { CStr::from_ptr(pointer) }
|
||||||
|
.to_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map_err(|error| format!("plugin returned invalid UTF-8 from {operation}: {error}"));
|
||||||
|
unsafe { free(pointer) };
|
||||||
|
let json = json_result?;
|
||||||
|
tracing::trace!(
|
||||||
|
operation,
|
||||||
|
response_bytes = json.len(),
|
||||||
|
"received plugin protocol response"
|
||||||
|
);
|
||||||
|
let response: WireResponse<T> = serde_json::from_str(&json)
|
||||||
|
.map_err(|error| format!("plugin returned invalid JSON from {operation}: {error}"))?;
|
||||||
|
match (response.ok, response.result, response.error) {
|
||||||
|
(true, Some(result), None) => Ok(result),
|
||||||
|
(true, None, None) => serde_json::from_value(serde_json::Value::Null).map_err(|error| {
|
||||||
|
format!("plugin returned no result value from successful {operation}: {error}")
|
||||||
|
}),
|
||||||
|
(false, _, Some(error)) => Err(error),
|
||||||
|
(true, _, Some(error)) => Err(format!(
|
||||||
|
"plugin returned both success and an error from {operation}: {error}"
|
||||||
|
)),
|
||||||
|
(false, _, None) => Err(format!(
|
||||||
|
"plugin returned failure without an error from {operation}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn manifest(requirement: &str, protocols: &[u32]) -> StableManifest {
|
||||||
|
StableManifest {
|
||||||
|
manifest_version: 1,
|
||||||
|
protocol_versions: protocols.to_vec(),
|
||||||
|
engine_requirement: requirement.into(),
|
||||||
|
plugin_id: "example".into(),
|
||||||
|
plugin_version: "0.3.0".into(),
|
||||||
|
target: env!("CONVERTIS_TARGET").into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_plugin_requirement_accepts_future_engines() {
|
||||||
|
let manifest = manifest(">=0.3.0", &[2]);
|
||||||
|
assert_eq!(validate_stable_manifest(&manifest, "0.3.0"), Ok(2));
|
||||||
|
assert_eq!(validate_stable_manifest(&manifest, "99.0.0"), Ok(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_is_negotiated_independently_of_release_version() {
|
||||||
|
let manifest = manifest(">=0.1.0", &[1, 2, 3]);
|
||||||
|
assert_eq!(validate_stable_manifest(&manifest, "1.0.0"), Ok(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_protocol_is_rejected() {
|
||||||
|
let error = validate_stable_manifest(&manifest(">=0.3.0", &[7]), "0.3.0").unwrap_err();
|
||||||
|
assert!(error.contains("not supported"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+58
-6
@@ -32,35 +32,61 @@ pub fn run_conversion(
|
|||||||
temp_root: &Path,
|
temp_root: &Path,
|
||||||
options: &BTreeMap<String, String>,
|
options: &BTreeMap<String, String>,
|
||||||
) -> Result<ConversionResult, (String, String)> {
|
) -> Result<ConversionResult, (String, String)> {
|
||||||
|
tracing::debug!(
|
||||||
|
route_steps = route.len(),
|
||||||
|
input = %input.display(),
|
||||||
|
temp_root = %temp_root.display(),
|
||||||
|
option_count = options.len(),
|
||||||
|
"preparing conversion workspace"
|
||||||
|
);
|
||||||
|
tracing::trace!(directory = %temp_root.display(), "ensuring temporary root exists");
|
||||||
fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||||
let workspace = tempfile::Builder::new()
|
let workspace = tempfile::Builder::new()
|
||||||
.prefix("convertis-")
|
.prefix("convertis-")
|
||||||
.tempdir_in(temp_root)
|
.tempdir_in(temp_root)
|
||||||
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||||
|
tracing::debug!(workspace = %workspace.path().display(), "created conversion workspace");
|
||||||
let mut current = input.to_path_buf();
|
let mut current = input.to_path_buf();
|
||||||
let mut kind = if input.is_dir() {
|
let mut kind = if input.is_dir() {
|
||||||
ArtifactKind::Directory
|
ArtifactKind::Directory
|
||||||
} else {
|
} else {
|
||||||
ArtifactKind::File
|
ArtifactKind::File
|
||||||
};
|
};
|
||||||
|
tracing::trace!(current = %current.display(), ?kind, "initialized current artifact");
|
||||||
|
|
||||||
for (index, step) in route.iter().enumerate() {
|
for (index, step) in route.iter().enumerate() {
|
||||||
let metadata = step.plugin.metadata();
|
let metadata = step.plugin.metadata();
|
||||||
|
tracing::debug!(
|
||||||
|
step = index + 1,
|
||||||
|
total_steps = route.len(),
|
||||||
|
plugin = %metadata.id,
|
||||||
|
from = %step.conversion.from,
|
||||||
|
to = %step.conversion.to,
|
||||||
|
input_kind = ?step.conversion.input_kind,
|
||||||
|
output_kind = ?step.conversion.output_kind,
|
||||||
|
"preparing conversion step"
|
||||||
|
);
|
||||||
let mut plugin_options = BTreeMap::new();
|
let mut plugin_options = BTreeMap::new();
|
||||||
for option in &metadata.options {
|
for option in &metadata.options {
|
||||||
if let Some(default) = &option.default {
|
if let Some(default) = &option.default {
|
||||||
|
tracing::trace!(plugin = %metadata.id, option = %option.name, value_length = default.len(), "applying default plugin option");
|
||||||
plugin_options.insert(option.name.clone(), default.clone());
|
plugin_options.insert(option.name.clone(), default.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (key, value) in options {
|
for (key, value) in options {
|
||||||
if let Some((plugin_id, option)) = key.split_once('.') {
|
if let Some((plugin_id, option)) = key.split_once('.') {
|
||||||
if plugin_id == metadata.id {
|
if plugin_id == metadata.id {
|
||||||
|
tracing::trace!(plugin = %metadata.id, key, option, value_length = value.len(), "applying qualified plugin option");
|
||||||
plugin_options.insert(option.to_owned(), value.clone());
|
plugin_options.insert(option.to_owned(), value.clone());
|
||||||
|
} else {
|
||||||
|
tracing::trace!(plugin = %metadata.id, key, "qualified option belongs to another route step");
|
||||||
}
|
}
|
||||||
} else if metadata.options.iter().any(|option| option.name == *key) {
|
} else if metadata.options.iter().any(|option| option.name == *key) {
|
||||||
|
tracing::trace!(plugin = %metadata.id, key, value_length = value.len(), "applying unqualified plugin option");
|
||||||
plugin_options.insert(key.clone(), value.clone());
|
plugin_options.insert(key.clone(), value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::trace!(plugin = %metadata.id, options = ?plugin_options.keys().collect::<Vec<_>>(), "resolved conversion-step options");
|
||||||
let output = match step.conversion.output_kind {
|
let output = match step.conversion.output_kind {
|
||||||
ArtifactKind::File => workspace.path().join(format!(
|
ArtifactKind::File => workspace.path().join(format!(
|
||||||
"step-{index}.{}",
|
"step-{index}.{}",
|
||||||
@@ -68,7 +94,9 @@ pub fn run_conversion(
|
|||||||
)),
|
)),
|
||||||
ArtifactKind::Directory => workspace.path().join(format!("step-{index}")),
|
ArtifactKind::Directory => workspace.path().join(format!("step-{index}")),
|
||||||
};
|
};
|
||||||
|
tracing::trace!(plugin = %metadata.id, output = %output.display(), "allocated conversion-step output path");
|
||||||
if step.conversion.output_kind == ArtifactKind::Directory {
|
if step.conversion.output_kind == ArtifactKind::Directory {
|
||||||
|
tracing::trace!(output = %output.display(), "creating directory artifact output");
|
||||||
fs::create_dir_all(&output)
|
fs::create_dir_all(&output)
|
||||||
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||||
}
|
}
|
||||||
@@ -79,12 +107,22 @@ pub fn run_conversion(
|
|||||||
to: step.conversion.to.clone(),
|
to: step.conversion.to.clone(),
|
||||||
options: plugin_options,
|
options: plugin_options,
|
||||||
};
|
};
|
||||||
step.plugin
|
tracing::trace!(
|
||||||
.convert(&request)
|
plugin = %metadata.id,
|
||||||
.map_err(|error| (error, step.plugin.metadata().id))?;
|
input = %request.input.display(),
|
||||||
|
output = %request.output.display(),
|
||||||
|
"dispatching conversion request to plugin"
|
||||||
|
);
|
||||||
|
step.plugin.convert(&request).map_err(|error| {
|
||||||
|
tracing::debug!(plugin = %metadata.id, %error, "conversion step failed");
|
||||||
|
(error, metadata.id.clone())
|
||||||
|
})?;
|
||||||
|
tracing::trace!(plugin = %metadata.id, output_exists = output.exists(), "plugin returned conversion success");
|
||||||
current = output;
|
current = output;
|
||||||
kind = step.conversion.output_kind;
|
kind = step.conversion.output_kind;
|
||||||
|
tracing::debug!(step = index + 1, plugin = %metadata.id, artifact = %current.display(), ?kind, "conversion step completed");
|
||||||
}
|
}
|
||||||
|
tracing::debug!(result = %current.display(), ?kind, "all conversion steps completed");
|
||||||
Ok(ConversionResult {
|
Ok(ConversionResult {
|
||||||
path: current,
|
path: current,
|
||||||
kind,
|
kind,
|
||||||
@@ -93,45 +131,59 @@ pub fn run_conversion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn file_extension(format: &str) -> &str {
|
fn file_extension(format: &str) -> &str {
|
||||||
match format {
|
let extension = match format {
|
||||||
"animated-gif" => "gif",
|
"animated-gif" => "gif",
|
||||||
"animated-webp" => "webp",
|
"animated-webp" => "webp",
|
||||||
"text" => "txt",
|
"text" => "txt",
|
||||||
other => other,
|
other => other,
|
||||||
}
|
};
|
||||||
|
tracing::trace!(format, extension, "mapped format to workspace extension");
|
||||||
|
extension
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
|
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
|
||||||
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
|
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
tracing::debug!(source = %result.path.display(), destination = %destination.display(), ?result.kind, "installing result atomically");
|
||||||
|
tracing::trace!(parent = %parent.display(), "ensuring destination parent exists");
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
match result.kind {
|
match result.kind {
|
||||||
ArtifactKind::File => {
|
ArtifactKind::File => {
|
||||||
|
tracing::trace!(parent = %parent.display(), "creating file staging area");
|
||||||
let staging = tempfile::NamedTempFile::new_in(parent)?;
|
let staging = tempfile::NamedTempFile::new_in(parent)?;
|
||||||
|
tracing::trace!(staging = %staging.path().display(), "copying file result into staging area");
|
||||||
fs::copy(&result.path, staging.path())?;
|
fs::copy(&result.path, staging.path())?;
|
||||||
|
tracing::trace!(destination = %destination.display(), "persisting staged file result");
|
||||||
staging.persist(destination).map_err(|error| error.error)?;
|
staging.persist(destination).map_err(|error| error.error)?;
|
||||||
}
|
}
|
||||||
ArtifactKind::Directory => {
|
ArtifactKind::Directory => {
|
||||||
|
tracing::trace!(parent = %parent.display(), "creating directory staging area");
|
||||||
let staging = tempfile::Builder::new()
|
let staging = tempfile::Builder::new()
|
||||||
.prefix(".convertis-output-")
|
.prefix(".convertis-output-")
|
||||||
.tempdir_in(parent)?;
|
.tempdir_in(parent)?;
|
||||||
let payload = staging.path().join("payload");
|
let payload = staging.path().join("payload");
|
||||||
copy_directory(&result.path, &payload)?;
|
copy_directory(&result.path, &payload)?;
|
||||||
|
tracing::trace!(payload = %payload.display(), destination = %destination.display(), "renaming staged directory result");
|
||||||
fs::rename(payload, destination)?;
|
fs::rename(payload, destination)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::debug!(destination = %destination.display(), "result installation completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
|
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
|
||||||
|
tracing::trace!(source = %source.display(), destination = %destination.display(), "copying directory");
|
||||||
fs::create_dir_all(destination)?;
|
fs::create_dir_all(destination)?;
|
||||||
for entry in fs::read_dir(source)? {
|
for entry in fs::read_dir(source)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let target = destination.join(entry.file_name());
|
let target = destination.join(entry.file_name());
|
||||||
if entry.file_type()?.is_dir() {
|
let file_type = entry.file_type()?;
|
||||||
|
tracing::trace!(source = %entry.path().display(), target = %target.display(), is_directory = file_type.is_dir(), "copying directory entry");
|
||||||
|
if file_type.is_dir() {
|
||||||
copy_directory(&entry.path(), &target)?;
|
copy_directory(&entry.path(), &target)?;
|
||||||
} else {
|
} else {
|
||||||
fs::copy(entry.path(), target)?;
|
fs::copy(entry.path(), target)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::trace!(source = %source.display(), destination = %destination.display(), "directory copy completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user