diff --git a/Cargo.lock b/Cargo.lock index f91e9bb183..8c83e1ec82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11469,15 +11469,18 @@ name = "support-tool" version = "0.0.1" dependencies = [ "anyhow", + "artcraft_api_defs", "artcraft_client", "clap 4.5.60", "easyenv", + "enums", "errors", "log", "seedance2pro_client", "serde_json", "tokens", "tokio", + "uuid 1.22.0", ] [[package]] diff --git a/crates/cli/production/support-tool/CLAUDE.md b/crates/cli/production/support-tool/CLAUDE.md index 287cdd91c8..0e7d14483a 100644 --- a/crates/cli/production/support-tool/CLAUDE.md +++ b/crates/cli/production/support-tool/CLAUDE.md @@ -14,6 +14,7 @@ cargo run -p support-tool -- seedance2pro generate_video --prompt "A corgi at th Requires a `.env-support-tool-secrets` file (or env vars) with: - `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/Seedance2Pro API auth +- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`) ## Architecture @@ -31,15 +32,27 @@ Requires a `.env-support-tool-secrets` file (or env vars) with: 4. Add a variant to `Seedance2proCommand` enum in `dispatch.rs` 5. Add a match arm in `dispatch.rs::run()` +## Utilities + +- `src/utils/parse_video_model.rs` — shared parser for loose video model names → `CommonVideoModel` +- `src/utils/normalize_subcommands.rs` — underscore-insensitive arg normalizer + ## Conventions - Use `log::info!()` for status output, never `println!` (except for final results) - Use `anyhow::anyhow!()` for ad-hoc errors - Get `Seedance2ProSession` from `state.cookies` via `from_cookies_string()` -- External crates used: `artcraft_client` (media file download), `seedance2pro_client` (video gen) +- Get `StorytellerCredentialSet` from `state.creds` (parsed from ARTCRAFT_COOKIES) +- External crates used: `artcraft_client` (media file download, omni API), `seedance2pro_client` (Kinovi direct), `artcraft_api_defs` (request types) ## Subcommands +### seedance2pro (direct Kinovi API) + - `seedance2pro find_job --token ` — search for a job across all pages - `seedance2pro failed_job_histogram` — histogram of failure reasons -- `seedance2pro generate_video --prompt [--model seedance2p0] [--reference_media_tokens "token1,token2"] [--localhost] [--download_path /tmp/media_files]` +- `seedance2pro generate_video --prompt [--model seedance2p0] [--start_frame_media_token mf_xxx] [--localhost] [--download_path /tmp/media_files]` + +### artcraft (omni API) + +- `artcraft generate_video --prompt [--model seedance2p0] [--start_frame_media_token mf_xxx] [--duration 5] [--localhost]` diff --git a/crates/cli/production/support-tool/Cargo.toml b/crates/cli/production/support-tool/Cargo.toml index 6edb49f236..09c7afbafc 100644 --- a/crates/cli/production/support-tool/Cargo.toml +++ b/crates/cli/production/support-tool/Cargo.toml @@ -11,7 +11,9 @@ path = "src/main.rs" easyenv.workspace = true # Internal +artcraft_api_defs.workspace = true artcraft_client.workspace = true +enums.workspace = true errors.workspace = true seedance2pro_client.workspace = true tokens.workspace = true @@ -22,3 +24,4 @@ clap.workspace = true log.workspace = true serde_json.workspace = true tokio.workspace = true +uuid = { version = "1.8.0", features = ["v4"] } diff --git a/crates/cli/production/support-tool/src/commands/artcraft/dispatch.rs b/crates/cli/production/support-tool/src/commands/artcraft/dispatch.rs new file mode 100644 index 0000000000..bbae0e4554 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/artcraft/dispatch.rs @@ -0,0 +1,33 @@ +use anyhow::anyhow; +use clap::Subcommand; + +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + +use super::state::ArtcraftState; +use super::subcommands; + +/// All canonical subcommand names for this module. +/// Used by the underscore-insensitive arg normalizer. +pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"]; + +#[derive(Subcommand)] +#[command(rename_all = "snake_case")] +pub enum ArtcraftCommand { + /// Generate a video via the ArtCraft omni endpoint + GenerateVideo(subcommands::generate_video::GenerateVideoArgs), +} + +pub async fn run(command: ArtcraftCommand) -> anyhow::Result<()> { + let cookies_str = easyenv::get_env_string_required("ARTCRAFT_COOKIES") + .map_err(|err| anyhow!("Missing ARTCRAFT_COOKIES env var: {:?}", err))?; + + let creds = StorytellerCredentialSet::parse_multi_cookie_header(&cookies_str) + .map_err(|err| anyhow!("Failed to parse ARTCRAFT_COOKIES: {:?}", err))? + .ok_or_else(|| anyhow!("ARTCRAFT_COOKIES contained no recognized cookies (expected session=... and/or visitor=...)"))?; + + let state = ArtcraftState { creds }; + + match command { + ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await, + } +} diff --git a/crates/cli/production/support-tool/src/commands/artcraft/mod.rs b/crates/cli/production/support-tool/src/commands/artcraft/mod.rs new file mode 100644 index 0000000000..da32b147e7 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/artcraft/mod.rs @@ -0,0 +1,6 @@ +pub mod dispatch; +mod state; +mod subcommands; + +pub use dispatch::ArtcraftCommand; +pub use dispatch::run; diff --git a/crates/cli/production/support-tool/src/commands/artcraft/state.rs b/crates/cli/production/support-tool/src/commands/artcraft/state.rs new file mode 100644 index 0000000000..470b921185 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/artcraft/state.rs @@ -0,0 +1,5 @@ +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + +pub struct ArtcraftState { + pub creds: StorytellerCredentialSet, +} diff --git a/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs new file mode 100644 index 0000000000..ec85c319f4 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/generate_video.rs @@ -0,0 +1,179 @@ +use std::fs; +use std::path::Path; + +use anyhow::anyhow; +use clap::Args; +use log::info; + +use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest; +use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate; +use artcraft_client::utils::api_host::ApiHost; +use tokens::tokens::media_files::MediaFileToken; + +use crate::utils::parse_video_model::parse_video_model; +use super::super::state::ArtcraftState; + +// ── Args ── + +#[derive(Args)] +#[command( + override_usage = "support-tool artcraft generate_video [OPTIONS] --prompt ", + after_help = "\ +EXAMPLES: + support-tool artcraft generate_video --prompt \"A corgi at the lake\" --model seedance2 + support-tool artcraft generate_video --prompt prompt.txt --model veo3 + support-tool artcraft generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123 + support-tool artcraft generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\" --model happyhorse +", +)] +pub struct GenerateVideoArgs { + /// A prompt string or path to a file containing the prompt (.txt or .md). + #[arg(long)] + pub prompt: String, + + /// A single media token for the start frame image. + #[arg(long)] + pub start_frame_media_token: Option, + + /// A single media token for the end frame image. + #[arg(long)] + pub end_frame_media_token: Option, + + /// Comma or space separated media tokens for image references. + #[arg(long)] + pub image_reference_tokens: Option, + + /// Comma or space separated media tokens for video references. + #[arg(long)] + pub video_reference_tokens: Option, + + /// Comma or space separated media tokens for audio references. + #[arg(long)] + pub audio_reference_tokens: Option, + + /// Use localhost:12345 instead of production API. + #[arg(long)] + pub localhost: bool, + + /// Video model to use. Accepts canonical names (eg. "seedance_2p0") or aliases + /// (eg. "seedance2", "happyhorse", "veo3fast"). [default: seedance2p0] + #[arg(long)] + pub model: Option, + + /// Duration in seconds. [default: 5] + #[arg(long)] + pub duration: Option, + + /// Idempotency token (auto-generated if not specified). + #[arg(long)] + pub idempotency_token: Option, +} + +// ── Entry point ── + +pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Result<()> { + let model_str = args.model.as_deref().unwrap_or("seedance2p0"); + let model = parse_video_model(model_str) + .ok_or_else(|| anyhow!("Unknown model '{}'. Try --help for supported names.", model_str))?; + + let prompt = resolve_prompt(&args.prompt)?; + let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller }; + let duration = args.duration.unwrap_or(5); + + info!("Model: {:?}", model); + info!("Prompt: {:?}", prompt); + info!("Duration: {}s", duration); + + // Parse media tokens. + let start_frame_token = args.start_frame_media_token.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| MediaFileToken::new_from_str(s)); + + let end_frame_token = args.end_frame_media_token.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| MediaFileToken::new_from_str(s)); + + let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref()); + let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref()); + let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref()); + + if let Some(t) = &start_frame_token { info!("Start frame: {}", t.as_str()); } + if let Some(t) = &end_frame_token { info!("End frame: {}", t.as_str()); } + if !image_ref_tokens.is_empty() { info!("Image refs: {:?}", image_ref_tokens.iter().map(|t| t.as_str()).collect::>()); } + if !video_ref_tokens.is_empty() { info!("Video refs: {:?}", video_ref_tokens.iter().map(|t| t.as_str()).collect::>()); } + if !audio_ref_tokens.is_empty() { info!("Audio refs: {:?}", audio_ref_tokens.iter().map(|t| t.as_str()).collect::>()); } + + // Build the omni request. Media tokens are already uploaded — no download/re-upload needed. + let idempotency_token = args.idempotency_token + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let request = OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some(idempotency_token.clone()), + model: Some(model), + prompt: Some(prompt), + negative_prompt: None, + start_frame_image_media_token: start_frame_token, + end_frame_image_media_token: end_frame_token, + reference_image_media_tokens: if image_ref_tokens.is_empty() { None } else { Some(image_ref_tokens) }, + reference_video_media_tokens: if video_ref_tokens.is_empty() { None } else { Some(video_ref_tokens) }, + reference_audio_media_tokens: if audio_ref_tokens.is_empty() { None } else { Some(audio_ref_tokens) }, + reference_character_tokens: None, + resolution: None, + aspect_ratio: None, + quality: None, + duration_seconds: Some(duration), + video_batch_count: None, + generate_audio: None, + }; + + info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token); + + let response = omni_gen_video_generate( + &api_host, + Some(&state.creds), + request, + ).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?; + + info!("Success!"); + info!("Primary job token: {}", response.inference_job_token.as_str()); + info!("All job tokens: {:?}", response.all_job_tokens.iter().map(|t| t.as_str()).collect::>()); + + Ok(()) +} + +// ── Helpers ── + +fn resolve_prompt(input: &str) -> anyhow::Result { + let looks_like_file = input.ends_with(".txt") || input.ends_with(".md"); + + if looks_like_file { + let path = Path::new(input); + if !path.exists() { + return Err(anyhow!("Prompt file does not exist: {:?}", path)); + } + let content = fs::read_to_string(path) + .map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?; + let trimmed = content.trim().to_string(); + info!("Read prompt from file {:?} ({} chars)", path, trimmed.len()); + Ok(trimmed) + } else { + info!("Using prompt string directly ({} chars)", input.len()); + Ok(input.to_string()) + } +} + +fn parse_media_tokens(input: Option<&str>) -> Vec { + let input = match input { + Some(s) if !s.trim().is_empty() => s, + _ => return Vec::new(), + }; + + input + .split(|c: char| c == ',' || c.is_whitespace()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| MediaFileToken::new_from_str(s)) + .collect() +} diff --git a/crates/cli/production/support-tool/src/commands/artcraft/subcommands/mod.rs b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/mod.rs new file mode 100644 index 0000000000..2492e000e2 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/artcraft/subcommands/mod.rs @@ -0,0 +1 @@ +pub mod generate_video; diff --git a/crates/cli/production/support-tool/src/commands/mod.rs b/crates/cli/production/support-tool/src/commands/mod.rs index 61eed3cb95..6f7a370cb8 100644 --- a/crates/cli/production/support-tool/src/commands/mod.rs +++ b/crates/cli/production/support-tool/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod artcraft; pub mod run; pub mod seedance2pro; diff --git a/crates/cli/production/support-tool/src/commands/run.rs b/crates/cli/production/support-tool/src/commands/run.rs index 3b9ccfc542..c89f30e5a0 100644 --- a/crates/cli/production/support-tool/src/commands/run.rs +++ b/crates/cli/production/support-tool/src/commands/run.rs @@ -1,12 +1,14 @@ use clap::{Parser, Subcommand}; +use super::artcraft; use super::seedance2pro; /// All canonical subcommand names across all modules. /// Used by the underscore-insensitive arg normalizer. pub fn all_canonical_names() -> Vec<&'static str> { - let mut names: Vec<&str> = vec!["seedance2pro"]; + let mut names: Vec<&str> = vec!["seedance2pro", "artcraft"]; names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES); + names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES); names } @@ -20,11 +22,17 @@ pub struct Cli { #[derive(Subcommand)] #[command(rename_all = "snake_case")] pub enum TopLevelCommand { - /// Seedance2 Pro support commands + /// Seedance2 Pro support commands (direct Kinovi API) Seedance2pro { #[command(subcommand)] command: seedance2pro::Seedance2proCommand, }, + + /// ArtCraft support commands (omni API) + Artcraft { + #[command(subcommand)] + command: artcraft::ArtcraftCommand, + }, } pub async fn run(cli: Cli) -> anyhow::Result<()> { @@ -32,5 +40,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { TopLevelCommand::Seedance2pro { command } => { seedance2pro::run(command).await } + TopLevelCommand::Artcraft { command } => { + artcraft::run(command).await + } } } diff --git a/crates/cli/production/support-tool/src/utils/mod.rs b/crates/cli/production/support-tool/src/utils/mod.rs index 3a41a3c5fb..8d5f76d745 100644 --- a/crates/cli/production/support-tool/src/utils/mod.rs +++ b/crates/cli/production/support-tool/src/utils/mod.rs @@ -1 +1,2 @@ pub mod normalize_subcommands; +pub mod parse_video_model; diff --git a/crates/cli/production/support-tool/src/utils/parse_video_model.rs b/crates/cli/production/support-tool/src/utils/parse_video_model.rs new file mode 100644 index 0000000000..a13e11a4e3 --- /dev/null +++ b/crates/cli/production/support-tool/src/utils/parse_video_model.rs @@ -0,0 +1,83 @@ +use enums::common::generation::common_video_model::CommonVideoModel; + +/// Parse a loose model name into a CommonVideoModel. +/// +/// Accepts the canonical serde names (eg. "seedance_2p0") as well as +/// human-friendly aliases (eg. "seedance2", "happyhorse", "veo3fast"). +pub fn parse_video_model(value: &str) -> Option { + let value = value.trim(); + + // Try canonical serde deserialization first (eg. "seedance_2p0", "happy_horse_1p0"). + if let Ok(model) = serde_json::from_str::(&format!("\"{}\"", value)) { + return Some(model); + } + + // Fallback: human-friendly aliases. + match value.to_lowercase().replace('-', "_").as_str() { + // Seedance family + "seedance2" | "seedance2p0" | "seedance_2p0" => Some(CommonVideoModel::Seedance2p0), + "seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Some(CommonVideoModel::Seedance2p0Fast), + "seedance1" | "seedance10lite" | "seedance_1p0_lite" => Some(CommonVideoModel::Seedance10Lite), + "seedance15" | "seedance1p5" | "seedance_1p5_pro" => Some(CommonVideoModel::Seedance1p5Pro), + + // Happy Horse + "happyhorse" | "happy_horse" | "happyhorse1" | "happy_horse_1p0" => Some(CommonVideoModel::HappyHorse1p0), + + // Kling family + "kling16" | "kling_1p6_pro" => Some(CommonVideoModel::Kling16Pro), + "kling21" | "kling_2p1_pro" => Some(CommonVideoModel::Kling21Pro), + "kling21master" | "kling_2p1_master" => Some(CommonVideoModel::Kling21Master), + "kling25turbo" | "kling_2p5_turbo_pro" => Some(CommonVideoModel::Kling2p5TurboPro), + "kling26" | "kling_2p6_pro" => Some(CommonVideoModel::Kling2p6Pro), + "kling3" | "kling30" | "kling_3p0_standard" => Some(CommonVideoModel::Kling3p0Standard), + "kling3pro" | "kling30pro" | "kling_3p0_pro" => Some(CommonVideoModel::Kling3p0Pro), + + // Veo family + "veo2" | "veo_2" => Some(CommonVideoModel::Veo2), + "veo3" | "veo_3" => Some(CommonVideoModel::Veo3), + "veo3fast" | "veo_3_fast" => Some(CommonVideoModel::Veo3Fast), + "veo31" | "veo3p1" | "veo_3p1" => Some(CommonVideoModel::Veo3p1), + "veo31fast" | "veo3p1fast" | "veo_3p1_fast" => Some(CommonVideoModel::Veo3p1Fast), + + // Sora family + "sora2" | "sora_2" => Some(CommonVideoModel::Sora2), + "sora2pro" | "sora_2_pro" => Some(CommonVideoModel::Sora2Pro), + + // Grok + "grok" | "grok_video" | "grokvideo" => Some(CommonVideoModel::GrokVideo), + + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_names() { + assert_eq!(parse_video_model("seedance_2p0"), Some(CommonVideoModel::Seedance2p0)); + assert_eq!(parse_video_model("happy_horse_1p0"), Some(CommonVideoModel::HappyHorse1p0)); + assert_eq!(parse_video_model("veo_3"), Some(CommonVideoModel::Veo3)); + } + + #[test] + fn aliases() { + assert_eq!(parse_video_model("seedance2"), Some(CommonVideoModel::Seedance2p0)); + assert_eq!(parse_video_model("seedance2fast"), Some(CommonVideoModel::Seedance2p0Fast)); + assert_eq!(parse_video_model("happyhorse"), Some(CommonVideoModel::HappyHorse1p0)); + assert_eq!(parse_video_model("veo3fast"), Some(CommonVideoModel::Veo3Fast)); + assert_eq!(parse_video_model("kling3pro"), Some(CommonVideoModel::Kling3p0Pro)); + } + + #[test] + fn unknown() { + assert_eq!(parse_video_model("nonexistent"), None); + assert_eq!(parse_video_model(""), None); + } + + #[test] + fn trims_whitespace() { + assert_eq!(parse_video_model(" seedance2 "), Some(CommonVideoModel::Seedance2p0)); + } +}