diff --git a/Cargo.lock b/Cargo.lock index 67542d1388..8374526d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4690,6 +4690,22 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "gmicloud_client" +version = "0.0.1" +dependencies = [ + "errors", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "test_data", + "test_utils", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "gobject-sys" version = "0.18.0" diff --git a/Cargo.toml b/Cargo.toml index 19a56407ea..39eed13d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/api_clients/google_drive_downloader", "crates/api_clients/google_sign_in", "crates/api_clients/grok_client", + "crates/api_clients/gmicloud_client", "crates/api_clients/midjourney_client", "crates/api_clients/muapi_client", "crates/api_clients/beeble_client", @@ -242,6 +243,7 @@ enums = { path = "crates/schema/public/enums" } error_utils = { path = "crates/lib/error_utils" } errors = { path = "crates/lib/errors" } fal_client = { path = "crates/api_clients/fal_client" } +gmicloud_client = { path = "crates/api_clients/gmicloud_client" } ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" } file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" } filesys = { path = "crates/lib/files/filesys" } diff --git a/crates/api_clients/gmicloud_client/Cargo.toml b/crates/api_clients/gmicloud_client/Cargo.toml new file mode 100644 index 0000000000..2fe2c1946d --- /dev/null +++ b/crates/api_clients/gmicloud_client/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "gmicloud_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "gmicloud_client" +path = "src/lib.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# Internal +errors.workspace = true + +# External +log.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +test_data = { path = "../../testing/test_data" } +test_utils = { path = "../../testing/test_utils" } +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs b/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs new file mode 100644 index 0000000000..0290ecb003 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/creds/gmicloud_api_key.rs @@ -0,0 +1,16 @@ +/// A GmiCloud API key used for Bearer token authentication. +pub struct GmiCloudApiKey(pub String); + +impl GmiCloudApiKey { + pub fn new(api_key: String) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn from_str(api_key: &str) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/gmicloud_client/src/creds/mod.rs b/crates/api_clients/gmicloud_client/src/creds/mod.rs new file mode 100644 index 0000000000..471a13fe7c --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod gmicloud_api_key; diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs new file mode 100644 index 0000000000..e2acccad3f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_client_error.rs @@ -0,0 +1,23 @@ +use std::fmt; + +/// Errors that occur client-side before/during request construction. +#[derive(Debug)] +pub enum GmiCloudClientError { + /// The API key was not provided or is empty. + NoApiKeyPresent, + + /// A reqwest client-level error. + ReqwestError(reqwest::Error), +} + +impl fmt::Display for GmiCloudClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudClientError { + fn from(err: reqwest::Error) -> Self { + Self::ReqwestError(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs new file mode 100644 index 0000000000..eef5221f8d --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_error.rs @@ -0,0 +1,37 @@ +use std::fmt; + +use crate::error::gmicloud_client_error::GmiCloudClientError; +use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError; +use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError; + +/// Top-level error type for the GmiCloud client. +#[derive(Debug)] +pub enum GmiCloudError { + Client(GmiCloudClientError), + ApiSpecific(GmiCloudSpecificApiError), + ApiGeneric(GmiCloudGenericApiError), +} + +impl fmt::Display for GmiCloudError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudClientError) -> Self { + Self::Client(err) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudSpecificApiError) -> Self { + Self::ApiSpecific(err) + } +} + +impl From for GmiCloudError { + fn from(err: GmiCloudGenericApiError) -> Self { + Self::ApiGeneric(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs new file mode 100644 index 0000000000..cc9d7e326a --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_generic_api_error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +/// Server-side errors with unknown or generic causes. +#[derive(Debug)] +pub enum GmiCloudGenericApiError { + /// Failed to parse the JSON response body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// Non-200 response that couldn't be classified. + UncategorizedBadResponseWithStatusAndBody { status_code: u16, body: String }, + + /// A reqwest transport error. + ReqwestError(reqwest::Error), +} + +impl fmt::Display for GmiCloudGenericApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for GmiCloudGenericApiError { + fn from(err: reqwest::Error) -> Self { + Self::ReqwestError(err) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs b/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs new file mode 100644 index 0000000000..42a52b4aa5 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/gmicloud_specific_api_error.rs @@ -0,0 +1,20 @@ +use std::fmt; + +/// Well-known server-side errors with specific causes. +#[derive(Debug)] +pub enum GmiCloudSpecificApiError { + /// The API key is invalid or expired. + Unauthorized, + + /// The request was rejected due to content policy. + ContentPolicyViolation(String), + + /// The account has insufficient credits or a billing issue. + BillingError { status_code: u16, body: String }, +} + +impl fmt::Display for GmiCloudSpecificApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/crates/api_clients/gmicloud_client/src/error/mod.rs b/crates/api_clients/gmicloud_client/src/error/mod.rs new file mode 100644 index 0000000000..a3317b572f --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/error/mod.rs @@ -0,0 +1,4 @@ +pub mod gmicloud_client_error; +pub mod gmicloud_error; +pub mod gmicloud_generic_api_error; +pub mod gmicloud_specific_api_error; diff --git a/crates/api_clients/gmicloud_client/src/lib.rs b/crates/api_clients/gmicloud_client/src/lib.rs new file mode 100644 index 0000000000..f062f8d0bf --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/lib.rs @@ -0,0 +1,7 @@ +pub mod creds; +pub mod error; +pub mod requests; +pub mod traits; + +#[cfg(test)] +mod test_utils; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/mod.rs new file mode 100644 index 0000000000..b04b953edb --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/mod.rs @@ -0,0 +1 @@ +pub mod video; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs new file mode 100644 index 0000000000..c3032250aa --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/mod.rs @@ -0,0 +1,2 @@ +pub mod seedance_2_0_260128; +pub mod seedance_2_0_fast_260128; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs new file mode 100644 index 0000000000..52501ae812 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/api.rs @@ -0,0 +1,247 @@ +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, +}; + +const MODEL_ID: &str = "seedance-2-0-260128"; + +/// User-facing request for the Seedance 2.0 model via GmiCloud. +#[derive(Clone, Debug)] +pub struct Seedance20Request { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Video duration. + pub duration: Option, + + /// Aspect ratio of the output video. + pub aspect_ratio: Option, + + /// Negative prompt (terms to avoid in generation). + pub negative_prompt: Option, + + /// An image URL for image-to-video generation. + pub start_frame_url: Option, + + /// Seed for deterministic generation. + pub seed: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Seedance20Duration { + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Seedance20AspectRatio { + /// 16:9 (landscape) + Landscape16x9, + /// 9:16 (portrait) + Portrait9x16, + /// 1:1 (square) + Square, + /// 4:3 + Standard4x3, + /// 3:4 + Portrait3x4, + /// 21:9 (ultra-wide) + UltraWide21x9, +} + +impl Seedance20Request { + pub fn to_raw_payload(&self) -> Seedance20Payload { + Seedance20Payload { + prompt: self.prompt.clone(), + duration: self.duration.map(|d| match d { + Seedance20Duration::FiveSeconds => "5", + Seedance20Duration::TenSeconds => "10", + }.to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| match ar { + Seedance20AspectRatio::Landscape16x9 => "16:9", + Seedance20AspectRatio::Portrait9x16 => "9:16", + Seedance20AspectRatio::Square => "1:1", + Seedance20AspectRatio::Standard4x3 => "4:3", + Seedance20AspectRatio::Portrait3x4 => "3:4", + Seedance20AspectRatio::UltraWide21x9 => "21:9", + }.to_string()), + negative_prompt: self.negative_prompt.clone(), + start_frame_url: self.start_frame_url.clone(), + seed: self.seed, + } + } + + pub async fn send_request( + &self, + api_key: &GmiCloudApiKey, + ) -> Result { + let body = GmiCloudCreateRequest { + model: MODEL_ID.to_string(), + payload: self.to_raw_payload(), + }; + create_gmicloud_request(api_key, &body).await + } + + pub fn model_id() -> &'static str { + MODEL_ID + } +} + +impl Seedance20Duration { + pub fn to_seconds(&self) -> u8 { + match self { + Seedance20Duration::FiveSeconds => 5, + Seedance20Duration::TenSeconds => 10, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod raw_payload_tests { + use super::*; + + #[test] + fn minimal_request_serializes() { + let request = Seedance20Request { + prompt: "a dog running through a field".to_string(), + duration: None, + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let payload = request.to_raw_payload(); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["prompt"], "a dog running through a field"); + assert!(json.get("duration").is_none()); + assert!(json.get("aspectRatio").is_none()); + assert!(json.get("negativePrompt").is_none()); + assert!(json.get("startFrameUrl").is_none()); + assert!(json.get("seed").is_none()); + } + + #[test] + fn full_request_serializes() { + let request = Seedance20Request { + prompt: "a cat sitting on a windowsill".to_string(), + duration: Some(Seedance20Duration::TenSeconds), + aspect_ratio: Some(Seedance20AspectRatio::Landscape16x9), + negative_prompt: Some("blurry, low quality".to_string()), + start_frame_url: Some("https://example.com/image.png".to_string()), + seed: Some(42), + }; + let payload = request.to_raw_payload(); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["prompt"], "a cat sitting on a windowsill"); + assert_eq!(json["duration"], "10"); + assert_eq!(json["aspectRatio"], "16:9"); + assert_eq!(json["negativePrompt"], "blurry, low quality"); + assert_eq!(json["startFrameUrl"], "https://example.com/image.png"); + assert_eq!(json["seed"], 42); + } + + #[test] + fn all_aspect_ratios_serialize() { + let cases = [ + (Seedance20AspectRatio::Landscape16x9, "16:9"), + (Seedance20AspectRatio::Portrait9x16, "9:16"), + (Seedance20AspectRatio::Square, "1:1"), + (Seedance20AspectRatio::Standard4x3, "4:3"), + (Seedance20AspectRatio::Portrait3x4, "3:4"), + (Seedance20AspectRatio::UltraWide21x9, "21:9"), + ]; + for (ar, expected) in cases { + let request = Seedance20Request { + prompt: "test".to_string(), + duration: None, + aspect_ratio: Some(ar), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["aspectRatio"], expected, "{ar:?}"); + } + } + + #[test] + fn all_durations_serialize() { + let cases = [ + (Seedance20Duration::FiveSeconds, "5"), + (Seedance20Duration::TenSeconds, "10"), + ]; + for (dur, expected) in cases { + let request = Seedance20Request { + prompt: "test".to_string(), + duration: Some(dur), + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["duration"], expected, "{dur:?}"); + } + } + + #[test] + fn create_request_body_shape() { + let request = Seedance20Request { + prompt: "test".to_string(), + duration: Some(Seedance20Duration::FiveSeconds), + aspect_ratio: Some(Seedance20AspectRatio::Square), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let body = GmiCloudCreateRequest { + model: Seedance20Request::model_id().to_string(), + payload: request.to_raw_payload(), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["model"], "seedance-2-0-260128"); + assert_eq!(json["payload"]["prompt"], "test"); + assert_eq!(json["payload"]["duration"], "5"); + assert_eq!(json["payload"]["aspectRatio"], "1:1"); + } + } + + mod duration_tests { + use super::*; + + #[test] + fn to_seconds() { + assert_eq!(Seedance20Duration::FiveSeconds.to_seconds(), 5); + assert_eq!(Seedance20Duration::TenSeconds.to_seconds(), 10); + } + } + + mod live_api_tests { + use super::*; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video() { + let api_key = crate::test_utils::load_api_key(); + let request = Seedance20Request { + prompt: "a golden retriever puppy playing in autumn leaves".to_string(), + duration: Some(Seedance20Duration::FiveSeconds), + aspect_ratio: Some(Seedance20AspectRatio::Landscape16x9), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let result = request.send_request(&api_key).await.unwrap(); + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs new file mode 100644 index 0000000000..84141b6fe3 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/cost.rs @@ -0,0 +1,87 @@ +use crate::requests::api::video::seedance_2_0_260128::api::{ + Seedance20Duration, Seedance20Request, +}; +use crate::traits::gmicloud_request_cost_calculator_trait::{ + GmiCloudRequestCostCalculator, UsdCents, +}; + +/// Default duration when not specified. +const DEFAULT_DURATION_SECONDS: u8 = 5; + +/// Cost per second of video in tenths of a US cent. +/// GmiCloud Seedance 2.0 (standard) pricing. +const TENTHS_PER_SECOND: u64 = 40; + +impl GmiCloudRequestCostCalculator for Seedance20Request { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_seconds = self.duration + .map(|d| d.to_seconds()) + .unwrap_or(DEFAULT_DURATION_SECONDS) as u64; + + let tenths = TENTHS_PER_SECOND * duration_seconds; + tenths.div_ceil(10) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::seedance_2_0_260128::api::Seedance20AspectRatio; + + fn make_request(duration: Option) -> Seedance20Request { + Seedance20Request { + prompt: "test".to_string(), + duration, + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + } + } + + #[test] + fn cost_default_duration() { + // Default 5s: 40 * 5 = 200 tenths = 20 cents + assert_eq!(make_request(None).calculate_cost_in_cents(), 20); + } + + #[test] + fn cost_five_seconds() { + assert_eq!( + make_request(Some(Seedance20Duration::FiveSeconds)).calculate_cost_in_cents(), + 20, + ); + } + + #[test] + fn cost_ten_seconds() { + // 40 * 10 = 400 tenths = 40 cents + assert_eq!( + make_request(Some(Seedance20Duration::TenSeconds)).calculate_cost_in_cents(), + 40, + ); + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + Seedance20AspectRatio::Landscape16x9, + Seedance20AspectRatio::Portrait9x16, + Seedance20AspectRatio::Square, + Seedance20AspectRatio::Standard4x3, + Seedance20AspectRatio::Portrait3x4, + Seedance20AspectRatio::UltraWide21x9, + ]; + for ar in aspect_ratios { + let request = Seedance20Request { + prompt: "test".to_string(), + duration: Some(Seedance20Duration::FiveSeconds), + aspect_ratio: Some(ar), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 20, "{ar:?}"); + } + } +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs new file mode 100644 index 0000000000..6f7f5b2a19 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_260128/raw_request.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-260128. +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance20Payload { + pub prompt: String, + + /// Duration in seconds, e.g. "5", "10". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio, e.g. "16:9", "9:16", "1:1", "4:3", "3:4", "21:9". + #[serde(rename = "aspectRatio")] + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Negative prompt (terms to avoid). + #[serde(rename = "negativePrompt")] + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// An image URL for image-to-video generation (start frame). + #[serde(rename = "startFrameUrl")] + #[serde(skip_serializing_if = "Option::is_none")] + pub start_frame_url: Option, + + /// Seed for deterministic generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs new file mode 100644 index 0000000000..11a2533e44 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/api.rs @@ -0,0 +1,214 @@ +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, +}; + +const MODEL_ID: &str = "seedance-2-0-fast-260128"; + +/// User-facing request for the Seedance 2.0 Fast model via GmiCloud. +#[derive(Clone, Debug)] +pub struct Seedance20FastRequest { + /// Text prompt describing the video to generate. + pub prompt: String, + + /// Video duration. + pub duration: Option, + + /// Aspect ratio of the output video. + pub aspect_ratio: Option, + + /// Negative prompt (terms to avoid in generation). + pub negative_prompt: Option, + + /// An image URL for image-to-video generation. + pub start_frame_url: Option, + + /// Seed for deterministic generation. + pub seed: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum Seedance20FastDuration { + FiveSeconds, + TenSeconds, +} + +#[derive(Copy, Clone, Debug)] +pub enum Seedance20FastAspectRatio { + /// 16:9 (landscape) + Landscape16x9, + /// 9:16 (portrait) + Portrait9x16, + /// 1:1 (square) + Square, + /// 4:3 + Standard4x3, + /// 3:4 + Portrait3x4, + /// 21:9 (ultra-wide) + UltraWide21x9, +} + +impl Seedance20FastRequest { + pub fn to_raw_payload(&self) -> Seedance20FastPayload { + Seedance20FastPayload { + prompt: self.prompt.clone(), + duration: self.duration.map(|d| match d { + Seedance20FastDuration::FiveSeconds => "5", + Seedance20FastDuration::TenSeconds => "10", + }.to_string()), + aspect_ratio: self.aspect_ratio.map(|ar| match ar { + Seedance20FastAspectRatio::Landscape16x9 => "16:9", + Seedance20FastAspectRatio::Portrait9x16 => "9:16", + Seedance20FastAspectRatio::Square => "1:1", + Seedance20FastAspectRatio::Standard4x3 => "4:3", + Seedance20FastAspectRatio::Portrait3x4 => "3:4", + Seedance20FastAspectRatio::UltraWide21x9 => "21:9", + }.to_string()), + negative_prompt: self.negative_prompt.clone(), + start_frame_url: self.start_frame_url.clone(), + seed: self.seed, + } + } + + pub async fn send_request( + &self, + api_key: &GmiCloudApiKey, + ) -> Result { + let body = GmiCloudCreateRequest { + model: MODEL_ID.to_string(), + payload: self.to_raw_payload(), + }; + create_gmicloud_request(api_key, &body).await + } + + pub fn model_id() -> &'static str { + MODEL_ID + } +} + +impl Seedance20FastDuration { + pub fn to_seconds(&self) -> u8 { + match self { + Seedance20FastDuration::FiveSeconds => 5, + Seedance20FastDuration::TenSeconds => 10, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod raw_payload_tests { + use super::*; + + #[test] + fn minimal_request_serializes() { + let request = Seedance20FastRequest { + prompt: "a dog running through a field".to_string(), + duration: None, + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let payload = request.to_raw_payload(); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["prompt"], "a dog running through a field"); + assert!(json.get("duration").is_none()); + assert!(json.get("aspectRatio").is_none()); + } + + #[test] + fn full_request_serializes() { + let request = Seedance20FastRequest { + prompt: "a cat sitting on a windowsill".to_string(), + duration: Some(Seedance20FastDuration::TenSeconds), + aspect_ratio: Some(Seedance20FastAspectRatio::Portrait9x16), + negative_prompt: Some("blurry".to_string()), + start_frame_url: Some("https://example.com/image.png".to_string()), + seed: Some(123), + }; + let payload = request.to_raw_payload(); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["prompt"], "a cat sitting on a windowsill"); + assert_eq!(json["duration"], "10"); + assert_eq!(json["aspectRatio"], "9:16"); + assert_eq!(json["negativePrompt"], "blurry"); + assert_eq!(json["startFrameUrl"], "https://example.com/image.png"); + assert_eq!(json["seed"], 123); + } + + #[test] + fn all_aspect_ratios_serialize() { + let cases = [ + (Seedance20FastAspectRatio::Landscape16x9, "16:9"), + (Seedance20FastAspectRatio::Portrait9x16, "9:16"), + (Seedance20FastAspectRatio::Square, "1:1"), + (Seedance20FastAspectRatio::Standard4x3, "4:3"), + (Seedance20FastAspectRatio::Portrait3x4, "3:4"), + (Seedance20FastAspectRatio::UltraWide21x9, "21:9"), + ]; + for (ar, expected) in cases { + let request = Seedance20FastRequest { + prompt: "test".to_string(), + duration: None, + aspect_ratio: Some(ar), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let json = serde_json::to_value(&request.to_raw_payload()).unwrap(); + assert_eq!(json["aspectRatio"], expected, "{ar:?}"); + } + } + + #[test] + fn create_request_body_shape() { + let request = Seedance20FastRequest { + prompt: "test".to_string(), + duration: Some(Seedance20FastDuration::FiveSeconds), + aspect_ratio: Some(Seedance20FastAspectRatio::Square), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let body = GmiCloudCreateRequest { + model: Seedance20FastRequest::model_id().to_string(), + payload: request.to_raw_payload(), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["model"], "seedance-2-0-fast-260128"); + assert_eq!(json["payload"]["prompt"], "test"); + assert_eq!(json["payload"]["duration"], "5"); + assert_eq!(json["payload"]["aspectRatio"], "1:1"); + } + } + + mod live_api_tests { + use super::*; + + #[tokio::test] + #[ignore] // requires real API key, incurs costs + async fn test_text_to_video() { + let api_key = crate::test_utils::load_api_key(); + let request = Seedance20FastRequest { + prompt: "a golden retriever puppy playing in autumn leaves".to_string(), + duration: Some(Seedance20FastDuration::FiveSeconds), + aspect_ratio: Some(Seedance20FastAspectRatio::Landscape16x9), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + let result = request.send_request(&api_key).await.unwrap(); + println!("Request ID: {}", result.request_id); + assert!(!result.request_id.is_empty()); + assert_eq!(result.model, MODEL_ID); + } + } + + // NB: Pricing tests are in cost.rs +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs new file mode 100644 index 0000000000..f1904ad855 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/cost.rs @@ -0,0 +1,113 @@ +use crate::requests::api::video::seedance_2_0_fast_260128::api::{ + Seedance20FastDuration, Seedance20FastRequest, +}; +use crate::traits::gmicloud_request_cost_calculator_trait::{ + GmiCloudRequestCostCalculator, UsdCents, +}; + +/// Default duration when not specified. +const DEFAULT_DURATION_SECONDS: u8 = 5; + +/// Cost per second of video in tenths of a US cent. +/// GmiCloud Seedance 2.0 Fast is cheaper than the standard model. +const TENTHS_PER_SECOND: u64 = 28; + +impl GmiCloudRequestCostCalculator for Seedance20FastRequest { + fn calculate_cost_in_cents(&self) -> UsdCents { + let duration_seconds = self.duration + .map(|d| d.to_seconds()) + .unwrap_or(DEFAULT_DURATION_SECONDS) as u64; + + let tenths = TENTHS_PER_SECOND * duration_seconds; + tenths.div_ceil(10) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastAspectRatio; + + fn make_request(duration: Option) -> Seedance20FastRequest { + Seedance20FastRequest { + prompt: "test".to_string(), + duration, + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + } + } + + #[test] + fn cost_default_duration() { + // Default 5s: 28 * 5 = 140 tenths = 14 cents + assert_eq!(make_request(None).calculate_cost_in_cents(), 14); + } + + #[test] + fn cost_five_seconds() { + assert_eq!( + make_request(Some(Seedance20FastDuration::FiveSeconds)).calculate_cost_in_cents(), + 14, + ); + } + + #[test] + fn cost_ten_seconds() { + // 28 * 10 = 280 tenths = 28 cents + assert_eq!( + make_request(Some(Seedance20FastDuration::TenSeconds)).calculate_cost_in_cents(), + 28, + ); + } + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let aspect_ratios = [ + Seedance20FastAspectRatio::Landscape16x9, + Seedance20FastAspectRatio::Portrait9x16, + Seedance20FastAspectRatio::Square, + Seedance20FastAspectRatio::Standard4x3, + Seedance20FastAspectRatio::Portrait3x4, + Seedance20FastAspectRatio::UltraWide21x9, + ]; + for ar in aspect_ratios { + let request = Seedance20FastRequest { + prompt: "test".to_string(), + duration: Some(Seedance20FastDuration::FiveSeconds), + aspect_ratio: Some(ar), + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + assert_eq!(request.calculate_cost_in_cents(), 14, "{ar:?}"); + } + } + + #[test] + fn fast_is_cheaper_than_standard() { + use crate::requests::api::video::seedance_2_0_260128::api::{ + Seedance20Duration, Seedance20Request, + }; + use crate::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator; + + let standard = Seedance20Request { + prompt: "test".to_string(), + duration: Some(Seedance20Duration::FiveSeconds), + aspect_ratio: None, + negative_prompt: None, + start_frame_url: None, + seed: None, + }; + + let fast = make_request(Some(Seedance20FastDuration::FiveSeconds)); + + assert!( + fast.calculate_cost_in_cents() < standard.calculate_cost_in_cents(), + "Fast ({}¢) should be cheaper than Standard ({}¢)", + fast.calculate_cost_in_cents(), + standard.calculate_cost_in_cents(), + ); + } +} diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs new file mode 100644 index 0000000000..a0a589f2cd --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/mod.rs @@ -0,0 +1,3 @@ +pub mod api; +mod raw_request; +mod cost; diff --git a/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs new file mode 100644 index 0000000000..b2210dd9c0 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/api/video/seedance_2_0_fast_260128/raw_request.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-fast-260128. +#[derive(Debug, Serialize, Deserialize)] +pub struct Seedance20FastPayload { + pub prompt: String, + + /// Duration in seconds, e.g. "5", "10". + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// Aspect ratio, e.g. "16:9", "9:16", "1:1", "4:3", "3:4", "21:9". + #[serde(rename = "aspectRatio")] + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + + /// Negative prompt (terms to avoid). + #[serde(rename = "negativePrompt")] + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_prompt: Option, + + /// An image URL for image-to-video generation (start frame). + #[serde(rename = "startFrameUrl")] + #[serde(skip_serializing_if = "Option::is_none")] + pub start_frame_url: Option, + + /// Seed for deterministic generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, +} diff --git a/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs b/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs new file mode 100644 index 0000000000..bd137a279b --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/common/create_request.rs @@ -0,0 +1,62 @@ +use log::warn; +use serde::{Deserialize, Serialize}; + +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; + +const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey"; + +/// The top-level request body sent to `POST /requests`. +#[derive(Debug, Serialize)] +pub struct GmiCloudCreateRequest { + pub model: String, + pub payload: P, +} + +/// The response from `POST /requests`. +#[derive(Debug, Deserialize)] +pub struct GmiCloudCreateResponse { + pub request_id: String, + pub model: String, + pub status: String, +} + +/// Submit a video generation request to GmiCloud. +pub async fn create_gmicloud_request( + api_key: &GmiCloudApiKey, + body: &GmiCloudCreateRequest

