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
2026-07-17 15:50:50 +02:00
parent 2ea317e117
commit eb76ed1937
43 changed files with 4243 additions and 2296 deletions
+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");