Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion rsky-video/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ urlencoding = "2"
cid = "0.11"
multihash-codetable = { version = "0.1", features = ["sha2"] }

# ffmpeg subprocess scratch dirs (MOV remux)
tempfile = "3"

# AT Protocol client (atrium from crates.io)
atrium-api = { version = "0.25", features = ["agent"] }
atrium-xrpc = "0.12"
Expand All @@ -68,4 +71,3 @@ rsky-syntax = { workspace = true }

[dev-dependencies]
mockito = "1.7.0"
tempfile = "3"
5 changes: 5 additions & 0 deletions rsky-video/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub struct AppConfig {
pub daily_video_limit: u32,
/// Daily byte upload limit per user (default: 10GB)
pub daily_byte_limit: u64,

/// Path to ffmpeg binary for MOV->MP4 remuxing.
pub ffmpeg_path: String,
}

impl AppConfig {
Expand Down Expand Up @@ -74,6 +77,8 @@ impl AppConfig {
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_737_418_240), // 10GB

ffmpeg_path: env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
})
}
}
10 changes: 10 additions & 0 deletions rsky-video/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub enum Error {

#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),

#[error("Transcode failed: {0}")]
TranscodeFailed(String),
}

impl IntoResponse for Error {
Expand Down Expand Up @@ -91,6 +94,13 @@ impl IntoResponse for Error {
tracing::error!("JSON error: {}", e);
(StatusCode::BAD_REQUEST, "Invalid JSON".to_string())
}
Error::TranscodeFailed(msg) => {
tracing::error!("Transcode failed: {}", msg);
(
StatusCode::UNPROCESSABLE_ENTITY,
format!("Could not process video: {}", msg),
)
}
};

let body = Json(json!({
Expand Down
1 change: 1 addition & 0 deletions rsky-video/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod db;
mod error;
mod pds;
mod signing;
mod transcode;
mod xrpc;

pub use config::AppConfig;
Expand Down
230 changes: 230 additions & 0 deletions rsky-video/src/transcode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
//! Video transcoding via ffmpeg subprocess (GIF re-encode, MOV remux).

use bytes::Bytes;
use tracing::{debug, info};

use crate::error::{Error, Result};

/// GIF87a and GIF89a magic bytes
const GIF_MAGIC_87A: &[u8] = b"GIF87a";
const GIF_MAGIC_89A: &[u8] = b"GIF89a";

/// Check if a file needs conversion before uploading to Bunny.
/// Returns true for GIF files (detected by both extension and magic bytes).
pub fn needs_conversion(filename: &str, data: &[u8]) -> bool {
let ext_match = filename
.rsplit('.')
.next()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gif"));

let magic_match =
data.len() >= 6 && (data.starts_with(GIF_MAGIC_87A) || data.starts_with(GIF_MAGIC_89A));

ext_match && magic_match
}

/// Detect MIME type from filename extension.
pub fn detect_mime_type(filename: &str) -> &'static str {
let ext = filename
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"gif" => "image/gif",
"mov" => "video/quicktime",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
_ => "video/mp4",
}
}

/// Convert GIF bytes to MP4 via ffmpeg subprocess.
///
/// Uses `-movflags +faststart` for streaming, `-pix_fmt yuv420p` for
/// broad compatibility, and scales to even dimensions (required by yuv420p).
pub async fn convert_gif_to_mp4(ffmpeg_path: &str, data: Bytes) -> Result<Bytes> {
let temp_dir = tempfile::tempdir()
.map_err(|e| Error::TranscodeFailed(format!("Failed to create temp dir: {}", e)))?;

let input_path = temp_dir.path().join("input.gif");
let output_path = temp_dir.path().join("output.mp4");

debug!("Converting GIF ({} bytes) to MP4 via ffmpeg", data.len());

tokio::fs::write(&input_path, &data)
.await
.map_err(|e| Error::TranscodeFailed(format!("Failed to write temp input: {}", e)))?;

let output = tokio::process::Command::new(ffmpeg_path)
.args([
"-i",
input_path.to_str().unwrap(),
"-movflags",
"+faststart",
"-pix_fmt",
"yuv420p",
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-y",
output_path.to_str().unwrap(),
])
.output()
.await
.map_err(|e| {
Error::TranscodeFailed(format!(
"Failed to run ffmpeg (is it installed at '{}'?): {}",
ffmpeg_path, e
))
})?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::TranscodeFailed(format!(
"ffmpeg exited with {}: {}",
output.status,
stderr.lines().last().unwrap_or("unknown error")
)));
}

let mp4_bytes = tokio::fs::read(&output_path)
.await
.map_err(|e| Error::TranscodeFailed(format!("Failed to read ffmpeg output: {}", e)))?;

info!(
"GIF converted to MP4: {} bytes -> {} bytes",
data.len(),
mp4_bytes.len()
);

Ok(Bytes::from(mp4_bytes))
}

/// True if the buffer is an ISO BMFF file with a QuickTime brand (`qt `).
pub fn is_quicktime_container(data: &[u8]) -> bool {
data.len() >= 12 && &data[4..8] == b"ftyp" && &data[8..12] == b"qt "
}

