use serde::Serialize;
use std::path::PathBuf;
use crate::error::AppError;
use crate::output::{self, Ctx};
#[derive(Serialize)]
struct PrepResult {
input: String,
output: String,
recipe: &'static str,
width: u32,
hint: &'static str,
}
pub fn run(
ctx: Ctx,
input: PathBuf,
output_path: Option<PathBuf>,
bw: bool,
width: u32,
) -> Result<(), AppError> {
if !input.exists() {
return Err(AppError::InvalidInput(format!(
"file not found: {}",
input.display()
)));
}
if !(256..=1024).contains(&width) {
return Err(AppError::InvalidInput(
"--width must be in [256, 1024]. 512 is the proven passing value.".into(),
));
}
let magick = which::which("magick").or_else(|_| which::which("convert"));
let magick = match magick {
Ok(p) => p,
Err(_) => {
return Err(AppError::Config(
"ImageMagick (`magick` or `convert`) not on PATH. Install with: brew install imagemagick"
.into(),
));
}
};
let out_path = output_path.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 recipe_label = if bw { "bw-grain" } else { "heavy-grain-color" };
output::info(
ctx,
&format!(
"applying {recipe_label} recipe ({}x{}) via {}",
width,
"auto",
magick.display()
),
);
let mut cmd = std::process::Command::new(&magick);
cmd.arg(&input);
cmd.arg("-resize").arg(format!("{width}x"));
if bw {
cmd.args([
"-modulate",
"100,55,100",
"-attenuate",
"0.8",
"+noise",
"Gaussian",
"-unsharp",
"0x1",
"-colorspace",
"Gray",
"-separate",
"-combine",
]);
} else {
cmd.args([
"-attenuate",
"1.4",
"+noise",
"Gaussian",
"-unsharp",
"0x1",
"-modulate",
"100,90,100",
]);
}
cmd.arg(&out_path);
let status = cmd
.status()
.map_err(|e| AppError::Transient(format!("failed to spawn ImageMagick: {e}")))?;
if !status.success() {
return Err(AppError::Transient(format!(
"ImageMagick exited {}",
status.code().unwrap_or(-1)
)));
}
if !out_path.exists() {
return Err(AppError::Transient(format!(
"ImageMagick completed but did not write {}",
out_path.display()
)));
}
let result = PrepResult {
input: input.display().to_string(),
output: out_path.display().to_string(),
recipe: recipe_label,
width,
hint: "pass to: seedance generate --first-frame <path> --prompt '...' --wait",
};
output::print_success_or(ctx, &result, |r| {
use owo_colors::OwoColorize;
println!("{} {}", "prepped:".bold(), r.output.green());
println!("recipe: {}", r.recipe.cyan());
println!("next: seedance generate --first-frame {} ...", r.output);
});
Ok(())
}
fn default_output(input: &std::path::Path) -> PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
input.display().to_string().hash(&mut h);
let hash = format!("{:08x}", h.finish());
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
home.join("Documents")
.join("seedance")
.join(format!("prep-face-{hash}.png"))
}