use serde::Serialize;
use std::path::PathBuf;
use crate::error::AppError;
use crate::output::{self, Ctx};
const HARD_DURATION_CAP_SECS: f64 = 14.5;
#[derive(Serialize)]
struct WrapResult {
input: String,
output: String,
height: u32,
background: String,
duration_cap_secs: f64,
#[serde(skip_serializing_if = "Option::is_none")]
uploaded_url: Option<String>,
hint: &'static str,
}
pub fn run(
ctx: Ctx,
input: PathBuf,
output: Option<PathBuf>,
background: String,
height: u32,
upload: bool,
) -> Result<(), AppError> {
if !input.exists() {
return Err(AppError::InvalidInput(format!(
"audio file not found: {}",
input.display()
)));
}
if which::which("ffmpeg").is_err() {
return Err(AppError::Config(
"ffmpeg is not on PATH. Install with: brew install ffmpeg (macOS) / apt install ffmpeg (linux)".into(),
));
}
let bg = match background.to_ascii_lowercase().as_str() {
"black" => "black",
"white" => "white",
other => {
return Err(AppError::InvalidInput(format!(
"--background must be 'black' or 'white'; got '{other}'"
)));
}
};
if !(height == 480 || height == 720) {
return Err(AppError::InvalidInput(format!(
"--height must be 480 or 720; got {height}"
)));
}
let out_path = output.unwrap_or_else(|| default_output(&input));
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let (w, h) = if height == 480 {
(864, 480)
} else {
(1280, 720)
};
output::info(
ctx,
&format!(
"wrapping {} -> {} ({}x{} {} bg, <= {} sec)",
input.display(),
out_path.display(),
w,
h,
bg,
HARD_DURATION_CAP_SECS
),
);
let color_src = format!("color=c={bg}:s={w}x{h}:r=24");
let dur = format!("{}", HARD_DURATION_CAP_SECS);
let mut cmd = std::process::Command::new("ffmpeg");
cmd.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
&color_src,
"-i",
])
.arg(&input)
.args([
"-t",
&dur,
"-c:v",
"libx264",
"-tune",
"stillimage",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
])
.arg(&out_path);
let status = cmd
.status()
.map_err(|e| AppError::Transient(format!("failed to spawn ffmpeg: {e}")))?;
if !status.success() {
return Err(AppError::Transient(format!(
"ffmpeg exited with code {}",
status.code().unwrap_or(-1)
)));
}
if !out_path.exists() {
return Err(AppError::Transient(format!(
"ffmpeg completed but {} was not created",
out_path.display()
)));
}
let uploaded_url = if upload {
Some(upload_to_tmpfiles(&out_path)?)
} else {
None
};
let result = WrapResult {
input: input.display().to_string(),
output: out_path.display().to_string(),
height,
background: bg.into(),
duration_cap_secs: HARD_DURATION_CAP_SECS,
uploaded_url: uploaded_url.clone(),
hint: if uploaded_url.is_some() {
"URL is ready -- pass as --video <url>"
} else {
"host this mp4 publicly (tmpfiles.org / S3) and pass as --video <url>; or re-run with --upload"
},
};
output::print_success_or(ctx, &result, |r| {
use owo_colors::OwoColorize;
println!("{} {}", "wrapped:".bold(), r.output.green());
if let Some(u) = &r.uploaded_url {
println!("{} {}", "url:".bold(), u.cyan());
println!("next: seedance generate --video {u} ...");
} else {
println!(
"next: upload publicly (e.g. `seedance upload {}`), pass as {}",
r.output,
"--video <url>".cyan()
);
println!(
"note: {}",
"BytePlus rejects catbox.moe URLs; tmpfiles.org works".dimmed()
);
}
});
Ok(())
}
fn upload_to_tmpfiles(path: &std::path::Path) -> Result<String, AppError> {
let http = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(AppError::from)?;
let form = reqwest::blocking::multipart::Form::new()
.file("file", path)
.map_err(|e| AppError::Transient(e.to_string()))?;
let resp = http
.post("https://tmpfiles.org/api/v1/upload")
.multipart(form)
.send()?;
let body = resp.text().unwrap_or_default();
#[derive(serde::Deserialize)]
struct Envelope {
status: String,
data: Option<Data>,
}
#[derive(serde::Deserialize)]
struct Data {
url: String,
}
let env: Envelope = serde_json::from_str(&body)
.map_err(|e| AppError::Transient(format!("tmpfiles response not JSON: {e} -- {body}")))?;
if env.status != "success" {
return Err(AppError::Transient(format!(
"tmpfiles returned status={} body={body}",
env.status
)));
}
let raw = env.data.map(|d| d.url).unwrap_or_default();
Ok(raw
.replacen("http://tmpfiles.org/", "https://tmpfiles.org/dl/", 1)
.replacen("https://tmpfiles.org/", "https://tmpfiles.org/dl/", 1)
.replace("/dl/dl/", "/dl/"))
}
fn default_output(input: &std::path::Path) -> PathBuf {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "audio".into());
let parent = input.parent().unwrap_or(std::path::Path::new("."));
parent.join(format!("{stem}.silent.mp4"))
}