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

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

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-ffmpeg-audio"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+83
View File
@@ -0,0 +1,83 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"mp3", "ogg", "aac", "m4a", "opus", "wma", "wav", "flac", "aiff", "au",
];
struct FfmpegAudio;
fn available(binary: &str) -> Result<(), String> {
Command::new(binary)
.arg("-version")
.output()
.map_err(|_| format!("'{binary}' is required"))?
.status
.success()
.then_some(())
.ok_or_else(|| format!("'{binary}' is unavailable"))
}
impl Plugin for FfmpegAudio {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-audio".into(),
package: "convertis-ffmpeg-audio".into(),
description: "FFmpeg audio conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (240, 220, 190)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
available("ffmpeg")
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let codec: &[&str] = match request.to.as_str() {
"mp3" => &["-c:a", "libmp3lame", "-q:a", "2"],
"ogg" => &["-c:a", "libvorbis", "-q:a", "4"],
"aac" | "m4a" => &["-c:a", "aac", "-b:a", "192k"],
"opus" => &["-c:a", "libopus", "-b:a", "128k"],
"flac" => &["-c:a", "flac"],
"wav" | "aiff" | "au" => &["-c:a", "pcm_s16le"],
"wma" => &["-c:a", "wmav2", "-b:a", "192k"],
_ => &[],
};
let output = Command::new("ffmpeg")
.arg("-v")
.arg("error")
.arg("-y")
.arg("-i")
.arg(&request.input)
.args(codec)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FfmpegAudio, "ffmpeg-audio");
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "convertis-ffmpeg-frames-to-video"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
serde.workspace = true
serde_json.workspace = true
+173
View File
@@ -0,0 +1,173 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{
ArtifactKind, Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata,
};
use serde::Deserialize;
use std::{fs, path::PathBuf, process::Command};
const FORMATS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"animated-webp",
];
struct FramesToVideo;
#[derive(Deserialize)]
struct FramesManifest {
pattern: String,
frame_rate: String,
#[serde(default)]
timestamps_seconds: Vec<f64>,
}
fn frame_files(directory: &std::path::Path) -> Result<Vec<PathBuf>, String> {
let mut files: Vec<_> = fs::read_dir(directory)
.map_err(|error| error.to_string())?
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| matches!(value, "png" | "jpg" | "jpeg" | "webp" | "bmp"))
})
.collect();
files.sort();
if files.is_empty() {
Err("frames directory contains no supported images".into())
} else {
Ok(files)
}
}
impl Plugin for FramesToVideo {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-frames-to-video".into(),
package: "convertis-ffmpeg-frames-to-video".into(),
description: "Build video from frames".into(),
conversions: FORMATS
.iter()
.map(|to| {
let mut c = Conversion::file("frames", to, (255, 220, 180));
c.input_kind = ArtifactKind::Directory;
c
})
.collect(),
options: vec![OptionSpec {
name: "fps".into(),
help: "Override input FPS; folders without metadata default to 30".into(),
default: None,
}],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|_| "'ffmpeg' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let manifest = fs::read(request.input.join(".convertis-frames.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<FramesManifest>(&bytes).ok());
let rate = request
.options
.get("fps")
.cloned()
.or_else(|| manifest.as_ref().map(|value| value.frame_rate.clone()))
.unwrap_or_else(|| "30".into());
let mut command = Command::new("ffmpeg");
command.args(["-v", "error", "-y"]);
let files = frame_files(&request.input)?;
let mut concat_file = None;
if let Some(manifest) = &manifest
&& !request.options.contains_key("fps")
&& manifest.timestamps_seconds.len() == files.len()
&& files.len() > 1
{
let path = request.output.with_extension("frames.txt");
let mut contents = String::new();
for (index, file) in files.iter().enumerate() {
let escaped = file.to_string_lossy().replace('\'', "'\\''");
contents.push_str(&format!("file '{escaped}'\n"));
if let Some(next) = manifest.timestamps_seconds.get(index + 1) {
contents.push_str(&format!(
"duration {}\n",
(next - manifest.timestamps_seconds[index]).max(0.001)
));
}
}
let last = files
.last()
.unwrap()
.to_string_lossy()
.replace('\'', "'\\''");
contents.push_str(&format!("file '{last}'\n"));
fs::write(&path, contents).map_err(|error| error.to_string())?;
command
.args(["-f", "concat", "-safe", "0", "-i"])
.arg(&path);
concat_file = Some(path);
} else if let Some(manifest) = &manifest {
command
.args(["-framerate", &rate, "-i"])
.arg(request.input.join(&manifest.pattern));
} else {
let extension = files[0]
.extension()
.and_then(|value| value.to_str())
.unwrap();
command.args(["-framerate", &rate]);
command
.args(["-pattern_type", "glob", "-i"])
.arg(request.input.join(format!("*.{extension}")));
}
match request.to.as_str() {
"webm" => {
command.args(["-c:v", "libvpx-vp9", "-pix_fmt", "yuv420p"]);
}
"gif" | "animated-gif" => {
command.args(["-vf", "scale=640:-1:flags=lanczos"]);
}
"webp" | "animated-webp" => {
command.args(["-c:v", "libwebp", "-loop", "0"]);
}
_ => {
command.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]);
}
}
let output = command
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if let Some(path) = concat_file {
let _ = fs::remove_file(path);
}
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FramesToVideo, "ffmpeg-frames-to-video");
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "convertis-ffmpeg-video-to-frames"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
serde.workspace = true
serde_json.workspace = true
+191
View File
@@ -0,0 +1,191 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{
ArtifactKind, Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata,
};
use serde::Serialize;
use std::{fs, process::Command};
const FORMATS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"gif",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"webp",
"animated-webp",
];
struct VideoToFrames;
#[derive(Serialize)]
struct FramesManifest {
schema_version: u32,
pattern: String,
frame_format: String,
frame_count: usize,
frame_rate: String,
timestamps_seconds: Vec<f64>,
}
fn rate_value(rate: &str) -> f64 {
if let Some((numerator, denominator)) = rate.split_once('/') {
let numerator = numerator.parse::<f64>().unwrap_or(30.0);
let denominator = denominator.parse::<f64>().unwrap_or(1.0);
if denominator != 0.0 {
return numerator / denominator;
}
}
rate.parse().unwrap_or(30.0)
}
impl Plugin for VideoToFrames {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-video-to-frames".into(),
package: "convertis-ffmpeg-video-to-frames".into(),
description: "Extract video frames".into(),
conversions: FORMATS
.iter()
.map(|from| {
let mut c = Conversion::file(from, "frames", (255, 230, 190));
c.output_kind = ArtifactKind::Directory;
c
})
.collect(),
options: vec![
OptionSpec {
name: "frame_format".into(),
help: "png, jpeg, or webp".into(),
default: Some("png".into()),
},
OptionSpec {
name: "fps".into(),
help: "Optional extraction FPS".into(),
default: None,
},
],
}
}
fn availability(&self) -> Result<(), String> {
for binary in ["ffmpeg", "ffprobe"] {
Command::new(binary)
.arg("-version")
.output()
.map_err(|_| format!("'{binary}' is required"))?;
}
Ok(())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
fs::create_dir_all(&request.output).map_err(|error| error.to_string())?;
let format = request
.options
.get("frame_format")
.map(String::as_str)
.unwrap_or("png");
if !matches!(format, "png" | "jpeg" | "webp") {
return Err("frame_format must be png, jpeg, or webp".into());
}
let detected_rate = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=avg_frame_rate",
"-of",
"default=nw=1:nk=1",
])
.arg(&request.input)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "30/1".into());
let detected_timestamps: Vec<f64> = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"csv=p=0",
])
.arg(&request.input)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
})
.unwrap_or_default();
let rate = request.options.get("fps").cloned().unwrap_or(detected_rate);
let pattern = format!("frame_%08d.{format}");
let mut command = Command::new("ffmpeg");
command
.args(["-v", "error", "-y", "-i"])
.arg(&request.input);
if let Some(fps) = request.options.get("fps") {
command.args(["-vf", &format!("fps={fps}")]);
}
let output = command
.arg(request.output.join(&pattern))
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
let frame_count = fs::read_dir(&request.output)
.map_err(|error| error.to_string())?
.flatten()
.filter(|entry| entry.path().extension().is_some())
.count();
let fps = rate_value(&rate).max(0.001);
let timestamps_seconds =
if request.options.contains_key("fps") || detected_timestamps.len() != frame_count {
(0..frame_count).map(|index| index as f64 / fps).collect()
} else {
detected_timestamps
};
let manifest = FramesManifest {
schema_version: 1,
pattern,
frame_format: format.into(),
frame_count,
frame_rate: rate,
timestamps_seconds,
};
fs::write(
request.output.join(".convertis-frames.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(VideoToFrames, "ffmpeg-video-to-frames");
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-ffmpeg-video"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+122
View File
@@ -0,0 +1,122 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const INPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"gif",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"webp",
"animated-webp",
];
const OUTPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"animated-webp",
];
struct FfmpegVideo;
impl Plugin for FfmpegVideo {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-video".into(),
package: "convertis-ffmpeg-video".into(),
description: "FFmpeg video conversion".into(),
conversions: INPUTS
.iter()
.flat_map(|from| {
OUTPUTS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (240, 210, 180)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|_| "'ffmpeg' is required".to_owned())
.and_then(|output| {
output
.status
.success()
.then_some(())
.ok_or_else(|| "'ffmpeg' is unavailable".to_owned())
})
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let codec: &[&str] = match request.to.as_str() {
"mp4" | "mkv" | "mov" | "m4v" => &[
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p",
],
"webm" => &[
"-c:v",
"libvpx-vp9",
"-crf",
"30",
"-b:v",
"0",
"-c:a",
"libopus",
],
"avi" => &["-c:v", "mpeg4", "-qscale:v", "3", "-c:a", "libmp3lame"],
"gif" | "animated-gif" => &["-vf", "fps=15,scale=640:-1:flags=lanczos"],
"webp" | "animated-webp" => &["-c:v", "libwebp", "-q:v", "75", "-loop", "0"],
"apng" => &["-plays", "0"],
"mpeg" => &["-c:v", "mpeg2video", "-c:a", "mp2"],
_ => &[],
};
let output = Command::new("ffmpeg")
.arg("-v")
.arg("error")
.arg("-y")
.arg("-i")
.arg(&request.input)
.args(codec)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FfmpegVideo, "ffmpeg-video");
-179
View File
@@ -1,179 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"ffmpeg_audio"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of ffmpeg for ffmpeg_audio plugin");
let available = std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok();
tracing::debug!("ffmpeg_audio plugin available: {}", available);
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"mp3" | "wav" => 255,
"m4a" | "aac" | "flac" => 240,
"ogg" | "opus" | "alac" => 220,
"ac3" | "eac3" | "dts" => 180,
"wma" | "amr" | "mka" | "mp2" => 150,
"aiff" | "au" | "caf" | "w64" => 120,
"ape" | "wv" | "tta" | "truehd" | "thd" => 100,
"spx" | "gsm" | "voc" | "ra" => 80,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"flac" | "wav" | "aiff" | "au" | "alac" | "ape" | "wv" | "tta" | "caf" | "w64"
| "truehd" | "thd" => 255,
"opus" | "ogg" | "aac" | "m4a" | "eac3" | "dts" => 220,
"mp3" | "ac3" | "mka" | "mp2" | "ra" => 200,
"wma" | "amr" | "spx" | "gsm" | "voc" => 150,
_ => 128,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
"wav" | "aiff" | "au" | "caf" | "w64" | "voc" => 255,
"flac" | "alac" | "wv" | "mp2" | "ac3" | "dts" => 200,
"mp3" | "aac" | "m4a" | "ogg" | "mka" | "eac3" => 180,
"opus" | "wma" | "amr" | "ape" | "tta" | "truehd" | "thd" | "spx" | "gsm" | "ra" => 150,
_ => 128,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("ffmpeg_audio starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!("Temp files created. In: {:?}, Out: {:?}", in_path, temp_out_path);
let mut raw_args = vec![];
match to {
"mp3" => raw_args.extend(vec!["-c:a", "libmp3lame", "-q:a", "2"]),
"ogg" => raw_args.extend(vec!["-c:a", "libvorbis", "-q:a", "4"]),
"aac" | "m4a" | "mka" => raw_args.extend(vec!["-c:a", "aac", "-b:a", "192k"]),
"opus" => raw_args.extend(vec!["-c:a", "libopus", "-b:a", "128k"]),
"mp2" => raw_args.extend(vec!["-c:a", "mp2", "-b:a", "192k"]),
"wma" => raw_args.extend(vec!["-c:a", "wmav2", "-b:a", "192k"]),
"flac" => raw_args.extend(vec!["-c:a", "flac"]),
"alac" => raw_args.extend(vec!["-c:a", "alac"]),
"ape" => raw_args.extend(vec!["-c:a", "ape"]),
"wv" => raw_args.extend(vec!["-c:a", "wavpack"]),
"tta" => raw_args.extend(vec!["-c:a", "tta"]),
"ac3" => raw_args.extend(vec!["-c:a", "ac3", "-b:a", "384k"]),
"eac3" => raw_args.extend(vec!["-c:a", "eac3", "-b:a", "640k"]),
"dts" => raw_args.extend(vec!["-c:a", "dca", "-strict", "-2", "-b:a", "1536k"]),
"truehd" | "thd" => raw_args.extend(vec!["-c:a", "truehd", "-strict", "-2"]),
"wav" | "aiff" | "au" | "caf" | "w64" => raw_args.extend(vec!["-c:a", "pcm_s16le"]),
"amr" => raw_args.extend(vec![
"-ar",
"8000",
"-c:a",
"libopencore_amrnb",
"-b:a",
"12.2k",
]),
"spx" => raw_args.extend(vec!["-c:a", "libspeex"]),
"gsm" => raw_args.extend(vec!["-ar", "8000", "-c:a", "libgsm"]),
_ => raw_args.extend(vec!["-c:a", "copy"]),
}
tracing::debug!("Built ffmpeg arguments: {:?}", raw_args);
tracing::trace!("Executing ffmpeg command...");
let output = std::process::Command::new("ffmpeg")
.arg("-y") // Overwrite output files without asking
.arg("-i")
.arg(&in_path)
.args(&raw_args)
.arg(&temp_out_path)
.output()
.map_err(|e| {
tracing::error!("Failed to execute ffmpeg: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
tracing::error!("ffmpeg execution failed: {}", err);
return Err(format!("ffmpeg failed: {}", err));
}
tracing::debug!("FFmpeg execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
-271
View File
@@ -1,271 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"ffmpeg_video"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of ffmpeg for ffmpeg_video plugin");
let available = std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok();
tracing::debug!("ffmpeg_video plugin available: {}", available);
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"mp4" | "gif" => 255,
"mov" | "m4v" => 240,
"webm" => 220,
"mkv" => 180,
"wmv" | "mpg" | "mpeg" => 160,
"flv" | "avi" | "3gp" | "vob" => 120,
"ts" | "m2ts" | "mxf" | "ogv" | "apng" | "webp" => 100,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"mkv" | "mxf" | "ts" | "m2ts" => 250,
"mov" | "mp4" | "m4v" => 240,
"webm" => 210,
"wmv" | "mpg" | "mpeg" | "vob" => 200,
"flv" | "avi" | "ogv" | "3gp" => 150,
"gif" | "apng" => 100,
"webp" => 50,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
"mp4" | "mov" | "m4v" => 220,
"mkv" | "mpg" | "mpeg" | "ts" | "m2ts" | "mxf" | "vob" => 200,
"flv" | "avi" | "3gp" | "ogv" => 180,
"wmv" => 170,
"gif" | "apng" => 150,
"webp" => 100,
"webm" => 50,
_ => 150,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("ffmpeg_video starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let mut raw_args = vec![];
match to {
"mp4" | "mkv" | "mov" | "m4v" => {
raw_args.extend(vec![
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p",
]);
}
"webm" => {
raw_args.extend(vec![
"-c:v",
"libvpx-vp9",
"-crf",
"30",
"-b:v",
"0",
"-c:a",
"libopus",
]);
}
"avi" => {
raw_args.extend(vec![
"-c:v",
"mpeg4",
"-vtag",
"xvid",
"-qscale:v",
"3",
"-c:a",
"libmp3lame",
]);
}
"wmv" => {
raw_args.extend(vec![
"-c:v", "wmv2", "-b:v", "1024k", "-c:a", "wmav2", "-b:a", "128k",
]);
}
"flv" => {
raw_args.extend(vec![
"-c:v",
"flv1",
"-b:v",
"800k",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
]);
}
"ts" | "m2ts" | "mpg" | "mpeg" | "vob" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"mp2",
"-b:a",
"192k",
]);
}
"mxf" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"pcm_s16le",
]);
}
"ogv" => {
raw_args.extend(vec![
"-c:v",
"libtheora",
"-qscale:v",
"6",
"-c:a",
"libvorbis",
"-qscale:a",
"4",
]);
}
"3gp" => {
raw_args.extend(vec![
"-c:v",
"h263",
"-s",
"352x288",
"-c:a",
"libopencore_amrnb",
"-ar",
"8000",
]);
}
"gif" => {
raw_args.extend(vec!["-vf", "fps=15,scale=320:-1:flags=lanczos"]);
}
"webp" => {
raw_args.extend(vec![
"-c:v",
"libwebp",
"-lossless",
"0",
"-qscale",
"75",
"-loop",
"0",
]);
}
"apng" => {
raw_args.extend(vec!["-plays", "0"]);
}
_ => {
raw_args.extend(vec!["-qscale:v", "3"]);
}
}
tracing::debug!("Built ffmpeg arguments: {:?}", raw_args);
tracing::trace!("Executing ffmpeg command...");
let output = std::process::Command::new("ffmpeg")
.arg("-y") // Overwrite output files without asking
.arg("-i")
.arg(&in_path)
.args(&raw_args)
.arg(&temp_out_path)
.output()
.map_err(|e| {
tracing::error!("Failed to execute ffmpeg: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
tracing::error!("ffmpeg execution failed: {}", err);
return Err(format!("ffmpeg failed: {}", err));
}
tracing::debug!("FFmpeg execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
-160
View File
@@ -1,160 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"graphics_magick"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of gm for graphics_magick plugin");
let available = std::process::Command::new("gm")
.arg("-version")
.output()
.is_ok();
tracing::debug!("graphics_magick plugin available: {}", available);
if !available {
tracing::warn!(
"graphics_magick plugin not available. Please install GraphicsMagick to use it."
);
}
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "jpeg" | "jpg" | "gif" => 255,
"bmp" | "tiff" | "ico" | "tga" | "webp" => 240,
"pdf" | "ps" | "eps" | "svg" => 200,
"avif" | "heic" | "jxl" | "jp2" | "j2k" => 180,
"pnm" | "ppm" | "pgm" | "pbm" | "pam" | "pcx" | "pict" | "dpx" | "miff" | "fits"
| "xcf" => 150,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "bmp" | "tiff" | "ico" | "tga" | "pnm" | "ppm" | "pgm" | "pbm" | "pam" => 250,
"jpeg" | "jpg" | "webp" | "avif" | "heic" | "jxl" | "jp2" | "j2k" => 200,
"gif" => 150,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
_ => 255,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("graphics_magick starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let raw_args = vec![
"convert",
in_path.to_str().unwrap(),
temp_out_path.to_str().unwrap(),
];
tracing::debug!("Built graphics_magick arguments: {:?}", raw_args);
let rt = tokio::runtime::Runtime::new().map_err(|e| {
tracing::error!("Failed to create Tokio runtime: {}", e);
e.to_string()
})?;
rt.block_on(async {
tracing::trace!("Executing GraphicsMagick...");
let output = std::process::Command::new("gm")
.args(raw_args)
.output()
.map_err(|e| {
tracing::error!("GraphicsMagick execution failed: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("GraphicsMagick failed with status {}. Stderr: {}", output.status, err_msg);
return Err(format!("GraphicsMagick error: {}", err_msg));
}
Ok(())
})?;
tracing::debug!("GraphicsMagick execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-graphicsmagick"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+60
View File
@@ -0,0 +1,60 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "svg", "pdf",
];
struct GraphicsMagick;
impl Plugin for GraphicsMagick {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "graphicsmagick".into(),
package: "convertis-graphicsmagick".into(),
description: "GraphicsMagick image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (210, 215, 180)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("gm")
.arg("-version")
.output()
.map_err(|_| "'gm' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let output = Command::new("gm")
.arg("convert")
.arg(&request.input)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(GraphicsMagick, "graphicsmagick");
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "convertis-html"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
base64.workspace = true
convertis-plugin-api.workspace = true
[dev-dependencies]
tempfile.workspace = true
+178
View File
@@ -0,0 +1,178 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use base64::{Engine, engine::general_purpose::STANDARD};
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
const INPUTS: &[&str] = &[
"png",
"jpeg",
"gif",
"animated-gif",
"webp",
"animated-webp",
"apng",
"bmp",
"tiff",
"ico",
"avif",
"heic",
"jxl",
"svg",
"pdf",
"mp4",
"webm",
"mkv",
"avi",
"mov",
"mpeg",
"ogv",
"mp3",
"ogg",
"aac",
"m4a",
"opus",
"wma",
"wav",
"flac",
"aiff",
"au",
"text",
];
struct Html;
fn escape(value: &str) -> String {
value
.chars()
.map(|character| match character {
'&' => "&amp;".to_owned(),
'<' => "&lt;".to_owned(),
'>' => "&gt;".to_owned(),
'"' => "&quot;".to_owned(),
'\'' => "&#39;".to_owned(),
other => other.to_string(),
})
.collect()
}
fn mime(format: &str) -> &'static str {
match format {
"png" | "apng" => "image/png",
"jpeg" => "image/jpeg",
"gif" | "animated-gif" => "image/gif",
"webp" | "animated-webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" => "image/tiff",
"ico" => "image/x-icon",
"avif" => "image/avif",
"heic" => "image/heic",
"jxl" => "image/jxl",
"svg" => "image/svg+xml",
"mp4" => "video/mp4",
"webm" => "video/webm",
"ogv" => "video/ogg",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
impl Plugin for Html {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "html".into(),
package: "convertis-html".into(),
description: "Create self-contained HTML".into(),
conversions: INPUTS
.iter()
.map(|from| Conversion::file(from, "html", (255, 255, 255)))
.collect(),
options: vec![],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let body = if request.from == "text" {
let text =
std::fs::read_to_string(&request.input).map_err(|error| error.to_string())?;
format!("<p style=\"white-space:pre-wrap\">{}</p>", escape(&text))
} else {
let data =
STANDARD.encode(std::fs::read(&request.input).map_err(|error| error.to_string())?);
let uri = format!("data:{};base64,{}", mime(&request.from), data);
if matches!(
request.from.as_str(),
"png"
| "apng"
| "jpeg"
| "gif"
| "animated-gif"
| "webp"
| "animated-webp"
| "bmp"
| "tiff"
| "ico"
| "avif"
| "heic"
| "jxl"
| "svg"
) {
format!("<img alt=\"Embedded media\" src=\"{uri}\">")
} else if matches!(
request.from.as_str(),
"mp3" | "ogg" | "aac" | "m4a" | "opus" | "wma" | "wav" | "flac" | "aiff" | "au"
) {
format!("<audio controls src=\"{uri}\"></audio>")
} else if request.from == "pdf" {
format!("<embed type=\"application/pdf\" src=\"{uri}\">")
} else {
format!("<video controls src=\"{uri}\"></video>")
}
};
let document = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>Converted media</title></head><body>{body}</body></html>\n"
);
std::fs::write(&request.output, document).map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(Html, "html");
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn text_is_escaped_inside_a_paragraph() {
let directory = tempfile::tempdir().unwrap();
let input = directory.path().join("input.txt");
let output = directory.path().join("output.html");
std::fs::write(&input, "<script>alert('x')</script>").unwrap();
Html.convert(&ConversionRequest {
input,
output: output.clone(),
from: "text".into(),
to: "html".into(),
options: BTreeMap::new(),
})
.unwrap();
let html = std::fs::read_to_string(output).unwrap();
assert!(html.contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;"));
assert!(!html.contains("<script>"));
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "convertis-image-ascii"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
image.workspace = true
+96
View File
@@ -0,0 +1,96 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata};
const FORMATS: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
struct ImageAscii;
impl Plugin for ImageAscii {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "image-ascii".into(),
package: "convertis-image-ascii".into(),
description: "Render images as ASCII text".into(),
conversions: FORMATS
.iter()
.map(|from| Conversion::file(from, "text", (255, 180, 230)))
.collect(),
options: vec![
OptionSpec {
name: "width".into(),
help: "Output width in characters".into(),
default: Some("80".into()),
},
OptionSpec {
name: "characters".into(),
help: "Dark-to-light character ramp".into(),
default: Some("@%#*+=-:. ".into()),
},
OptionSpec {
name: "invert".into(),
help: "Reverse the character ramp".into(),
default: Some("false".into()),
},
],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let image = image::open(&request.input).map_err(|error| error.to_string())?;
let width: u32 = request
.options
.get("width")
.map(String::as_str)
.unwrap_or("80")
.parse()
.map_err(|_| "width must be a positive integer")?;
if width == 0 {
return Err("width must be greater than zero".into());
}
let height = ((image.height() as f32 / image.width() as f32) * width as f32 * 0.5)
.round()
.max(1.0) as u32;
let grayscale = image
.resize_exact(width, height, image::imageops::FilterType::Triangle)
.to_luma8();
let mut ramp: Vec<char> = request
.options
.get("characters")
.map(String::as_str)
.unwrap_or("@%#*+=-:. ")
.chars()
.collect();
if ramp.len() < 2 {
return Err("characters must contain at least two characters".into());
}
if request
.options
.get("invert")
.is_some_and(|value| value == "true")
{
ramp.reverse();
}
let mut output = String::with_capacity((width as usize + 1) * height as usize);
for y in 0..height {
for x in 0..width {
let value = grayscale.get_pixel(x, y)[0] as usize;
output.push(ramp[value * (ramp.len() - 1) / 255]);
}
output.push('\n');
}
std::fs::write(&request.output, output).map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(ImageAscii, "image-ascii");
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-imagemagick"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+59
View File
@@ -0,0 +1,59 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "heic", "jxl", "svg", "pdf",
];
struct ImageMagick;
impl Plugin for ImageMagick {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "imagemagick".into(),
package: "convertis-imagemagick".into(),
description: "ImageMagick image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (220, 220, 170)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("magick")
.arg("-version")
.output()
.map_err(|_| "'magick' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let output = Command::new("magick")
.arg(&request.input)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(ImageMagick, "imagemagick");
-78
View File
@@ -1,78 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use image::ImageFormat;
use std::io::Cursor;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability for jpeg_to_png plugin (always true)");
true
}
fn name(&self) -> &'static str {
"jpeg_to_png"
}
fn from_formats(&self) -> Vec<&'static str> {
vec!["jpeg"]
}
fn to_formats(&self) -> Vec<&'static str> {
vec!["png"]
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
255
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
255
}
fn speed(&self, _from: &str, _to: &str) -> u8 {
200
}
#[tracing::instrument(skip(self, input, _temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("jpeg_to_png starting conversion");
tracing::trace!("Loading JPEG image from memory ({} bytes)", input.len());
let img = image::load_from_memory_with_format(input, ImageFormat::Jpeg)
.map_err(|e| {
tracing::error!("Failed to decode JPEG image: {}", e);
e.to_string()
})?;
tracing::trace!("JPEG image loaded successfully. Dimensions: {}x{}", img.width(), img.height());
let mut output = Cursor::new(Vec::new());
tracing::trace!("Encoding image as PNG");
img.write_to(&mut output, ImageFormat::Png)
.map_err(|e| {
tracing::error!("Failed to encode image as PNG: {}", e);
e.to_string()
})?;
let out_bytes = output.into_inner();
tracing::debug!("jpeg_to_png conversion completed. Output size: {} bytes", out_bytes.len());
Ok(out_bytes)
}
}
-157
View File
@@ -1,157 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"magick"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of magick for magick plugin");
let available = std::process::Command::new("magick")
.arg("-version")
.output()
.is_ok();
tracing::debug!("magick plugin available: {}", available);
if !available {
tracing::warn!("magick plugin not available. Please install ImageMagick to use it.");
}
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "jpeg" | "jpg" | "gif" => 255,
"bmp" | "tiff" | "ico" | "tga" | "webp" => 240,
"pdf" | "ps" | "eps" | "svg" => 200,
"avif" | "heic" | "jxl" | "jp2" | "j2k" => 180,
"pnm" | "ppm" | "pgm" | "pbm" | "pam" | "pcx" | "pict" | "dpx" | "miff" | "fits"
| "xcf" => 150,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "bmp" | "tiff" | "ico" | "tga" | "pnm" | "ppm" | "pgm" | "pbm" | "pam" => 250,
"jpeg" | "jpg" | "webp" | "avif" | "heic" | "jxl" | "jp2" | "j2k" => 200,
"gif" => 150,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
_ => 128,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("magick starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let raw_args = vec![
in_path.to_str().unwrap(),
temp_out_path.to_str().unwrap(),
];
tracing::debug!("Built magick arguments: {:?}", raw_args);
let rt = tokio::runtime::Runtime::new().map_err(|e| {
tracing::error!("Failed to create Tokio runtime: {}", e);
e.to_string()
})?;
rt.block_on(async {
tracing::trace!("Executing ImageMagick...");
let output = std::process::Command::new("magick")
.args(raw_args)
.output()
.map_err(|e| {
tracing::error!("ImageMagick execution failed: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("ImageMagick failed with status {}. Stderr: {}", output.status, err_msg);
return Err(format!("ImageMagick error: {}", err_msg));
}
Ok(())
})?;
tracing::debug!("ImageMagick execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "convertis-native-image"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
image.workspace = true
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use image::{ImageFormat, ImageReader};
use std::{fs::File, io::BufReader};
const FORMATS: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
struct NativeImage;
fn image_format(name: &str) -> Result<ImageFormat, String> {
ImageFormat::from_extension(name).ok_or_else(|| format!("unsupported image format '{name}'"))
}
impl Plugin for NativeImage {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "native-image".into(),
package: "convertis-native-image".into(),
description: "Native common image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (255, 230, 240)))
})
.collect(),
options: vec![],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let reader = ImageReader::with_format(
BufReader::new(File::open(&request.input).map_err(|error| error.to_string())?),
image_format(&request.from)?,
);
let mut image = reader.decode().map_err(|error| error.to_string())?;
if request.to == "ico" && (image.width() > 256 || image.height() > 256) {
image = image.resize(256, 256, image::imageops::FilterType::Lanczos3);
}
image
.save_with_format(&request.output, image_format(&request.to)?)
.map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(NativeImage, "native-image");
-74
View File
@@ -1,74 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
pub struct PluginImpl;
impl Plugin for PluginImpl {
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability for png_to_jpeg plugin (always true)");
true
}
fn name(&self) -> &'static str {
"png_to_jpeg"
}
fn from_formats(&self) -> Vec<&'static str> {
vec!["png"]
}
fn to_formats(&self) -> Vec<&'static str> {
vec!["jpeg"]
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
255
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
200
} // JPEG has some compression loss
fn speed(&self, _from: &str, _to: &str) -> u8 {
220
}
#[tracing::instrument(skip(self, input, _temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &std::path::Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("png_to_jpeg starting conversion");
tracing::trace!("Loading PNG image from memory ({} bytes)", input.len());
let img = image::load_from_memory_with_format(input, image::ImageFormat::Png)
.map_err(|e| {
tracing::error!("Failed to decode PNG image: {}", e);
e.to_string()
})?;
tracing::trace!("PNG image loaded successfully. Dimensions: {}x{}", img.width(), img.height());
let mut output = std::io::Cursor::new(Vec::new());
tracing::trace!("Encoding image as JPEG");
img.write_to(&mut output, image::ImageFormat::Jpeg)
.map_err(|e| {
tracing::error!("Failed to encode image as JPEG: {}", e);
e.to_string()
})?;
let out_bytes = output.into_inner();
tracing::debug!("png_to_jpeg conversion completed. Output size: {} bytes", out_bytes.len());
Ok(out_bytes)
}
}