use std::fmt;
use rff_codec::CodecRegistry;
use rff_core::{CodecId, MediaType};
use rff_format::{FormatRegistry, MuxCaps};
mod json;
pub use json::matrix_to_json;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceStream {
pub index: usize,
pub media_type: MediaType,
pub codec_id: CodecId,
}
impl SourceStream {
pub fn new(index: usize, media_type: MediaType, codec_id: CodecId) -> SourceStream {
SourceStream {
index,
media_type,
codec_id,
}
}
}
impl From<&rff_format::Stream> for SourceStream {
fn from(s: &rff_format::Stream) -> SourceStream {
SourceStream {
index: s.index,
media_type: s.media_type,
codec_id: s.codec_id,
}
}
}
#[derive(Debug, Clone)]
pub struct Plan {
pub source: Vec<SourceStream>,
pub source_format: Option<String>,
pub targets: Vec<Target>,
}
impl Plan {
pub fn stream_copies(&self) -> impl Iterator<Item = &Target> {
self.targets.iter().filter(|t| t.fidelity == Fidelity::Copy)
}
pub fn of_kind(&self, kind: TargetKind) -> impl Iterator<Item = &Target> + '_ {
self.targets.iter().filter(move |t| t.kind == kind)
}
pub fn target(&self, format: &str) -> Option<&Target> {
self.targets.iter().find(|t| t.format == format)
}
}
#[derive(Debug, Clone)]
pub struct Target {
pub format: &'static str,
pub long_name: &'static str,
pub extension: &'static str,
pub extensions: &'static [&'static str],
pub kind: TargetKind,
pub streams: Vec<StreamPlan>,
pub fidelity: Fidelity,
pub notes: Vec<String>,
pub args: Vec<String>,
}
impl Target {
pub fn kept(&self) -> impl Iterator<Item = &StreamPlan> {
self.streams.iter().filter(|s| !s.action.is_drop())
}
pub fn dropped(&self) -> impl Iterator<Item = &StreamPlan> {
self.streams.iter().filter(|s| s.action.is_drop())
}
pub fn stream_summary(&self) -> String {
let mut parts: Vec<String> = self.kept().map(|s| s.summary()).collect();
if parts.is_empty() {
parts.push("nothing".into());
}
let mut s = parts.join(" + ");
let dropped = self.dropped().count();
if dropped > 0 {
s.push_str(&format!(", {dropped} dropped"));
}
s
}
pub fn summary(&self) -> String {
format!("{} ({})", self.stream_summary(), self.fidelity)
}
pub fn command(&self, input: &str, output_stem: &str) -> String {
let mut s = format!("rff -i {input}");
for a in &self.args {
s.push(' ');
s.push_str(a);
}
format!("{s} {output_stem}.{}", self.extension)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TargetKind {
Video,
Audio,
Image,
Subtitle,
}
impl TargetKind {
pub fn name(self) -> &'static str {
match self {
TargetKind::Video => "video",
TargetKind::Audio => "audio",
TargetKind::Image => "image",
TargetKind::Subtitle => "subtitle",
}
}
}
impl fmt::Display for TargetKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fidelity {
Copy,
Lossless,
Lossy,
}
impl Fidelity {
pub fn name(self) -> &'static str {
match self {
Fidelity::Copy => "copy",
Fidelity::Lossless => "lossless",
Fidelity::Lossy => "lossy",
}
}
}
impl fmt::Display for Fidelity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamPlan {
pub input_index: usize,
pub media_type: MediaType,
pub from: CodecId,
pub action: Action,
}
impl StreamPlan {
pub fn summary(&self) -> String {
match self.action {
Action::Copy => format!("{} copy", self.from),
Action::Transcode { to, .. } => format!("{}->{}", self.from, to),
Action::Drop(reason) => format!("{} dropped ({reason})", self.from),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Copy,
Transcode { to: CodecId, lossy: bool },
Drop(DropReason),
}
impl Action {
pub fn is_drop(&self) -> bool {
matches!(self, Action::Drop(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropReason {
UnsupportedMedia,
NoEncoder,
NoDecoder,
ContainerFull,
}
impl DropReason {
pub fn name(self) -> &'static str {
match self {
DropReason::UnsupportedMedia => "unsupported-media",
DropReason::NoEncoder => "no-encoder",
DropReason::NoDecoder => "no-decoder",
DropReason::ContainerFull => "container-full",
}
}
fn explain(self) -> &'static str {
match self {
DropReason::UnsupportedMedia => "container carries no stream of this type",
DropReason::NoEncoder => "no encoder for any codec this container accepts",
DropReason::NoDecoder => "no decoder for the source codec",
DropReason::ContainerFull => "container is full",
}
}
}
impl fmt::Display for DropReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.explain())
}
}
pub fn is_lossless(id: CodecId) -> bool {
matches!(
id,
CodecId::Png
| CodecId::Flac
| CodecId::Pcm
| CodecId::RawVideo
| CodecId::Webp
| CodecId::Subrip
| CodecId::WebVtt
)
}
fn encodes_stills_only(id: CodecId) -> bool {
matches!(id, CodecId::Avif)
}
fn is_text_subtitle(id: CodecId) -> bool {
matches!(id, CodecId::Subrip | CodecId::WebVtt)
}
fn preference(media: MediaType) -> &'static [CodecId] {
match media {
MediaType::Video => &[
CodecId::H264,
CodecId::Vp9,
CodecId::Av2,
CodecId::Avif,
CodecId::Jpeg,
CodecId::Png,
CodecId::Webp,
CodecId::Gif,
CodecId::Jxl,
CodecId::RawVideo,
],
MediaType::Audio => &[
CodecId::Opus,
CodecId::Aac,
CodecId::Flac,
CodecId::Mp3,
CodecId::Vorbis,
CodecId::Pcm,
],
MediaType::Subtitle => &[CodecId::Subrip, CodecId::WebVtt],
_ => &[],
}
}
fn preferred_override(format: &str, media: MediaType) -> Option<CodecId> {
match (format, media) {
("mp4" | "dash", MediaType::Audio) => Some(CodecId::Aac),
_ => None,
}
}
const VIDEO_ORDER: &[&str] = &[
"mp4",
"matroska",
"webm",
"mpegts",
"flv",
"avi",
"hls",
"dash",
"ivf",
"av2f",
"yuv4mpegpipe",
];
const AUDIO_ORDER: &[&str] = &[
"mp3", "flac", "wav", "ogg", "mp4", "matroska", "webm", "mpegts", "flv", "avi", "hls", "dash",
];
const IMAGE_ORDER: &[&str] = &["jpeg", "png", "webp", "avif", "gif", "jpegxl", "av2f"];
const SUBTITLE_ORDER: &[&str] = &["srt", "webvtt", "matroska", "mp4"];
fn rank(kind: TargetKind, name: &str) -> usize {
let order = match kind {
TargetKind::Video => VIDEO_ORDER,
TargetKind::Audio => AUDIO_ORDER,
TargetKind::Image => IMAGE_ORDER,
TargetKind::Subtitle => SUBTITLE_ORDER,
};
order.iter().position(|n| *n == name).unwrap_or(order.len())
}
const MEDIA_PRIORITY: [MediaType; 3] = [MediaType::Video, MediaType::Audio, MediaType::Subtitle];
fn media_rank(m: MediaType) -> usize {
MEDIA_PRIORITY.iter().position(|p| *p == m).unwrap_or(9)
}
pub fn plan(codecs: &CodecRegistry, formats: &FormatRegistry, source: &[SourceStream]) -> Plan {
let mut targets: Vec<Target> = formats
.iter()
.filter(|f| f.can_mux())
.filter_map(|f| {
build_target(
codecs,
f.name,
f.long_name,
f.extensions,
&f.mux_caps,
f.muxer_path.is_some(),
|ext| formats.by_extension(ext).map(|r| r.name) != Some(f.name),
source,
)
})
.collect();
let source_kind = source_kind(source);
targets.sort_by_key(|t| {
(
(t.kind != source_kind) as u8,
t.fidelity,
rank(t.kind, t.format),
t.format,
)
});
Plan {
source: source.to_vec(),
source_format: None,
targets,
}
}
fn source_kind(source: &[SourceStream]) -> TargetKind {
if source.iter().any(|s| s.media_type == MediaType::Video) {
TargetKind::Video
} else if source.iter().any(|s| s.media_type == MediaType::Audio) {
TargetKind::Audio
} else {
TargetKind::Subtitle
}
}
#[allow(clippy::too_many_arguments)]
fn build_target(
codecs: &CodecRegistry,
format: &'static str,
long_name: &'static str,
extensions: &'static [&'static str],
caps: &MuxCaps,
multi_file: bool,
needs_explicit_f: impl Fn(&str) -> bool,
source: &[SourceStream],
) -> Option<Target> {
let mut streams: Vec<StreamPlan> = source
.iter()
.map(|s| StreamPlan {
input_index: s.index,
media_type: s.media_type,
from: s.codec_id,
action: decide(codecs, format, caps, s),
})
.collect();
if let Some(max) = caps.max_streams {
let mut order: Vec<usize> = (0..streams.len())
.filter(|&i| !streams[i].action.is_drop())
.collect();
order.sort_by_key(|&i| (media_rank(streams[i].media_type), streams[i].input_index));
for &i in order.iter().skip(max) {
streams[i].action = Action::Drop(DropReason::ContainerFull);
}
}
if streams.iter().all(|s| s.action.is_drop()) {
return None;
}
let fidelity = streams
.iter()
.filter_map(|s| match s.action {
Action::Copy => Some(Fidelity::Copy),
Action::Transcode { lossy: false, .. } => Some(Fidelity::Lossless),
Action::Transcode { lossy: true, .. } => Some(Fidelity::Lossy),
Action::Drop(_) => None,
})
.max()
.unwrap_or(Fidelity::Copy);
let kind = if caps.still_image {
TargetKind::Image
} else {
match streams
.iter()
.filter(|s| !s.action.is_drop())
.map(|s| s.media_type)
.min_by_key(|m| media_rank(*m))
{
Some(MediaType::Video) => TargetKind::Video,
Some(MediaType::Audio) => TargetKind::Audio,
_ => TargetKind::Subtitle,
}
};
let extension = primary_extension(extensions, &streams, format);
Some(Target {
format,
long_name,
extension,
extensions,
kind,
notes: notes(format, caps, multi_file, &streams, kind),
args: args(format, needs_explicit_f(extension), &streams),
streams,
fidelity,
})
}
fn primary_extension(
extensions: &'static [&'static str],
streams: &[StreamPlan],
format: &'static str,
) -> &'static str {
for s in streams {
let codec = match s.action {
Action::Copy => s.from,
Action::Transcode { to, .. } => to,
Action::Drop(_) => continue,
};
if let Some(ext) = extensions.iter().find(|e| **e == codec.name()) {
return ext;
}
}
extensions.first().copied().unwrap_or(format)
}
fn decide(codecs: &CodecRegistry, format: &str, caps: &MuxCaps, s: &SourceStream) -> Action {
if !caps.accepts_media(s.media_type) {
return Action::Drop(DropReason::UnsupportedMedia);
}
if caps.accepts(s.codec_id) {
return Action::Copy;
}
if s.media_type == MediaType::Subtitle && is_text_subtitle(s.codec_id) {
return match caps
.codecs_for(MediaType::Subtitle)
.find(|id| is_text_subtitle(*id))
{
Some(to) => Action::Transcode { to, lossy: false },
None => Action::Drop(DropReason::NoEncoder),
};
}
if !codecs.by_id(s.codec_id).is_some_and(|c| c.can_decode()) {
return Action::Drop(DropReason::NoDecoder);
}
match pick_encoder(codecs, format, caps, s.media_type) {
Some(to) => Action::Transcode {
to,
lossy: !is_lossless(to),
},
None => Action::Drop(DropReason::NoEncoder),
}
}
fn pick_encoder(
codecs: &CodecRegistry,
format: &str,
caps: &MuxCaps,
media: MediaType,
) -> Option<CodecId> {
let usable = |id: CodecId| {
let shape_fits = caps.still_image || !encodes_stills_only(id);
shape_fits && caps.accepts(id) && codecs.by_id(id).is_some_and(|c| c.can_encode())
};
preferred_override(format, media)
.filter(|id| usable(*id))
.or_else(|| preference(media).iter().copied().find(|id| usable(*id)))
}
fn notes(
format: &str,
caps: &MuxCaps,
multi_file: bool,
streams: &[StreamPlan],
kind: TargetKind,
) -> Vec<String> {
let mut notes = Vec::new();
if caps.still_image
&& kind == TargetKind::Image
&& streams
.iter()
.any(|s| !s.action.is_drop() && s.media_type == MediaType::Video)
{
notes.push("holds one picture — only the first frame is written".into());
}
if multi_file {
notes.push("writes a playlist plus segment files, not a single file".into());
}
for s in streams {
if let Action::Transcode { to, lossy: true } = s.action {
if !is_lossless(s.from) {
notes.push(format!(
"{} -> {to} re-encodes already-lossy data (generation loss)",
s.from
));
}
}
}
for s in streams {
if let Action::Drop(reason) = s.action {
notes.push(format!(
"stream {} ({} {}) is dropped: {reason}",
s.input_index, s.media_type, s.from
));
}
}
if format == "av2f" {
notes.push(
"experimental: the four-character codes are ours, so these files read back \
here and nowhere else"
.into(),
);
}
notes
}
fn args(format: &str, needs_f: bool, streams: &[StreamPlan]) -> Vec<String> {
let mut args = Vec::new();
let mut seen = [false; 3]; for s in streams {
let (slot, flag) = match s.media_type {
MediaType::Video => (0usize, "-c:v"),
MediaType::Audio => (1, "-c:a"),
MediaType::Subtitle => (2, "-c:s"),
_ => continue,
};
if seen[slot] {
continue;
}
let codec = match s.action {
Action::Copy => "copy".to_string(),
Action::Transcode { to, .. } => to.name().to_string(),
Action::Drop(_) => continue,
};
seen[slot] = true;
args.push(flag.to_string());
args.push(codec);
}
if needs_f {
args.push("-f".into());
args.push(format.to_string());
}
args
}
#[derive(Debug, Clone)]
pub struct FormatSupport {
pub format: &'static str,
pub long_name: &'static str,
pub extensions: &'static [&'static str],
pub can_read: bool,
pub can_write: bool,
pub video: Vec<CodecId>,
pub audio: Vec<CodecId>,
pub subtitle: Vec<CodecId>,
pub still_image: bool,
pub max_streams: Option<usize>,
}
pub fn format_matrix(formats: &FormatRegistry) -> Vec<FormatSupport> {
let mut rows: Vec<FormatSupport> = formats
.iter()
.map(|f| FormatSupport {
format: f.name,
long_name: f.long_name,
extensions: f.extensions,
can_read: f.can_demux(),
can_write: f.can_mux(),
video: f.mux_caps.codecs_for(MediaType::Video).collect(),
audio: f.mux_caps.codecs_for(MediaType::Audio).collect(),
subtitle: f.mux_caps.codecs_for(MediaType::Subtitle).collect(),
still_image: f.mux_caps.still_image,
max_streams: f.mux_caps.max_streams,
})
.collect();
rows.sort_by_key(|r| (rank(TargetKind::Video, r.format), r.format));
rows
}
pub fn readable_extensions(formats: &FormatRegistry) -> Vec<&'static str> {
let mut v: Vec<&'static str> = formats
.iter()
.filter(|f| f.can_demux())
.flat_map(|f| f.extensions.iter().copied())
.collect();
v.sort_unstable();
v.dedup();
v
}