use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(
name = "seedance",
version,
about = "Generate video with ByteDance Seedance 2.0 from the terminal.",
long_about = "Generate video with ByteDance Seedance 2.0 via the BytePlus ModelArk API.
Supports text-to-video, image-to-video (first / first+last / up to 9 reference images),
reference videos, reference audio, and multimodal mixes. Use time-coded prompts like
`[Image 1] ... [Video 1] ... [Audio 1] ...` and `[0-4s]: shot description` for multi-shot control.",
after_long_help = HELP_FOOTER,
)]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub quiet: bool,
#[command(subcommand)]
pub command: Commands,
}
const HELP_FOOTER: &str = "\
Tips:
* Run `seedance agent-info | jq` for the full capability manifest
* Get an API key from https://console.byteplus.com/ark, then save it with:
seedance config set api-key ark-xxxxxxxx (stored at chmod 600, never echoed)
or export SEEDANCE_API_KEY / ARK_API_KEY
* Reference files: images and audio can be local paths (base64-encoded inline) OR URLs
* Videos must be URLs -- the API does not accept base64 for video
* Audio alone is not allowed -- Seedance requires at least one image or video alongside audio
* Known quirk: uploading audio mutates lyrics. Workaround: render a silent MP4 with the audio
baked in, then pass it as --video (credit: @simeonnz via @MrDavids1)
* Use --wait to block until the task finishes and download the result in one command
* Real human faces in references are blocked -- use faces from previously generated Seedance videos
* Default output path: ~/Documents/seedance[/<project>]/<timestamp>-[<label>-]<short-id>.mp4
Override with -o /path/to/file.mp4 (honoured verbatim) or -o /some/dir/ (parent dir only;
the timestamp+label+short-id filename still applies inside it).
* Every --wait run writes a sibling <file>.seedance.json sidecar with the full prompt,
refs, model, seed, and task id so agents can audit which clip came from which prompt.
* For a consistent person: `seedance character-sheet <photo>` (uses nanaban), then
pass the resulting PNG as --image. Bypasses Seedance's single-face upload block.
* For exact music/dialogue: `seedance audio-to-video <audio>` (uses ffmpeg) -> host the
resulting mp4 -> pass as --video. Preserves lyrics that --audio would otherwise rewrite.
Examples:
seedance generate --prompt \"A cat yawns at the camera\" --wait --output cat.mp4
Text-to-video, blocks until the mp4 lands on disk
seedance generate --prompt \"[Image 1] the boy waves\" --image boy.png --duration 8 --wait -o out.mp4
Single reference image + prompt, 8 seconds, wait and download
seedance generate --first-frame first.png --last-frame last.png --prompt \"morph between them\"
First+last frame mode -- returns a task id, poll separately
seedance generate --prompt \"...\" --image a.png --image b.png --video ref.mp4 --fast --wait -o out.mp4
Multimodal reference-to-video using the fast tier
seedance status cgt-20260416-abcd1234
Poll a task; prints video_url when succeeded
seedance download cgt-20260416-abcd1234 --output final.mp4
Download the video for a completed task
seedance doctor
Verify API key + base URL reachability before running a real generation";
#[derive(Clone, Copy, ValueEnum, serde::Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Resolution {
#[value(name = "480p")]
P480,
#[value(name = "720p")]
P720,
}
impl Resolution {
pub fn as_api(&self) -> &'static str {
match self {
Self::P480 => "480p",
Self::P720 => "720p",
}
}
}
#[derive(Clone, Copy, ValueEnum, serde::Serialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum Ratio {
#[value(name = "16:9")]
Sixteen9,
#[value(name = "4:3")]
Four3,
#[value(name = "1:1")]
One1,
#[value(name = "3:4")]
Three4,
#[value(name = "9:16")]
Nine16,
#[value(name = "21:9")]
TwentyOne9,
Adaptive,
}
impl Ratio {
pub fn as_api(&self) -> &'static str {
match self {
Self::Sixteen9 => "16:9",
Self::Four3 => "4:3",
Self::One1 => "1:1",
Self::Three4 => "3:4",
Self::Nine16 => "9:16",
Self::TwentyOne9 => "21:9",
Self::Adaptive => "adaptive",
}
}
}
#[derive(Subcommand)]
pub enum Commands {
#[command(visible_alias = "gen")]
Generate(Box<GenerateArgs>),
#[command(visible_alias = "get")]
Status {
id: String,
#[arg(long, env = "SEEDANCE_API_KEY", hide_env_values = true)]
api_key: Option<String>,
},
Download {
id: String,
#[arg(long, short = 'o')]
output: Option<std::path::PathBuf>,
#[arg(long, env = "SEEDANCE_API_KEY", hide_env_values = true)]
api_key: Option<String>,
},
#[command(visible_alias = "rm")]
Cancel {
id: String,
#[arg(long, env = "SEEDANCE_API_KEY", hide_env_values = true)]
api_key: Option<String>,
},
CharacterSheet {
input: String,
#[arg(short = 'o', long)]
output: Option<std::path::PathBuf>,
#[arg(long)]
style: Option<String>,
#[arg(long, default_value_t = 9)]
angles: u8,
#[arg(long, value_name = "NAME")]
character: Option<String>,
#[arg(long, value_name = "NAME")]
project: Option<String>,
},
AudioToVideo {
input: std::path::PathBuf,
#[arg(short = 'o', long)]
output: Option<std::path::PathBuf>,
#[arg(long, default_value = "black")]
background: String,
#[arg(long, default_value_t = 480)]
height: u32,
#[arg(long)]
upload: bool,
},
PrepFace {
input: std::path::PathBuf,
#[arg(short = 'o', long)]
output: Option<std::path::PathBuf>,
#[arg(long)]
bw: bool,
#[arg(long, default_value_t = 512)]
width: u32,
},
Upload {
input: std::path::PathBuf,
},
#[command(visible_alias = "ls")]
Models,
Doctor,
#[command(visible_alias = "info")]
AgentInfo,
Skill {
#[command(subcommand)]
action: SkillAction,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Update {
#[arg(long)]
check: bool,
},
}
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(long, short = 'p')]
pub prompt: Option<String>,
#[arg(long = "image", short = 'i', value_name = "PATH|URL")]
pub images: Vec<String>,
#[arg(long, value_name = "PATH|URL", conflicts_with = "images")]
pub first_frame: Option<String>,
#[arg(
long,
value_name = "PATH|URL",
requires = "first_frame",
conflicts_with = "images"
)]
pub last_frame: Option<String>,
#[arg(long = "video", short = 'v', value_name = "URL")]
pub videos: Vec<String>,
#[arg(long = "audio", short = 'a', value_name = "PATH|URL")]
pub audio: Vec<String>,
#[arg(long, short = 'd', default_value_t = 5, allow_hyphen_values = true)]
pub duration: i32,
#[arg(long, short = 'r', value_enum, default_value = "720p")]
pub resolution: Resolution,
#[arg(long, value_enum, default_value = "adaptive")]
pub ratio: Ratio,
#[arg(long, default_value_t = -1, allow_hyphen_values = true)]
pub seed: i64,
#[arg(
long = "audio-sync",
default_value_t = true,
overrides_with = "no_audio_sync"
)]
pub audio_sync: bool,
#[arg(long = "no-audio-sync", default_value_t = false)]
pub no_audio_sync: bool,
#[arg(long)]
pub watermark: bool,
#[arg(long, conflicts_with = "model")]
pub fast: bool,
#[arg(long)]
pub model: Option<String>,
#[arg(long, value_name = "URL")]
pub callback_url: Option<String>,
#[arg(long, value_name = "ID")]
pub safety_identifier: Option<String>,
#[arg(long, short = 'w')]
pub wait: bool,
#[arg(long, short = 'o', value_name = "PATH")]
pub output: Option<std::path::PathBuf>,
#[arg(long, default_value_t = 5)]
pub poll_interval: u64,
#[arg(long, default_value_t = 900)]
pub timeout: u64,
#[arg(long, env = "SEEDANCE_API_KEY", hide_env_values = true)]
pub api_key: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long, value_name = "LABEL")]
pub label: Option<String>,
#[arg(long, value_name = "NAME")]
pub project: Option<String>,
}
#[derive(Subcommand)]
pub enum SkillAction {
Install,
Status,
}
#[derive(Subcommand)]
pub enum ConfigAction {
Show,
Path,
Set {
#[arg(value_enum)]
key: ConfigKey,
value: String,
},
Unset {
#[arg(value_enum)]
key: ConfigKey,
},
}
#[derive(Clone, Copy, ValueEnum, Debug)]
#[value(rename_all = "kebab-case")]
pub enum ConfigKey {
ApiKey,
BaseUrl,
Model,
}
impl ConfigKey {
pub fn as_str(&self) -> &'static str {
match self {
Self::ApiKey => "api_key",
Self::BaseUrl => "base_url",
Self::Model => "model",
}
}
}