mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c140c00b4 | ||
|
|
46a7993bfb | ||
|
|
1ac983a871 | ||
|
|
03f7eb2037 | ||
|
|
539a217521 |
@@ -9,6 +9,9 @@ pub enum GmiCloudSpecificApiError {
|
||||
/// The request was rejected due to content policy.
|
||||
ContentPolicyViolation(String),
|
||||
|
||||
/// The input image was rejected because it may contain a real person.
|
||||
ContentContainsRealPerson(String),
|
||||
|
||||
/// The account has insufficient credits or a billing issue.
|
||||
BillingError { status_code: u16, body: String },
|
||||
}
|
||||
|
||||
+15
-2
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::requests::api::video::seedance_2_0_260128::raw_request::Seedance20Payload;
|
||||
use crate::requests::common::create_request::{
|
||||
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
create_gmicloud_request, create_gmicloud_request_with_context,
|
||||
GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
};
|
||||
use crate::requests::context::request_context::RequestContext;
|
||||
|
||||
const MODEL_ID: &str = "seedance-2-0-260128";
|
||||
|
||||
@@ -113,12 +115,23 @@ impl Seedance20Request {
|
||||
pub async fn send_request(
|
||||
&self,
|
||||
api_key: &GmiCloudApiKey,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let context = RequestContext {
|
||||
api_key,
|
||||
maybe_timeout: None,
|
||||
};
|
||||
self.send_request_with_context(&context).await
|
||||
}
|
||||
|
||||
pub async fn send_request_with_context(
|
||||
&self,
|
||||
context: &RequestContext<'_>,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: MODEL_ID.to_string(),
|
||||
payload: self.to_raw_payload(),
|
||||
};
|
||||
create_gmicloud_request(api_key, &body).await
|
||||
create_gmicloud_request_with_context(context, &body).await
|
||||
}
|
||||
|
||||
pub fn model_id() -> &'static str {
|
||||
|
||||
+15
-2
@@ -2,8 +2,10 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::requests::api::video::seedance_2_0_fast_260128::raw_request::Seedance20FastPayload;
|
||||
use crate::requests::common::create_request::{
|
||||
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
create_gmicloud_request, create_gmicloud_request_with_context,
|
||||
GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
};
|
||||
use crate::requests::context::request_context::RequestContext;
|
||||
|
||||
const MODEL_ID: &str = "seedance-2-0-fast-260128";
|
||||
|
||||
@@ -111,12 +113,23 @@ impl Seedance20FastRequest {
|
||||
pub async fn send_request(
|
||||
&self,
|
||||
api_key: &GmiCloudApiKey,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let context = RequestContext {
|
||||
api_key,
|
||||
maybe_timeout: None,
|
||||
};
|
||||
self.send_request_with_context(&context).await
|
||||
}
|
||||
|
||||
pub async fn send_request_with_context(
|
||||
&self,
|
||||
context: &RequestContext<'_>,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: MODEL_ID.to_string(),
|
||||
payload: self.to_raw_payload(),
|
||||
};
|
||||
create_gmicloud_request(api_key, &body).await
|
||||
create_gmicloud_request_with_context(context, &body).await
|
||||
}
|
||||
|
||||
pub fn model_id() -> &'static str {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -5,9 +7,14 @@ use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError;
|
||||
use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError;
|
||||
use crate::requests::context::request_context::RequestContext;
|
||||
|
||||
const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey";
|
||||
|
||||
/// Default request timeout. Video requests return quickly (async queue),
|
||||
/// but image requests block until completion and can take 60+ seconds.
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// The top-level request body sent to `POST /requests`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GmiCloudCreateRequest<P: Serialize> {
|
||||
@@ -23,17 +30,34 @@ pub struct GmiCloudCreateResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Submit a video generation request to GmiCloud.
|
||||
/// Submit a request to GmiCloud using just an API key (default timeout).
|
||||
pub async fn create_gmicloud_request<P: Serialize>(
|
||||
api_key: &GmiCloudApiKey,
|
||||
body: &GmiCloudCreateRequest<P>,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let context = RequestContext {
|
||||
api_key,
|
||||
maybe_timeout: None,
|
||||
};
|
||||
create_gmicloud_request_with_context(&context, body).await
|
||||
}
|
||||
|
||||
/// Submit a request to GmiCloud with explicit context (custom timeout, etc.).
|
||||
pub async fn create_gmicloud_request_with_context<P: Serialize>(
|
||||
context: &RequestContext<'_>,
|
||||
body: &GmiCloudCreateRequest<P>,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let url = format!("{}/requests", BASE_URL);
|
||||
let timeout = context.maybe_timeout.unwrap_or(DEFAULT_TIMEOUT);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(GmiCloudGenericApiError::from)?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key.as_str()))
|
||||
.header("Authorization", format!("Bearer {}", context.api_key.as_str()))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
@@ -49,6 +73,12 @@ pub async fn create_gmicloud_request<P: Serialize>(
|
||||
|
||||
if !status.is_success() {
|
||||
warn!("GmiCloud API error: status={}, body={}", status, body_text);
|
||||
|
||||
// Detect specific error codes embedded in the response body.
|
||||
if let Some(specific) = classify_error_body(&body_text) {
|
||||
return Err(specific.into());
|
||||
}
|
||||
|
||||
return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status.as_u16(),
|
||||
body: body_text,
|
||||
@@ -60,3 +90,28 @@ pub async fn create_gmicloud_request<P: Serialize>(
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Attempt to classify an error response body into a specific error type.
|
||||
/// GmiCloud sometimes wraps inner API errors as a JSON string within `{"error": "..."}`.
|
||||
fn classify_error_body(body: &str) -> Option<GmiCloudSpecificApiError> {
|
||||
if body.contains("InputImageSensitiveContentDetected") {
|
||||
let message = extract_inner_message(body)
|
||||
.unwrap_or_else(|| "Input image may contain a real person".to_string());
|
||||
return Some(GmiCloudSpecificApiError::ContentContainsRealPerson(message));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the inner `message` field from a doubly-encoded GmiCloud error response.
|
||||
/// Expected shape: `{"error":"API request failed with status 400: {\"error\":{\"message\":\"...\"}}"}`
|
||||
fn extract_inner_message(body: &str) -> Option<String> {
|
||||
let outer: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let error_str = outer.get("error")?.as_str()?;
|
||||
|
||||
// The inner JSON is embedded after "status NNN: "
|
||||
let inner_json_start = error_str.find('{')?;
|
||||
let inner_str = &error_str[inner_json_start..];
|
||||
let inner: serde_json::Value = serde_json::from_str(inner_str).ok()?;
|
||||
|
||||
inner.get("error")?.get("message")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request_context;
|
||||
@@ -0,0 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
|
||||
pub struct RequestContext<'a> {
|
||||
pub api_key: &'a GmiCloudApiKey,
|
||||
pub maybe_timeout: Option<Duration>,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod common;
|
||||
pub mod context;
|
||||
pub mod poll_request_queue;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use clap::Subcommand;
|
||||
use clap::{Args, Subcommand};
|
||||
use log::{info, warn};
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use super::state::ArtcraftState;
|
||||
use super::subcommands;
|
||||
@@ -10,6 +15,24 @@ use super::subcommands;
|
||||
/// Used by the underscore-insensitive arg normalizer.
|
||||
pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"];
|
||||
|
||||
const DEFAULT_COOKIE_FILE: &str = "artcraft_cookies.txt";
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ArtcraftArgs {
|
||||
/// Path to a file containing the session cookies.
|
||||
/// If not specified, reads from `artcraft_cookies.txt` in the current directory.
|
||||
#[arg(long, global = true)]
|
||||
pub cookie_file: Option<String>,
|
||||
|
||||
/// Target environment: "dev" / "development" (localhost:12345) or "prod" / "production" (api.storyteller.ai).
|
||||
/// [default: dev]
|
||||
#[arg(long, global = true, default_value = "dev")]
|
||||
pub environment: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: ArtcraftCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(rename_all = "snake_case")]
|
||||
pub enum ArtcraftCommand {
|
||||
@@ -17,17 +40,52 @@ pub enum ArtcraftCommand {
|
||||
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))?;
|
||||
pub async fn dispatch(args: ArtcraftArgs) -> anyhow::Result<()> {
|
||||
let cookie_path = args.cookie_file.as_deref().unwrap_or(DEFAULT_COOKIE_FILE);
|
||||
let cookies_str = load_cookies(cookie_path)?;
|
||||
|
||||
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=...)"))?;
|
||||
.map_err(|err| anyhow!("Failed to parse cookies: {:?}", err))?
|
||||
.ok_or_else(|| anyhow!("Cookie file contained no recognized cookies (expected session=... and/or visitor=...)"))?;
|
||||
|
||||
let state = ArtcraftState { creds };
|
||||
let api_host = parse_api_host(&args.environment)?;
|
||||
let state = ArtcraftState { creds, api_host };
|
||||
|
||||
match command {
|
||||
ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
|
||||
match args.command {
|
||||
ArtcraftCommand::GenerateVideo(cmd_args) => subcommands::generate_video::run(&state, cmd_args).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_cookies(cookie_path: &str) -> anyhow::Result<String> {
|
||||
info!("Reading cookies from: {}", cookie_path);
|
||||
|
||||
if Path::new(cookie_path).exists() {
|
||||
let content = fs::read_to_string(cookie_path)
|
||||
.map_err(|err| anyhow!("Failed to read cookie file {:?}: {}", cookie_path, err))?;
|
||||
Ok(content.trim().to_string())
|
||||
} else {
|
||||
warn!("Cookie file {:?} does not exist.", cookie_path);
|
||||
Err(anyhow!(
|
||||
"Cookie file {:?} not found. Use --cookie-file to specify a path, \
|
||||
or create '{}' in the current directory.",
|
||||
cookie_path, DEFAULT_COOKIE_FILE
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_api_host(environment: &str) -> anyhow::Result<ApiHost> {
|
||||
match environment {
|
||||
"dev" | "development" => {
|
||||
info!("Environment: development (localhost:12345)");
|
||||
Ok(ApiHost::Localhost { port: 12345 })
|
||||
}
|
||||
"prod" | "production" => {
|
||||
info!("Environment: production (api.storyteller.ai)");
|
||||
Ok(ApiHost::Storyteller)
|
||||
}
|
||||
other => Err(anyhow!(
|
||||
"Unknown environment '{}'. Use 'dev', 'development', 'prod', or 'production'.",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ pub mod dispatch;
|
||||
mod state;
|
||||
mod subcommands;
|
||||
|
||||
pub use dispatch::ArtcraftArgs;
|
||||
pub use dispatch::ArtcraftCommand;
|
||||
pub use dispatch::run;
|
||||
pub use dispatch::dispatch;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
pub struct ArtcraftState {
|
||||
pub creds: StorytellerCredentialSet,
|
||||
pub api_host: ApiHost,
|
||||
}
|
||||
|
||||
+2
-5
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -51,9 +50,7 @@ pub struct GenerateVideoArgs {
|
||||
#[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]
|
||||
@@ -77,7 +74,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
|
||||
.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 api_host = &state.api_host;
|
||||
let duration = args.duration.unwrap_or(5);
|
||||
|
||||
info!("Model: {:?}", model);
|
||||
@@ -131,7 +128,7 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
|
||||
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
|
||||
|
||||
let response = omni_gen_video_generate(
|
||||
&api_host,
|
||||
api_host,
|
||||
Some(&state.creds),
|
||||
request,
|
||||
).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?;
|
||||
|
||||
@@ -29,10 +29,7 @@ pub enum TopLevelCommand {
|
||||
},
|
||||
|
||||
/// ArtCraft support commands (omni API)
|
||||
Artcraft {
|
||||
#[command(subcommand)]
|
||||
command: artcraft::ArtcraftCommand,
|
||||
},
|
||||
Artcraft(artcraft::dispatch::ArtcraftArgs),
|
||||
}
|
||||
|
||||
pub async fn run(cli: Cli) -> anyhow::Result<()> {
|
||||
@@ -40,8 +37,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
|
||||
TopLevelCommand::Seedance2pro { command } => {
|
||||
seedance2pro::run(command).await
|
||||
}
|
||||
TopLevelCommand::Artcraft { command } => {
|
||||
artcraft::run(command).await
|
||||
TopLevelCommand::Artcraft(args) => {
|
||||
artcraft::dispatch(args).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user