use std::path::{Path, PathBuf};
use pixelflow_core::{
ErrorCategory, ErrorCode, FormatDescriptor, PixelFlowError, Rational, Result,
SourceOptionValue, SourceRequest, resolve_format_alias,
};
use super::cache::{cache_file_name, default_cache_path};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum VfrMode {
ImplicitNormalize,
Normalize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Ffms2SourceOptions {
source_path: PathBuf,
cache_path: PathBuf,
frame_rate_override: Option<Rational>,
vfr_mode: VfrMode,
output_format: FormatDescriptor,
track: Option<usize>,
threads: usize,
}
impl Ffms2SourceOptions {
pub(crate) fn from_request(request: &SourceRequest, script_dir: Option<&Path>) -> Result<Self> {
let source_path = resolve_path(script_dir, request.path());
let mut cache_path = script_dir.map_or_else(
|| PathBuf::from(default_cache_name(request.path())),
|dir| default_cache_path(dir, request.path()),
);
let mut frame_rate_override = None;
let mut vfr_mode = VfrMode::ImplicitNormalize;
let mut output_format = resolve_format_alias("yuv420p8")?;
let mut track = None;
let mut threads = 1usize;
for (name, value) in request.options() {
match name.as_str() {
"cache" => {
let path = option_string(name, value)?;
cache_path = resolve_path(script_dir, &path);
}
"fps" => {
let rate = option_rational(name, value)?;
if rate.numerator <= 0 || rate.denominator <= 0 {
return Err(invalid_option(
"source.unknown_frame_rate",
"frame rate override must have positive numerator and denominator",
));
}
frame_rate_override = Some(rate);
}
"vfr" => {
vfr_mode = match option_string(name, value)?.as_str() {
"normalize" => VfrMode::Normalize,
"passthrough" => {
return Err(invalid_option(
"source.vfr_passthrough_unsupported",
"vfr passthrough is reserved but unsupported in phase 1",
));
}
other => {
return Err(invalid_option(
"source.invalid_option",
format!("unsupported vfr mode '{other}'"),
));
}
};
}
"format" => {
output_format = resolve_format_alias(&option_string(name, value)?)?;
}
"track" => {
track = Some(option_usize(name, value)?);
}
"threads" => {
let value = option_usize(name, value)?;
threads = value.max(1);
}
other => {
return Err(invalid_option(
"source.invalid_option",
format!("unknown source option '{other}'"),
));
}
}
}
Ok(Self {
source_path,
cache_path,
frame_rate_override,
vfr_mode,
output_format,
track,
threads,
})
}
pub(crate) fn source_path(&self) -> &Path {
&self.source_path
}
pub(crate) fn cache_path(&self) -> &Path {
&self.cache_path
}
pub(crate) const fn frame_rate_override(&self) -> Option<Rational> {
self.frame_rate_override
}
pub(crate) const fn vfr_mode(&self) -> VfrMode {
self.vfr_mode
}
pub(crate) const fn output_format(&self) -> &FormatDescriptor {
&self.output_format
}
pub(crate) const fn track(&self) -> Option<usize> {
self.track
}
pub(crate) const fn threads(&self) -> usize {
self.threads
}
}
fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf {
let path = Path::new(raw);
if path.is_absolute() {
path.to_path_buf()
} else if let Some(base_dir) = base_dir {
base_dir.join(path)
} else {
path.to_path_buf()
}
}
fn default_cache_name(source_path: &str) -> String {
cache_file_name(source_path)
}
fn option_string(name: &str, value: &SourceOptionValue) -> Result<String> {
match value {
SourceOptionValue::String(value) => Ok(value.clone()),
_ => Err(invalid_option(
"source.invalid_option",
format!("source option '{name}' must be string"),
)),
}
}
fn option_rational(name: &str, value: &SourceOptionValue) -> Result<Rational> {
match value {
SourceOptionValue::Rational(value) => Ok(*value),
_ => Err(invalid_option(
"source.invalid_option",
format!("source option '{name}' must be rational"),
)),
}
}
fn option_usize(name: &str, value: &SourceOptionValue) -> Result<usize> {
match value {
SourceOptionValue::Int(value) => usize::try_from(*value).map_err(|_| {
invalid_option(
"source.invalid_option",
format!("source option '{name}' must be non-negative"),
)
}),
_ => Err(invalid_option(
"source.invalid_option",
format!("source option '{name}' must be integer"),
)),
}
}
fn invalid_option(code: &'static str, message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(ErrorCategory::Source, ErrorCode::new(code), message)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use pixelflow_core::{ErrorCategory, ErrorCode, Rational, SourceOptionValue, SourceRequest};
use super::{Ffms2SourceOptions, VfrMode};
#[test]
fn options_resolve_relative_source_and_default_cache_against_script_dir() {
let temp = tempdir().expect("tempdir");
let request = SourceRequest::new("media/input clip.mkv");
let options =
Ffms2SourceOptions::from_request(&request, Some(temp.path())).expect("options parse");
assert_eq!(
options.source_path(),
temp.path().join("media/input clip.mkv")
);
assert!(options.cache_path().starts_with(temp.path()));
assert!(
options
.cache_path()
.to_string_lossy()
.ends_with(".ffms2.pfidx")
);
assert_eq!(options.vfr_mode(), VfrMode::ImplicitNormalize);
}
#[test]
fn options_accept_explicit_cache_fps_vfr_format_track_and_threads() {
let request = SourceRequest::new("input.mkv")
.with_option("cache", SourceOptionValue::String("custom.idx".to_owned()))
.with_option(
"fps",
SourceOptionValue::Rational(Rational {
numerator: 24_000,
denominator: 1_001,
}),
)
.with_option("vfr", SourceOptionValue::String("normalize".to_owned()))
.with_option("format", SourceOptionValue::String("yuv420p10".to_owned()))
.with_option("track", SourceOptionValue::Int(2))
.with_option("threads", SourceOptionValue::Int(4));
let options = Ffms2SourceOptions::from_request(&request, None).expect("options parse");
assert_eq!(
options.frame_rate_override(),
Some(Rational {
numerator: 24_000,
denominator: 1_001,
})
);
assert_eq!(options.vfr_mode(), VfrMode::Normalize);
assert_eq!(options.output_format().name(), "yuv420p10");
assert_eq!(options.track(), Some(2));
assert_eq!(options.threads(), 4);
}
#[test]
fn options_reject_vfr_passthrough() {
let request = SourceRequest::new("input.mkv")
.with_option("vfr", SourceOptionValue::String("passthrough".to_owned()));
let error =
Ffms2SourceOptions::from_request(&request, None).expect_err("passthrough unsupported");
assert_eq!(error.category(), ErrorCategory::Source);
assert_eq!(
error.code(),
ErrorCode::new("source.vfr_passthrough_unsupported")
);
}
}