/// Remux MOV -> MP4 without re-encoding (stream copy via ffmpeg `-c copy`).
pub async fn convert_mov_to_mp4(ffmpeg_path: &str, data: Bytes) -> Result<Bytes> {
let temp_dir =
tempfile::tempdir().map_err(|e| Error::TranscodeFailed(format!("temp dir: {e}")))?;

let input_path = temp_dir.path().join("input.mov");
let output_path = temp_dir.path().join("output.mp4");

debug!("Remuxing MOV ({} bytes) to MP4 via ffmpeg", data.len());
let start = std::time::Instant::now();

tokio::fs::write(&input_path, &data)
.await
.map_err(|e| Error::TranscodeFailed(format!("write input: {e}")))?;

let output = tokio::process::Command::new(ffmpeg_path)
.args([
"-i",
input_path.to_str().unwrap(),
"-c",
"copy",
"-movflags",
"+faststart",
"-y",
output_path.to_str().unwrap(),
])
.output()
.await
.map_err(|e| Error::TranscodeFailed(format!("run ffmpeg ({}): {e}", ffmpeg_path)))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::TranscodeFailed(format!(
"ffmpeg {}: {}",
output.status,
stderr.lines().last().unwrap_or("unknown error")
)));
}

let mp4_bytes = tokio::fs::read(&output_path)
.await
.map_err(|e| Error::TranscodeFailed(format!("read output: {e}")))?;

info!(
"MOV remuxed to MP4 in {} ms: {} -> {} bytes",
start.elapsed().as_millis(),
data.len(),
mp4_bytes.len()
);

Ok(Bytes::from(mp4_bytes))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_needs_conversion_gif87a() {
let data = b"GIF87a\x00\x00\x00\x00";
assert!(needs_conversion("animation.gif", data));
}

#[test]
fn test_needs_conversion_gif89a() {
let data = b"GIF89a\x01\x00\x01\x00";
assert!(needs_conversion("test.GIF", data));
}

#[test]
fn test_needs_conversion_not_gif_extension() {
let data = b"GIF89a\x01\x00\x01\x00";
assert!(!needs_conversion("video.mp4", data));
}

#[test]
fn test_needs_conversion_not_gif_magic() {
let data = b"\x00\x00\x00\x1cftyp";
assert!(!needs_conversion("file.gif", data));
}

#[test]
fn test_needs_conversion_empty() {
assert!(!needs_conversion("file.gif", &[]));
}

#[test]
fn test_detect_mime_type() {
assert_eq!(detect_mime_type("video.mp4"), "video/mp4");
assert_eq!(detect_mime_type("clip.mov"), "video/quicktime");
assert_eq!(detect_mime_type("anim.gif"), "image/gif");
assert_eq!(detect_mime_type("video.webm"), "video/webm");
assert_eq!(detect_mime_type("unknown.xyz"), "video/mp4");
assert_eq!(detect_mime_type("noext"), "video/mp4");
}

#[test]
fn quicktime_container_iphone_screen_recording() {
// size(4) + "ftyp" + "qt " brand
let data = b"\x00\x00\x00\x14ftypqt \x00\x00\x02\x00";
assert!(is_quicktime_container(data));
}

#[test]
fn mp4_brand_is_not_quicktime() {
// ISO BMFF with mp42 brand -- already valid video/mp4, no remux needed.
let data = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00";
assert!(!is_quicktime_container(data));
}

#[test]
fn isom_brand_is_not_quicktime() {
let data = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00";
assert!(!is_quicktime_container(data));
}

#[test]
fn random_bytes_are_not_quicktime() {
assert!(!is_quicktime_container(b""));
assert!(!is_quicktime_container(b"GIF89a\x00\x00\x00\x00\x00\x00"));
assert!(!is_quicktime_container(b"too-short"));
}
}
17 changes: 16 additions & 1 deletion rsky-video/src/xrpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::{
bunny::WebhookPayload,
db::{self, job_state},
error::{Error, Result},
pds,
pds, transcode,
};

/// Query parameters for getUploadLimits
Expand Down Expand Up @@ -193,6 +193,21 @@ pub async fn upload_video(
let job_id = job.job_id;
info!("Created job: {}", job_id);

// PDS sniffs blob bytes; iPhone .mov gets tagged video/quicktime and the bsky lexicon rejects it.
let body = if transcode::is_quicktime_container(&body) {
info!("Detected QuickTime/MOV container, remuxing to MP4");
match transcode::convert_mov_to_mp4(&state.config.ffmpeg_path, body).await {
Ok(mp4) => mp4,
Err(e) => {
error!("MOV->MP4 remux failed: {}", e);
db::fail_job(&state.db_pool, job_id, &format!("remux failed: {}", e)).await?;
return Err(e);
}
}
} else {
body
};

// STEP 1: Upload blob to user's PDS FIRST
// Forward the client's service auth token to the PDS.
// The token should have aud: user's PDS DID (not video service).
Expand Down
Loading