use crate::core::context::muxer::Muxer;
use crate::error::{OpenOutputError, Result};
use ffmpeg_sys_next::AVCodecID::{AV_CODEC_ID_NONE, AV_CODEC_ID_OTF, AV_CODEC_ID_TTF};
use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_ATTACHMENT;
use ffmpeg_sys_next::{
av_dict_set, av_mallocz, avformat_new_stream, AVCodecID, AVStream, AV_INPUT_BUFFER_PADDING_SIZE,
};
use std::ffi::{CStr, CString};
use std::fs::File;
use std::io::Read;
use std::path::Path;
pub(crate) const MAX_ATTACHMENT_SIZE: u64 = 100 * 1024 * 1024;
#[allow(clippy::manual_c_str_literals)]
const FILENAME_KEY: &CStr = unsafe { CStr::from_bytes_with_nul_unchecked(b"filename\0") };
#[allow(clippy::manual_c_str_literals)]
const MIMETYPE_KEY: &CStr = unsafe { CStr::from_bytes_with_nul_unchecked(b"mimetype\0") };
pub(crate) unsafe fn create_attachment_streams(mux: &Muxer) -> Result<()> {
if mux.attachments.is_empty() {
return Ok(());
}
let oc = mux.out_fmt_ctx_ptr();
debug_assert!(!oc.is_null(), "attachment: output context already taken");
debug_assert_eq!(
(*oc).nb_streams as usize,
mux.nb_streams,
"attachment streams must be created after every mapped stream"
);
warn_if_unsupported_muxer(oc);
const PAD: usize = AV_INPUT_BUFFER_PADDING_SIZE as usize;
for spec in &mux.attachments {
let path_display = spec.path.display().to_string();
let bytes = read_attachment_bytes(&spec.path, &path_display)?;
let len = bytes.len();
let mimetype = match &spec.mimetype {
Some(m) if m.is_empty() => {
return Err(OpenOutputError::AttachmentEmptyMimetype(path_display).into());
}
Some(m) => m.clone(),
None => guess_mimetype(&spec.path),
};
let basename = spec
.path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("attachment");
let filename_c = CString::new(basename)?;
let mimetype_c = CString::new(mimetype.as_str())?;
let st = avformat_new_stream(oc, std::ptr::null());
if st.is_null() {
return Err(OpenOutputError::OutOfMemory.into());
}
let par = (*st).codecpar;
let total = len + PAD;
let buf = av_mallocz(total) as *mut u8;
if buf.is_null() {
return Err(OpenOutputError::OutOfMemory.into());
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, len);
(*par).extradata = buf;
(*par).extradata_size = len as i32;
(*par).codec_type = AVMEDIA_TYPE_ATTACHMENT;
(*par).codec_id = guess_codec_id(&spec.path);
set_stream_tag(st, FILENAME_KEY, &filename_c)?;
set_stream_tag(st, MIMETYPE_KEY, &mimetype_c)?;
}
Ok(())
}
fn read_attachment_bytes(path: &Path, path_display: &str) -> Result<Vec<u8>> {
let file = File::open(path)
.map_err(|e| OpenOutputError::AttachmentRead(path_display.to_string(), e))?;
let meta = file
.metadata()
.map_err(|e| OpenOutputError::AttachmentRead(path_display.to_string(), e))?;
if !meta.file_type().is_file() {
let e = std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file");
return Err(OpenOutputError::AttachmentRead(path_display.to_string(), e).into());
}
let mut bytes = Vec::new();
file.take(MAX_ATTACHMENT_SIZE + 1)
.read_to_end(&mut bytes)
.map_err(|e| OpenOutputError::AttachmentRead(path_display.to_string(), e))?;
if bytes.is_empty() {
return Err(OpenOutputError::AttachmentEmpty(path_display.to_string()).into());
}
check_attachment_size(path_display, bytes.len() as u64)?;
Ok(bytes)
}
fn check_attachment_size(path_display: &str, len: u64) -> Result<()> {
if len > MAX_ATTACHMENT_SIZE {
return Err(OpenOutputError::AttachmentTooLarge(
path_display.to_string(),
len,
MAX_ATTACHMENT_SIZE,
)
.into());
}
Ok(())
}
fn guess_mimetype(path: &Path) -> String {
match extension_lower(path).as_deref() {
Some("ttf") => "application/x-truetype-font".to_string(),
Some("otf") => "application/vnd.ms-opentype".to_string(),
_ => "application/octet-stream".to_string(),
}
}
fn guess_codec_id(path: &Path) -> AVCodecID {
match extension_lower(path).as_deref() {
Some("ttf") => AV_CODEC_ID_TTF,
Some("otf") => AV_CODEC_ID_OTF,
_ => AV_CODEC_ID_NONE,
}
}
fn extension_lower(path: &Path) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
}
unsafe fn set_stream_tag(st: *mut AVStream, key: &CStr, value: &CStr) -> Result<()> {
let ret = av_dict_set(&mut (*st).metadata, key.as_ptr(), value.as_ptr(), 0);
if ret < 0 {
return Err(OpenOutputError::from(ret).into());
}
Ok(())
}
unsafe fn warn_if_unsupported_muxer(oc: *mut ffmpeg_sys_next::AVFormatContext) {
let oformat = (*oc).oformat;
if oformat.is_null() || (*oformat).name.is_null() {
return;
}
let name = CStr::from_ptr((*oformat).name).to_string_lossy();
if !(name.contains("matroska") || name.contains("webm")) {
log::warn!(
"output format '{name}' may not support attachment streams; \
attachments are reliably supported only by Matroska/WebM, and the \
muxer may reject them when writing the header"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[cfg(unix)]
#[test]
fn rejects_non_regular_file_without_hanging() {
let err = read_attachment_bytes(Path::new("/dev/zero"), "/dev/zero").unwrap_err();
assert!(
matches!(
err,
Error::OpenOutput(OpenOutputError::AttachmentRead(_, _))
),
"expected AttachmentRead for a non-regular file, got {err:?}"
);
}
#[test]
fn rejects_missing_file() {
let err = read_attachment_bytes(
Path::new("/nonexistent/ez-ffmpeg/attachment.bin"),
"/nonexistent/ez-ffmpeg/attachment.bin",
)
.unwrap_err();
assert!(
matches!(
err,
Error::OpenOutput(OpenOutputError::AttachmentRead(_, _))
),
"expected AttachmentRead for a missing file, got {err:?}"
);
}
#[test]
fn oversized_attachment_is_rejected_without_allocating() {
let err = check_attachment_size("big.bin", MAX_ATTACHMENT_SIZE + 1).unwrap_err();
assert!(
matches!(
err,
Error::OpenOutput(OpenOutputError::AttachmentTooLarge(_, len, cap))
if len == MAX_ATTACHMENT_SIZE + 1 && cap == MAX_ATTACHMENT_SIZE
),
"expected AttachmentTooLarge, got {err:?}"
);
}
#[test]
fn exactly_at_cap_is_allowed() {
assert!(check_attachment_size("ok.bin", MAX_ATTACHMENT_SIZE).is_ok());
assert!(check_attachment_size("zero.bin", 0).is_ok());
}
#[test]
fn mimetype_guessing_is_case_insensitive_and_matches_font_table() {
assert_eq!(
guess_mimetype(Path::new("DejaVuSans.ttf")),
"application/x-truetype-font"
);
assert_eq!(
guess_mimetype(Path::new("FONT.OTF")),
"application/vnd.ms-opentype"
);
assert_eq!(
guess_mimetype(Path::new("cover.png")),
"application/octet-stream"
);
assert_eq!(
guess_mimetype(Path::new("noextension")),
"application/octet-stream"
);
}
#[test]
fn codec_id_guessing_matches_extension() {
assert_eq!(guess_codec_id(Path::new("a.ttf")), AV_CODEC_ID_TTF);
assert_eq!(guess_codec_id(Path::new("a.OtF")), AV_CODEC_ID_OTF);
assert_eq!(guess_codec_id(Path::new("a.bin")), AV_CODEC_ID_NONE);
}
}