, +) -> Result { + let url = format!("{}/requests", BASE_URL); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key.as_str())) + .json(body) + .send() + .await + .map_err(GmiCloudGenericApiError::from)?; + + let status = response.status(); + let body_text = response.text().await + .map_err(GmiCloudGenericApiError::from)?; + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(GmiCloudSpecificApiError::Unauthorized.into()); + } + + if !status.is_success() { + warn!("GmiCloud API error: status={}, body={}", status, body_text); + return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status.as_u16(), + body: body_text, + }.into()); + } + + let parsed: GmiCloudCreateResponse = serde_json::from_str(&body_text) + .map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?; + + Ok(parsed) +} diff --git a/crates/api_clients/gmicloud_client/src/requests/common/mod.rs b/crates/api_clients/gmicloud_client/src/requests/common/mod.rs new file mode 100644 index 0000000000..d817246f06 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod create_request; +pub mod poll_request; diff --git a/crates/api_clients/gmicloud_client/src/requests/common/poll_request.rs b/crates/api_clients/gmicloud_client/src/requests/common/poll_request.rs new file mode 100644 index 0000000000..cef0690e22 --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/common/poll_request.rs @@ -0,0 +1,62 @@ +use log::warn; +use serde::Deserialize; + +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; + +const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey"; + +/// The response from `GET /requests/{request_id}`. +#[derive(Debug, Deserialize)] +pub struct GmiCloudPollResponse { + pub request_id: String, + pub model: String, + pub status: String, + pub outcome: Option, +} + +/// The outcome of a completed request. +#[derive(Debug, Deserialize)] +pub struct GmiCloudOutcome { + pub video_url: Option, + pub thumbnail_image_url: Option, +} + +/// Poll the status of a GmiCloud request. +pub async fn poll_gmicloud_request( + api_key: &GmiCloudApiKey, + request_id: &str, +) -> Result { + let url = format!("{}/requests/{}", BASE_URL, request_id); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key.as_str())) + .send() + .await + .map_err(GmiCloudGenericApiError::from)?; + + let status = response.status(); + let body_text = response.text().await + .map_err(GmiCloudGenericApiError::from)?; + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(GmiCloudSpecificApiError::Unauthorized.into()); + } + + if !status.is_success() { + warn!("GmiCloud poll error: status={}, body={}", status, body_text); + return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status.as_u16(), + body: body_text, + }.into()); + } + + let parsed: GmiCloudPollResponse = serde_json::from_str(&body_text) + .map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?; + + Ok(parsed) +} diff --git a/crates/api_clients/gmicloud_client/src/requests/mod.rs b/crates/api_clients/gmicloud_client/src/requests/mod.rs new file mode 100644 index 0000000000..482ec3cddc --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod common; diff --git a/crates/api_clients/gmicloud_client/src/test_utils/mod.rs b/crates/api_clients/gmicloud_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..8749608c6c --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/test_utils/mod.rs @@ -0,0 +1,10 @@ +use crate::creds::gmicloud_api_key::GmiCloudApiKey; +use std::fs::read_to_string; + +const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/gmicloud_api_key.txt"; + +pub fn load_api_key() -> GmiCloudApiKey { + let secret = read_to_string(API_KEY_PATH) + .expect("Failed to read GmiCloud API key file"); + GmiCloudApiKey::from_str(&secret) +} diff --git a/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs b/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs new file mode 100644 index 0000000000..ad5b8cd1ad --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/traits/gmicloud_request_cost_calculator_trait.rs @@ -0,0 +1,7 @@ +/// Cost in US cents (1¢ = 1 unit). +pub type UsdCents = u64; + +/// Implemented on request types to estimate the cost of a GmiCloud API call. +pub trait GmiCloudRequestCostCalculator { + fn calculate_cost_in_cents(&self) -> UsdCents; +} diff --git a/crates/api_clients/gmicloud_client/src/traits/mod.rs b/crates/api_clients/gmicloud_client/src/traits/mod.rs new file mode 100644 index 0000000000..b6a09dfdfa --- /dev/null +++ b/crates/api_clients/gmicloud_client/src/traits/mod.rs @@ -0,0 +1 @@ +pub mod gmicloud_request_cost_calculator_trait;