prompting tool for artcraft

This commit is contained in:
Brandon Thomas committed 2026-05-03 00:25:18 -04:00
1 parent 4b5e171261
commit 03c8adb2cf
12 files changed
+343 -4

No files matched your search

Generated
+3
View File
@@ -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]]
+15 -2
View File
@@ -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 <order_id>` — search for a job across all pages
- `seedance2pro failed_job_histogram` — histogram of failure reasons
- `seedance2pro generate_video --prompt <text_or_file> [--model seedance2p0] [--reference_media_tokens "token1,token2"] [--localhost] [--download_path /tmp/media_files]`
- `seedance2pro generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--localhost] [--download_path /tmp/media_files]`
### artcraft (omni API)
- `artcraft generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--duration 5] [--localhost]`
@@ -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"] }
@@ -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,
}
}
@@ -0,0 +1,6 @@
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::ArtcraftCommand;
pub use dispatch::run;
@@ -0,0 +1,5 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub struct ArtcraftState {
pub creds: StorytellerCredentialSet,
}
@@ -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 <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<String>,
/// A single media token for the end frame image.
#[arg(long)]
pub end_frame_media_token: Option<String>,
/// Comma or space separated media tokens for image references.
#[arg(long)]
pub image_reference_tokens: Option<String>,
/// Comma or space separated media tokens for video references.
#[arg(long)]
pub video_reference_tokens: Option<String>,
/// Comma or space separated media tokens for audio references.
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// 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<String>,
/// Duration in seconds. [default: 5]
#[arg(long)]
pub duration: Option<u16>,
/// Idempotency token (auto-generated if not specified).
#[arg(long)]
pub idempotency_token: Option<String>,
}
// ── 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::<Vec<_>>()); }
if !video_ref_tokens.is_empty() { info!("Video refs: {:?}", video_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
if !audio_ref_tokens.is_empty() { info!("Audio refs: {:?}", audio_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
// 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::<Vec<_>>());
Ok(())
}
// ── Helpers ──
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
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<MediaFileToken> {
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()
}
@@ -0,0 +1 @@
pub mod generate_video;
@@ -1,2 +1,3 @@
pub mod artcraft;
pub mod run;
pub mod seedance2pro;
@@ -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
}
}
}
@@ -1 +1,2 @@
pub mod normalize_subcommands;
pub mod parse_video_model;
@@ -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<CommonVideoModel> {
let value = value.trim();
// Try canonical serde deserialization first (eg. "seedance_2p0", "happy_horse_1p0").
if let Ok(model) = serde_json::from_str::<CommonVideoModel>(&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));
}
}