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-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");
|
||||
Reference in New Issue
Block a user