gmicloud client

This commit is contained in:
Brandon Thomas committed 2026-05-15 19:31:00 -04:00
1 parent 00132478b1
commit 78d97401fc
28 files changed
+1059

No files matched your search

Generated
+16
View File
@@ -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"
+2
View File
@@ -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" }
@@ -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"] }
@@ -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
}
}
@@ -0,0 +1 @@
pub mod gmicloud_api_key;
@@ -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<reqwest::Error> for GmiCloudClientError {
fn from(err: reqwest::Error) -> Self {
Self::ReqwestError(err)
}
}
@@ -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<GmiCloudClientError> for GmiCloudError {
fn from(err: GmiCloudClientError) -> Self {
Self::Client(err)
}
}
impl From<GmiCloudSpecificApiError> for GmiCloudError {
fn from(err: GmiCloudSpecificApiError) -> Self {
Self::ApiSpecific(err)
}
}
impl From<GmiCloudGenericApiError> for GmiCloudError {
fn from(err: GmiCloudGenericApiError) -> Self {
Self::ApiGeneric(err)
}
}
@@ -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<reqwest::Error> for GmiCloudGenericApiError {
fn from(err: reqwest::Error) -> Self {
Self::ReqwestError(err)
}
}
@@ -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)
}
}
@@ -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;
@@ -0,0 +1,7 @@
pub mod creds;
pub mod error;
pub mod requests;
pub mod traits;
#[cfg(test)]
mod test_utils;
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1,2 @@
pub mod seedance_2_0_260128;
pub mod seedance_2_0_fast_260128;
@@ -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<Seedance20Duration>,
/// Aspect ratio of the output video.
pub aspect_ratio: Option<Seedance20AspectRatio>,
/// Negative prompt (terms to avoid in generation).
pub negative_prompt: Option<String>,
/// An image URL for image-to-video generation.
pub start_frame_url: Option<String>,
/// Seed for deterministic generation.
pub seed: Option<u64>,
}
#[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<GmiCloudCreateResponse, GmiCloudError> {
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
}
@@ -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<Seedance20Duration>) -> 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:?}");
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
mod raw_request;
mod cost;
@@ -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<String>,
/// 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<String>,
/// Negative prompt (terms to avoid).
#[serde(rename = "negativePrompt")]
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// 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<String>,
/// Seed for deterministic generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
@@ -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<Seedance20FastDuration>,
/// Aspect ratio of the output video.
pub aspect_ratio: Option<Seedance20FastAspectRatio>,
/// Negative prompt (terms to avoid in generation).
pub negative_prompt: Option<String>,
/// An image URL for image-to-video generation.
pub start_frame_url: Option<String>,
/// Seed for deterministic generation.
pub seed: Option<u64>,
}
#[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<GmiCloudCreateResponse, GmiCloudError> {
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
}
@@ -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<Seedance20FastDuration>) -> 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(),
);
}
}
@@ -0,0 +1,3 @@
pub mod api;
mod raw_request;
mod cost;
@@ -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<String>,
/// 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<String>,
/// Negative prompt (terms to avoid).
#[serde(rename = "negativePrompt")]
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
/// 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<String>,
/// Seed for deterministic generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
@@ -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<P: Serialize> {
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<P: Serialize>(
api_key: &GmiCloudApiKey,
body: &GmiCloudCreateRequest<P>,
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
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)
}
@@ -0,0 +1,2 @@
pub mod create_request;
pub mod poll_request;
@@ -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<GmiCloudOutcome>,
}
/// The outcome of a completed request.
#[derive(Debug, Deserialize)]
pub struct GmiCloudOutcome {
pub video_url: Option<String>,
pub thumbnail_image_url: Option<String>,
}
/// Poll the status of a GmiCloud request.
pub async fn poll_gmicloud_request(
api_key: &GmiCloudApiKey,
request_id: &str,
) -> Result<GmiCloudPollResponse, GmiCloudError> {
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)
}
@@ -0,0 +1,2 @@
pub mod api;
pub mod common;
@@ -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)
}
@@ -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;
}
@@ -0,0 +1 @@
pub mod gmicloud_request_cost_calculator_trait;