485 lines
17 KiB
Rust
485 lines
17 KiB
Rust
|
|
//! Special-folder media storage: images plus videos with async ffmpeg transcode.
|
||
|
|
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
|
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
use tokio::process::Command;
|
||
|
|
use tokio::sync::Semaphore;
|
||
|
|
|
||
|
|
use crate::folders::MAX_ITEMS;
|
||
|
|
use crate::photos::{self, MAX_BYTES};
|
||
|
|
|
||
|
|
pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "lowercase")]
|
||
|
|
pub enum MediaType {
|
||
|
|
Image,
|
||
|
|
Video,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "lowercase")]
|
||
|
|
pub enum MediaStatus {
|
||
|
|
Ready,
|
||
|
|
Processing,
|
||
|
|
Failed,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct MediaItem {
|
||
|
|
pub id: String,
|
||
|
|
#[serde(rename = "type")]
|
||
|
|
pub media_type: MediaType,
|
||
|
|
pub status: MediaStatus,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub enum UploadKind {
|
||
|
|
Image(&'static str),
|
||
|
|
Video(&'static str),
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, PartialEq, Eq)]
|
||
|
|
pub enum MediaError {
|
||
|
|
Full,
|
||
|
|
TooLarge,
|
||
|
|
BadType,
|
||
|
|
BadFolder,
|
||
|
|
Io,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
struct VideoMeta {
|
||
|
|
id: String,
|
||
|
|
#[serde(rename = "type")]
|
||
|
|
media_type: MediaType,
|
||
|
|
status: MediaStatus,
|
||
|
|
original_name: String,
|
||
|
|
created_at: String,
|
||
|
|
output_file: String,
|
||
|
|
error: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct FolderMediaStore {
|
||
|
|
base: PathBuf,
|
||
|
|
transcodes: Arc<Semaphore>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct VideoJob {
|
||
|
|
pub folder: String,
|
||
|
|
pub id: String,
|
||
|
|
pub input_path: PathBuf,
|
||
|
|
pub output_path: PathBuf,
|
||
|
|
pub meta_path: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl FolderMediaStore {
|
||
|
|
pub fn from_env() -> Self {
|
||
|
|
let base = std::env::var("SPECIAL_DIR")
|
||
|
|
.unwrap_or_else(|_| "special-folders".to_string());
|
||
|
|
Self::new(base)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn new(base: impl Into<PathBuf>) -> Self {
|
||
|
|
Self {
|
||
|
|
base: base.into(),
|
||
|
|
transcodes: Arc::new(Semaphore::new(1)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn folder_dir(&self, folder: &str) -> Option<PathBuf> {
|
||
|
|
let safe = photos::sanitize_user(folder)?;
|
||
|
|
Some(self.base.join(safe))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn media_dir(&self, folder: &str) -> Option<PathBuf> {
|
||
|
|
Some(self.folder_dir(folder)?.join("media"))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn tmp_dir(&self, folder: &str) -> Option<PathBuf> {
|
||
|
|
Some(self.folder_dir(folder)?.join(".tmp"))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn list(&self, folder: &str) -> (Vec<MediaItem>, u64) {
|
||
|
|
let Some(dir) = self.folder_dir(folder) else {
|
||
|
|
return (Vec::new(), 0);
|
||
|
|
};
|
||
|
|
let mut items = Vec::new();
|
||
|
|
let mut total_bytes = 0u64;
|
||
|
|
|
||
|
|
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||
|
|
for entry in rd.flatten() {
|
||
|
|
let path = entry.path();
|
||
|
|
if path.is_file() {
|
||
|
|
if let Ok(meta) = entry.metadata() {
|
||
|
|
total_bytes += meta.len();
|
||
|
|
}
|
||
|
|
if let Some(name) = entry.file_name().to_str() {
|
||
|
|
if photos::valid_id(name) {
|
||
|
|
items.push(MediaItem {
|
||
|
|
id: name.to_string(),
|
||
|
|
media_type: MediaType::Image,
|
||
|
|
status: MediaStatus::Ready,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if let Some(media_dir) = self.media_dir(folder) {
|
||
|
|
if let Ok(rd) = std::fs::read_dir(media_dir) {
|
||
|
|
for entry in rd.flatten() {
|
||
|
|
let path = entry.path();
|
||
|
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if let Ok(bytes) = std::fs::read(&path) {
|
||
|
|
if let Ok(meta) = serde_json::from_slice::<VideoMeta>(&bytes) {
|
||
|
|
let item = MediaItem {
|
||
|
|
id: meta.id.clone(),
|
||
|
|
media_type: MediaType::Video,
|
||
|
|
status: meta.status,
|
||
|
|
};
|
||
|
|
if let Some(parent) = path.parent().and_then(|p| p.parent()) {
|
||
|
|
let output = parent.join(&meta.output_file);
|
||
|
|
if let Ok(meta) = std::fs::metadata(output) {
|
||
|
|
total_bytes += meta.len();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
items.push(item);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
items.sort_by(|a, b| a.id.cmp(&b.id));
|
||
|
|
(items, total_bytes)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn count_items(&self, folder: &str, decoration_count: usize) -> usize {
|
||
|
|
let _ = MAX_ITEMS;
|
||
|
|
self.list(folder).0.len() + decoration_count
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn save_image_bytes(&self, folder: &str, ext: &str, bytes: &[u8]) -> Result<MediaItem, MediaError> {
|
||
|
|
if bytes.len() > MAX_BYTES {
|
||
|
|
return Err(MediaError::TooLarge);
|
||
|
|
}
|
||
|
|
if !matches!(ext, "jpg" | "png" | "gif" | "webp") {
|
||
|
|
return Err(MediaError::BadType);
|
||
|
|
}
|
||
|
|
let dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?;
|
||
|
|
std::fs::create_dir_all(&dir).map_err(|_| MediaError::Io)?;
|
||
|
|
let id = format!("{}.{}", new_stem(), ext);
|
||
|
|
std::fs::write(dir.join(&id), bytes).map_err(|_| MediaError::Io)?;
|
||
|
|
Ok(MediaItem {
|
||
|
|
id,
|
||
|
|
media_type: MediaType::Image,
|
||
|
|
status: MediaStatus::Ready,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn begin_video_upload(&self, folder: &str, ext: &str, original_name: &str) -> Result<VideoJob, MediaError> {
|
||
|
|
if !matches!(ext, "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv") {
|
||
|
|
return Err(MediaError::BadType);
|
||
|
|
}
|
||
|
|
let folder_dir = self.folder_dir(folder).ok_or(MediaError::BadFolder)?;
|
||
|
|
let media_dir = self.media_dir(folder).ok_or(MediaError::BadFolder)?;
|
||
|
|
let tmp_dir = self.tmp_dir(folder).ok_or(MediaError::BadFolder)?;
|
||
|
|
std::fs::create_dir_all(&folder_dir).map_err(|_| MediaError::Io)?;
|
||
|
|
std::fs::create_dir_all(&media_dir).map_err(|_| MediaError::Io)?;
|
||
|
|
std::fs::create_dir_all(&tmp_dir).map_err(|_| MediaError::Io)?;
|
||
|
|
|
||
|
|
let id = new_stem();
|
||
|
|
let input_path = tmp_dir.join(format!("{id}.{ext}"));
|
||
|
|
let output_path = folder_dir.join(format!("{id}.mp4"));
|
||
|
|
let meta_path = media_dir.join(format!("{id}.json"));
|
||
|
|
let meta = VideoMeta {
|
||
|
|
id: id.clone(),
|
||
|
|
media_type: MediaType::Video,
|
||
|
|
status: MediaStatus::Processing,
|
||
|
|
original_name: original_name.to_string(),
|
||
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||
|
|
output_file: format!("{id}.mp4"),
|
||
|
|
error: None,
|
||
|
|
};
|
||
|
|
write_meta(&meta_path, &meta)?;
|
||
|
|
Ok(VideoJob {
|
||
|
|
folder: folder.to_string(),
|
||
|
|
id,
|
||
|
|
input_path,
|
||
|
|
output_path,
|
||
|
|
meta_path,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn finish_video_upload(&self, job: VideoJob) -> Result<MediaItem, MediaError> {
|
||
|
|
self.spawn_transcode(job.clone());
|
||
|
|
Ok(MediaItem {
|
||
|
|
id: job.id,
|
||
|
|
media_type: MediaType::Video,
|
||
|
|
status: MediaStatus::Processing,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn spawn_transcode(&self, job: VideoJob) {
|
||
|
|
let semaphore = self.transcodes.clone();
|
||
|
|
tokio::spawn(async move {
|
||
|
|
let _permit = match semaphore.acquire_owned().await {
|
||
|
|
Ok(permit) => permit,
|
||
|
|
Err(_) => return,
|
||
|
|
};
|
||
|
|
let args = ffmpeg_args(&job.input_path, &job.output_path);
|
||
|
|
let status = Command::new("ffmpeg").args(&args).status().await;
|
||
|
|
match status {
|
||
|
|
Ok(code) if code.success() => {
|
||
|
|
update_video_status(&job.meta_path, MediaStatus::Ready, None);
|
||
|
|
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||
|
|
}
|
||
|
|
Ok(code) => {
|
||
|
|
update_video_status(
|
||
|
|
&job.meta_path,
|
||
|
|
MediaStatus::Failed,
|
||
|
|
Some(format!("ffmpeg exited with status {code}")),
|
||
|
|
);
|
||
|
|
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||
|
|
let _ = tokio::fs::remove_file(&job.output_path).await;
|
||
|
|
}
|
||
|
|
Err(err) => {
|
||
|
|
update_video_status(&job.meta_path, MediaStatus::Failed, Some(err.to_string()));
|
||
|
|
let _ = tokio::fs::remove_file(&job.input_path).await;
|
||
|
|
let _ = tokio::fs::remove_file(&job.output_path).await;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn read(&self, folder: &str, id: &str) -> Option<(&'static str, Vec<u8>)> {
|
||
|
|
let dir = self.folder_dir(folder)?;
|
||
|
|
if photos::valid_id(id) {
|
||
|
|
let bytes = std::fs::read(dir.join(id)).ok()?;
|
||
|
|
let ext = id.rsplit_once('.').map(|(_, ext)| ext)?;
|
||
|
|
return Some((mime_for_image_ext(ext), bytes));
|
||
|
|
}
|
||
|
|
if valid_video_id(id) {
|
||
|
|
let meta = self.video_meta(folder, id)?;
|
||
|
|
if meta.status != MediaStatus::Ready {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let bytes = std::fs::read(dir.join(meta.output_file)).ok()?;
|
||
|
|
return Some(("video/mp4", bytes));
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn status(&self, folder: &str, id: &str) -> Option<MediaItem> {
|
||
|
|
if photos::valid_id(id) {
|
||
|
|
let dir = self.folder_dir(folder)?;
|
||
|
|
if dir.join(id).is_file() {
|
||
|
|
return Some(MediaItem {
|
||
|
|
id: id.to_string(),
|
||
|
|
media_type: MediaType::Image,
|
||
|
|
status: MediaStatus::Ready,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let meta = self.video_meta(folder, id)?;
|
||
|
|
Some(MediaItem {
|
||
|
|
id: meta.id,
|
||
|
|
media_type: MediaType::Video,
|
||
|
|
status: meta.status,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn delete(&self, folder: &str, id: &str) -> bool {
|
||
|
|
let Some(dir) = self.folder_dir(folder) else {
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
if photos::valid_id(id) {
|
||
|
|
return std::fs::remove_file(dir.join(id)).is_ok();
|
||
|
|
}
|
||
|
|
if !valid_video_id(id) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
let meta = self.video_meta(folder, id);
|
||
|
|
let mut removed = false;
|
||
|
|
if let Some(meta) = meta {
|
||
|
|
removed |= std::fs::remove_file(dir.join(meta.output_file)).is_ok();
|
||
|
|
}
|
||
|
|
if let Some(tmp_dir) = self.tmp_dir(folder) {
|
||
|
|
if let Ok(rd) = std::fs::read_dir(tmp_dir) {
|
||
|
|
for entry in rd.flatten() {
|
||
|
|
if let Some(name) = entry.file_name().to_str() {
|
||
|
|
if name.starts_with(id) {
|
||
|
|
removed |= std::fs::remove_file(entry.path()).is_ok();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(media_dir) = self.media_dir(folder) {
|
||
|
|
removed |= std::fs::remove_file(media_dir.join(format!("{id}.json"))).is_ok();
|
||
|
|
}
|
||
|
|
removed
|
||
|
|
}
|
||
|
|
|
||
|
|
fn video_meta(&self, folder: &str, id: &str) -> Option<VideoMeta> {
|
||
|
|
if !valid_video_id(id) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let path = self.media_dir(folder)?.join(format!("{id}.json"));
|
||
|
|
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn classify_upload(content_type: Option<&str>, filename: Option<&str>) -> Option<UploadKind> {
|
||
|
|
let mime = content_type.unwrap_or("").to_ascii_lowercase();
|
||
|
|
match mime.as_str() {
|
||
|
|
"image/jpeg" => return Some(UploadKind::Image("jpg")),
|
||
|
|
"image/png" => return Some(UploadKind::Image("png")),
|
||
|
|
"image/gif" => return Some(UploadKind::Image("gif")),
|
||
|
|
"image/webp" => return Some(UploadKind::Image("webp")),
|
||
|
|
"video/mp4" => return Some(UploadKind::Video("mp4")),
|
||
|
|
"video/quicktime" => return Some(UploadKind::Video("mov")),
|
||
|
|
"video/webm" => return Some(UploadKind::Video("webm")),
|
||
|
|
"video/x-msvideo" => return Some(UploadKind::Video("avi")),
|
||
|
|
"video/x-matroska" => return Some(UploadKind::Video("mkv")),
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
let ext = filename
|
||
|
|
.and_then(|name| name.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()))?;
|
||
|
|
match ext.as_str() {
|
||
|
|
"jpg" | "jpeg" => Some(UploadKind::Image("jpg")),
|
||
|
|
"png" => Some(UploadKind::Image("png")),
|
||
|
|
"gif" => Some(UploadKind::Image("gif")),
|
||
|
|
"webp" => Some(UploadKind::Image("webp")),
|
||
|
|
"mp4" => Some(UploadKind::Video("mp4")),
|
||
|
|
"mov" => Some(UploadKind::Video("mov")),
|
||
|
|
"m4v" => Some(UploadKind::Video("m4v")),
|
||
|
|
"webm" => Some(UploadKind::Video("webm")),
|
||
|
|
"avi" => Some(UploadKind::Video("avi")),
|
||
|
|
"mkv" => Some(UploadKind::Video("mkv")),
|
||
|
|
_ => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn valid_video_id(id: &str) -> bool {
|
||
|
|
id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn ffmpeg_args(input: &Path, output: &Path) -> Vec<String> {
|
||
|
|
vec![
|
||
|
|
"-y".to_string(),
|
||
|
|
"-i".to_string(),
|
||
|
|
input.to_string_lossy().to_string(),
|
||
|
|
"-vf".to_string(),
|
||
|
|
"scale='min(iw,if(gte(iw,ih),1280,720))':'min(ih,if(gte(iw,ih),720,1280))':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=24".to_string(),
|
||
|
|
"-c:v".to_string(),
|
||
|
|
"libx264".to_string(),
|
||
|
|
"-preset".to_string(),
|
||
|
|
"veryfast".to_string(),
|
||
|
|
"-crf".to_string(),
|
||
|
|
"30".to_string(),
|
||
|
|
"-maxrate".to_string(),
|
||
|
|
"900k".to_string(),
|
||
|
|
"-bufsize".to_string(),
|
||
|
|
"1800k".to_string(),
|
||
|
|
"-c:a".to_string(),
|
||
|
|
"aac".to_string(),
|
||
|
|
"-b:a".to_string(),
|
||
|
|
"96k".to_string(),
|
||
|
|
"-movflags".to_string(),
|
||
|
|
"+faststart".to_string(),
|
||
|
|
output.to_string_lossy().to_string(),
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_meta(path: &Path, meta: &VideoMeta) -> Result<(), MediaError> {
|
||
|
|
let bytes = serde_json::to_vec_pretty(meta).map_err(|_| MediaError::Io)?;
|
||
|
|
std::fs::write(path, bytes).map_err(|_| MediaError::Io)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn update_video_status(path: &Path, status: MediaStatus, error: Option<String>) {
|
||
|
|
let Ok(bytes) = std::fs::read(path) else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
let Ok(mut meta) = serde_json::from_slice::<VideoMeta>(&bytes) else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
meta.status = status;
|
||
|
|
meta.error = error.map(|msg| msg.chars().take(240).collect());
|
||
|
|
let _ = write_meta(path, &meta);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn mime_for_image_ext(ext: &str) -> &'static str {
|
||
|
|
match ext {
|
||
|
|
"jpg" => "image/jpeg",
|
||
|
|
"png" => "image/png",
|
||
|
|
"gif" => "image/gif",
|
||
|
|
"webp" => "image/webp",
|
||
|
|
_ => "application/octet-stream",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn new_stem() -> String {
|
||
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
|
|
let nanos = SystemTime::now()
|
||
|
|
.duration_since(UNIX_EPOCH)
|
||
|
|
.map(|duration| duration.as_nanos())
|
||
|
|
.unwrap_or(0);
|
||
|
|
let next = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||
|
|
let digest = Sha256::digest(format!("{nanos}-{next}").as_bytes());
|
||
|
|
digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn classify_upload_accepts_common_images_and_videos() {
|
||
|
|
assert_eq!(classify_upload(Some("image/jpeg"), Some("a.jpg")), Some(UploadKind::Image("jpg")));
|
||
|
|
assert_eq!(classify_upload(Some("image/png"), Some("a.png")), Some(UploadKind::Image("png")));
|
||
|
|
assert_eq!(classify_upload(Some("video/mp4"), Some("clip.mp4")), Some(UploadKind::Video("mp4")));
|
||
|
|
assert_eq!(classify_upload(Some("video/quicktime"), Some("clip.mov")), Some(UploadKind::Video("mov")));
|
||
|
|
assert_eq!(classify_upload(Some("application/octet-stream"), Some("clip.mkv")), Some(UploadKind::Video("mkv")));
|
||
|
|
assert_eq!(classify_upload(Some("text/plain"), Some("note.txt")), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn valid_video_id_is_lower_hex_stem_only() {
|
||
|
|
assert!(valid_video_id("a1b2c3d4e5f60708"));
|
||
|
|
assert!(!valid_video_id("a1b2.mp4"));
|
||
|
|
assert!(!valid_video_id("../a1b2c3d4e5f60708"));
|
||
|
|
assert!(!valid_video_id("A1b2c3d4e5f60708"));
|
||
|
|
assert!(!valid_video_id(""));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ffmpeg_args_match_low_bandwidth_target() {
|
||
|
|
let args = ffmpeg_args(Path::new("/tmp/in.mov"), Path::new("/tmp/out.mp4"));
|
||
|
|
let joined = args.join(" ");
|
||
|
|
assert!(joined.contains("-vf"));
|
||
|
|
assert!(joined.contains("fps=24"));
|
||
|
|
assert!(joined.contains("-maxrate 900k"));
|
||
|
|
assert!(joined.contains("-bufsize 1800k"));
|
||
|
|
assert!(joined.contains("-b:a 96k"));
|
||
|
|
assert!(joined.contains("+faststart"));
|
||
|
|
assert!(joined.contains("force_original_aspect_ratio=decrease"));
|
||
|
|
assert!(joined.contains("force_divisible_by=2"));
|
||
|
|
assert!(joined.contains("/tmp/in.mov"));
|
||
|
|
assert!(joined.contains("/tmp/out.mp4"));
|
||
|
|
}
|
||
|
|
}
|