refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
This commit is contained in:
1 parent
2ea317e117
commit
eb76ed1937
43 files changed
+4243
-2296
No files matched your search
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
'&' => "&".to_owned(),
|
||||
'<' => "<".to_owned(),
|
||||
'>' => ">".to_owned(),
|
||||
'"' => """.to_owned(),
|
||||
'\'' => "'".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("<script>alert('x')</script>"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user