seedance 0.2.0

Generate video with ByteDance Seedance 2.0 from the terminal. Agent-friendly.
/// Apply the empirical face-filter-bypass recipe discovered 2026-04-16.
///
/// BytePlus Seedance 2.0 blocks `InputImageSensitiveContentDetected.PrivacyInformation`
/// on anything that reads as a real-person photo. Across ~10 attempts against a
/// single test portrait we found two recipes that slip the filter while keeping
/// identity recognisable:
///
/// 1. Heavy-grain colour (default here):
///    `magick <in> -resize 512x -attenuate 1.4 +noise Gaussian -unsharp 0x1 -modulate 100,90,100 <out>`
///    Keeps colour. Adds significant gaussian grain + 10% desat + downscale.
///    PASSES the filter; face remains recognisable across the output clip.
///
/// 2. Black-and-white + grain (`--bw`):
///    `magick <in> -resize 512x -modulate 100,55,100 -attenuate 0.8 +noise Gaussian -unsharp 0x1 -colorspace Gray -separate -combine <out>`
///    No colour. Also PASSES. Output video will be B&W because first-frame
///    tonal grade propagates through the clip.
///
/// Recipes that DID NOT pass (for reference):
/// - Nano Banana Pro photographic close-up at native res
/// - GPT Image close-up
/// - Low-res (480px) + mild grain (too weak)
/// - Oil-paint filter (magick -paint)
/// - Cinema-crush color grade
/// - Cross-processed teal-orange grade
/// - 9-panel character sheet (either color or B&W -- multiple faces trigger harder)
///
/// The filter appears to fire on face geometry + "photograph-like" texture cues;
/// reducing resolution + adding dense gaussian noise breaks the texture signal
/// while preserving face geometry for identity.
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"))
}