mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
prompting tool for artcraft
This commit is contained in:
1 parent
4b5e171261
commit
03c8adb2cf
12 files changed
+343
-4
No files matched your search
Generated
+3
@@ -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]]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+179
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user