refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
This commit is contained in:
@@ -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");
|
||||
Reference in New Issue
Block a user