Compare commits

...
Author SHA1 Message Date
Brandon Thomas 81f50b329b pricing 2026-05-16 08:36:38 -04:00
Brandon Thomas 6438d7499e move 2026-05-16 08:24:17 -04:00
Brandon Thomas 47b7bf18cd new method 2026-05-16 08:19:42 -04:00
Brandon Thomas ee06475417 no 1080p for fast 2026-05-16 07:46:07 -04:00
Brandon Thomas 6e0754c5b2 pricing 2026-05-16 07:40:44 -04:00
Brandon Thomas 5ad8287460 tests 2026-05-16 06:39:18 -04:00
Brandon Thomas 70d7b932f0 update interface 2026-05-15 19:52:28 -04:00
Brandon Thomas 78d97401fc gmicloud client 2026-05-15 19:31:00 -04:00
29 changed files with 1706 additions and 0 deletions
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,388 @@
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. Required.
pub prompt: String,
/// Video duration in seconds (4–15). Default: 5.
pub duration: Option<u8>,
/// Output resolution.
pub resolution: Option<Seedance20Resolution>,
/// Aspect ratio of the output video.
pub ratio: Option<Seedance20Ratio>,
/// Random seed for reproducibility (0–4294967295).
pub seed: Option<u32>,
/// Whether to embed a watermark. Default: false.
pub watermark: Option<bool>,
/// Whether to synthesize audio. Default: true.
pub generate_audio: Option<bool>,
/// Whether to enable web search grounding. Default: false.
pub web_search: Option<bool>,
/// First frame image URL for image-to-video generation.
pub first_frame: Option<String>,
/// Last frame image URL for image-to-video generation.
pub last_frame: Option<String>,
/// Reference image URLs.
pub reference_images: Option<Vec<String>>,
/// Reference video URLs.
pub reference_videos: Option<Vec<String>>,
/// Reference audio file URLs.
pub reference_audios: Option<Vec<String>>,
/// Pre-uploaded asset IDs.
pub reference_asset_ids: Option<Vec<String>>,
}
#[derive(Copy, Clone, Debug)]
pub enum Seedance20Resolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
#[derive(Copy, Clone, Debug)]
pub enum Seedance20Ratio {
/// 16:9 (landscape)
Landscape16x9,
/// 4:3
Standard4x3,
/// 1:1 (square)
Square,
/// 3:4
Portrait3x4,
/// 9:16 (portrait)
Portrait9x16,
/// 21:9 (ultra-wide)
UltraWide21x9,
/// Adaptive (inferred from input)
Adaptive,
}
impl Seedance20Request {
pub fn to_raw_payload(&self) -> Seedance20Payload {
Seedance20Payload {
prompt: self.prompt.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| match r {
Seedance20Resolution::FourEightyP => "480p",
Seedance20Resolution::SevenTwentyP => "720p",
Seedance20Resolution::TenEightyP => "1080p",
}.to_string()),
ratio: self.ratio.map(|r| match r {
Seedance20Ratio::Landscape16x9 => "16:9",
Seedance20Ratio::Standard4x3 => "4:3",
Seedance20Ratio::Square => "1:1",
Seedance20Ratio::Portrait3x4 => "3:4",
Seedance20Ratio::Portrait9x16 => "9:16",
Seedance20Ratio::UltraWide21x9 => "21:9",
Seedance20Ratio::Adaptive => "adaptive",
}.to_string()),
seed: self.seed,
watermark: self.watermark,
generate_audio: self.generate_audio,
web_search: self.web_search,
first_frame: self.first_frame.clone(),
last_frame: self.last_frame.clone(),
reference_images: self.reference_images.clone(),
reference_videos: self.reference_videos.clone(),
reference_audios: self.reference_audios.clone(),
reference_asset_ids: self.reference_asset_ids.clone(),
}
}
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
}
/// The effective duration in seconds for cost calculation.
pub fn effective_duration_seconds(&self) -> u8 {
self.duration.unwrap_or(5)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_request() -> Seedance20Request {
Seedance20Request {
prompt: "a dog running through a field".to_string(),
duration: None,
resolution: None,
ratio: None,
seed: None,
watermark: None,
generate_audio: None,
web_search: None,
first_frame: None,
last_frame: None,
reference_images: None,
reference_videos: None,
reference_audios: None,
reference_asset_ids: None,
}
}
mod raw_payload_tests {
use super::*;
#[test]
fn minimal_request_serializes_only_prompt() {
let request = minimal_request();
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["prompt"], "a dog running through a field");
// All optional fields should be absent
assert!(json.get("duration").is_none());
assert!(json.get("resolution").is_none());
assert!(json.get("ratio").is_none());
assert!(json.get("seed").is_none());
assert!(json.get("watermark").is_none());
assert!(json.get("generate_audio").is_none());
assert!(json.get("web_search").is_none());
assert!(json.get("first_frame").is_none());
assert!(json.get("last_frame").is_none());
assert!(json.get("reference_images").is_none());
assert!(json.get("reference_videos").is_none());
assert!(json.get("reference_audios").is_none());
assert!(json.get("reference_asset_ids").is_none());
}
#[test]
fn full_request_serializes_all_fields() {
let request = Seedance20Request {
prompt: "a cat sitting on a windowsill".to_string(),
duration: Some(10),
resolution: Some(Seedance20Resolution::TenEightyP),
ratio: Some(Seedance20Ratio::Landscape16x9),
seed: Some(42),
watermark: Some(false),
generate_audio: Some(true),
web_search: Some(true),
first_frame: Some("https://example.com/first.png".to_string()),
last_frame: Some("https://example.com/last.png".to_string()),
reference_images: Some(vec!["https://example.com/ref1.png".to_string()]),
reference_videos: Some(vec!["https://example.com/ref1.mp4".to_string()]),
reference_audios: Some(vec!["https://example.com/ref1.wav".to_string()]),
reference_asset_ids: Some(vec!["asset_123".to_string()]),
};
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["prompt"], "a cat sitting on a windowsill");
assert_eq!(json["duration"], 10);
assert_eq!(json["resolution"], "1080p");
assert_eq!(json["ratio"], "16:9");
assert_eq!(json["seed"], 42);
assert_eq!(json["watermark"], false);
assert_eq!(json["generate_audio"], true);
assert_eq!(json["web_search"], true);
assert_eq!(json["first_frame"], "https://example.com/first.png");
assert_eq!(json["last_frame"], "https://example.com/last.png");
assert_eq!(json["reference_images"][0], "https://example.com/ref1.png");
assert_eq!(json["reference_videos"][0], "https://example.com/ref1.mp4");
assert_eq!(json["reference_audios"][0], "https://example.com/ref1.wav");
assert_eq!(json["reference_asset_ids"][0], "asset_123");
}
#[test]
fn all_ratios_serialize() {
let cases = [
(Seedance20Ratio::Landscape16x9, "16:9"),
(Seedance20Ratio::Standard4x3, "4:3"),
(Seedance20Ratio::Square, "1:1"),
(Seedance20Ratio::Portrait3x4, "3:4"),
(Seedance20Ratio::Portrait9x16, "9:16"),
(Seedance20Ratio::UltraWide21x9, "21:9"),
(Seedance20Ratio::Adaptive, "adaptive"),
];
for (ratio, expected) in cases {
let mut request = minimal_request();
request.ratio = Some(ratio);
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["ratio"], expected, "{ratio:?}");
}
}
#[test]
fn all_resolutions_serialize() {
let cases = [
(Seedance20Resolution::FourEightyP, "480p"),
(Seedance20Resolution::SevenTwentyP, "720p"),
(Seedance20Resolution::TenEightyP, "1080p"),
];
for (resolution, expected) in cases {
let mut request = minimal_request();
request.resolution = Some(resolution);
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["resolution"], expected, "{resolution:?}");
}
}
#[test]
fn duration_is_integer() {
let mut request = minimal_request();
request.duration = Some(7);
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["duration"], 7);
assert!(json["duration"].is_number());
}
#[test]
fn create_request_body_shape() {
let mut request = minimal_request();
request.prompt = "test".to_string();
request.duration = Some(5);
request.ratio = Some(Seedance20Ratio::Square);
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"]["ratio"], "1:1");
}
#[test]
fn image_to_video_with_first_frame() {
let mut request = minimal_request();
request.first_frame = Some("https://example.com/cat.png".to_string());
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["first_frame"], "https://example.com/cat.png");
assert!(json.get("last_frame").is_none());
}
#[test]
fn keyframe_with_first_and_last() {
let mut request = minimal_request();
request.first_frame = Some("https://example.com/start.png".to_string());
request.last_frame = Some("https://example.com/end.png".to_string());
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["first_frame"], "https://example.com/start.png");
assert_eq!(json["last_frame"], "https://example.com/end.png");
}
}
mod effective_duration_tests {
use super::*;
#[test]
fn defaults_to_five() {
assert_eq!(minimal_request().effective_duration_seconds(), 5);
}
#[test]
fn respects_explicit_value() {
let mut request = minimal_request();
request.duration = Some(12);
assert_eq!(request.effective_duration_seconds(), 12);
}
}
mod live_api_tests {
use super::*;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[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 mut request = minimal_request();
request.prompt = "a golden retriever puppy playing in autumn leaves".to_string();
request.duration = Some(5);
request.ratio = Some(Seedance20Ratio::Landscape16x9);
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);
}
// Balance was $49.75 before running this.
// [480p] Request ID: 4a0efe0d-990f-480d-9792-5bb71dd26b85
// Balance was $49.51 after running this.
// 24 cents = 2.4 cents per second
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let api_key = crate::test_utils::load_api_key();
let mut request = minimal_request();
request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string();
request.duration = Some(10);
request.resolution = Some(Seedance20Resolution::FourEightyP);
request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string());
let result = request.send_request(&api_key).await.unwrap();
println!("[480p] Request ID: {}", result.request_id);
assert!(!result.request_id.is_empty());
assert_eq!(result.model, MODEL_ID);
}
// Balance was $49.51 before running this.
// [720p] Request ID: 40f63632-dd9d-4725-ab73-5bb764e773be
// Balance was $48.99 after running this.
// 52 cents = 5.2 cents per second
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_720p() {
let api_key = crate::test_utils::load_api_key();
let mut request = minimal_request();
request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string();
request.duration = Some(10);
request.resolution = Some(Seedance20Resolution::SevenTwentyP);
request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string());
let result = request.send_request(&api_key).await.unwrap();
println!("[720p] Request ID: {}", result.request_id);
assert!(!result.request_id.is_empty());
assert_eq!(result.model, MODEL_ID);
}
// Balance was $48.99 before running this.
// Balance is $47.83 after running this
// 116 cents = 11.6 cents per second
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_1080p() {
let api_key = crate::test_utils::load_api_key();
let mut request = minimal_request();
request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string();
request.duration = Some(10);
request.resolution = Some(Seedance20Resolution::TenEightyP);
request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string());
let result = request.send_request(&api_key).await.unwrap();
println!("[1080p] 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,163 @@
use crate::requests::api::video::seedance_2_0_260128::api::{
Seedance20Request, Seedance20Resolution,
};
use crate::traits::gmicloud_request_cost_calculator_trait::{
GmiCloudRequestCostCalculator, UsdCents,
};
impl GmiCloudRequestCostCalculator for Seedance20Request {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_seconds = self.effective_duration_seconds() as u64;
// Cost per second in tenths of a US cent, by resolution.
// Observed from GmiCloud billing:
// 480p: $0.024/s = 2.4 tenths/s
// 720p: $0.052/s = 5.2 tenths/s (default)
// 1080p: $0.116/s = 11.6 tenths/s
let tenths_per_second: f64 = match self.resolution {
Some(Seedance20Resolution::FourEightyP) => 2.4,
Some(Seedance20Resolution::SevenTwentyP) | None => 5.2,
Some(Seedance20Resolution::TenEightyP) => 11.6,
};
let tenths = (tenths_per_second * duration_seconds as f64).ceil() as u64;
tenths.div_ceil(10)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::seedance_2_0_260128::api::Seedance20Ratio;
fn make_request(duration: Option<u8>, resolution: Option<Seedance20Resolution>) -> Seedance20Request {
Seedance20Request {
prompt: "test".to_string(),
duration,
resolution,
ratio: None,
seed: None,
watermark: None,
generate_audio: None,
web_search: None,
first_frame: None,
last_frame: None,
reference_images: None,
reference_videos: None,
reference_audios: None,
reference_asset_ids: None,
}
}
mod default_resolution_tests {
use super::*;
#[test]
fn cost_default_5s() {
// 720p default: 5.2 * 5 = 26 tenths → 3 cents
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 3);
}
#[test]
fn cost_default_10s() {
// 5.2 * 10 = 52 tenths → 6 cents
assert_eq!(make_request(Some(10), None).calculate_cost_in_cents(), 6);
}
#[test]
fn cost_default_15s() {
// 5.2 * 15 = 78 tenths → 8 cents
assert_eq!(make_request(Some(15), None).calculate_cost_in_cents(), 8);
}
}
mod resolution_480p_tests {
use super::*;
#[test]
fn cost_480p_5s() {
// 2.4 * 5 = 12 tenths → 2 cents
assert_eq!(make_request(Some(5), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 2);
}
#[test]
fn cost_480p_10s() {
// 2.4 * 10 = 24 tenths → 3 cents
assert_eq!(make_request(Some(10), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 3);
}
#[test]
fn cost_480p_15s() {
// 2.4 * 15 = 36 tenths → 4 cents
assert_eq!(make_request(Some(15), Some(Seedance20Resolution::FourEightyP)).calculate_cost_in_cents(), 4);
}
}
mod resolution_720p_tests {
use super::*;
#[test]
fn cost_720p_5s() {
// 5.2 * 5 = 26 tenths → 3 cents
assert_eq!(make_request(Some(5), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 3);
}
#[test]
fn cost_720p_10s() {
// 5.2 * 10 = 52 tenths → 6 cents
assert_eq!(make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 6);
}
#[test]
fn cost_720p_15s() {
// 5.2 * 15 = 78 tenths → 8 cents
assert_eq!(make_request(Some(15), Some(Seedance20Resolution::SevenTwentyP)).calculate_cost_in_cents(), 8);
}
}
mod resolution_1080p_tests {
use super::*;
#[test]
fn cost_1080p_5s() {
// 11.6 * 5 = 58 tenths → 6 cents
assert_eq!(make_request(Some(5), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 6);
}
#[test]
fn cost_1080p_10s() {
// 11.6 * 10 = 116 tenths → 12 cents
assert_eq!(make_request(Some(10), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 12);
}
#[test]
fn cost_1080p_15s() {
// 11.6 * 15 = 174 tenths → 18 cents
assert_eq!(make_request(Some(15), Some(Seedance20Resolution::TenEightyP)).calculate_cost_in_cents(), 18);
}
}
mod ratio_independence_tests {
use super::*;
#[test]
fn cost_is_independent_of_ratio() {
let ratios = [
Seedance20Ratio::Landscape16x9,
Seedance20Ratio::Portrait9x16,
Seedance20Ratio::Square,
Seedance20Ratio::Standard4x3,
Seedance20Ratio::Portrait3x4,
Seedance20Ratio::UltraWide21x9,
Seedance20Ratio::Adaptive,
];
let base = make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP))
.calculate_cost_in_cents();
for ratio in ratios {
let mut request = make_request(Some(10), Some(Seedance20Resolution::SevenTwentyP));
request.ratio = Some(ratio);
assert_eq!(request.calculate_cost_in_cents(), base, "{ratio:?}");
}
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
mod raw_request;
mod cost;
@@ -0,0 +1,59 @@
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 (4–15). Default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution: "480p", "720p", "1080p". Default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio: "16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive".
#[serde(skip_serializing_if = "Option::is_none")]
pub ratio: Option<String>,
/// Random seed for reproducibility (0–4294967295).
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u32>,
/// Whether to embed a watermark. Default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub watermark: Option<bool>,
/// Whether to synthesize audio. Default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to enable web search grounding. Default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search: Option<bool>,
/// First frame image URL for image-to-video generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub first_frame: Option<String>,
/// Last frame image URL for image-to-video generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_frame: Option<String>,
/// Reference image URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_images: Option<Vec<String>>,
/// Reference video URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_videos: Option<Vec<String>>,
/// Reference audio file URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_audios: Option<Vec<String>>,
/// Pre-uploaded asset IDs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_asset_ids: Option<Vec<String>>,
}
@@ -0,0 +1,293 @@
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. Required.
pub prompt: String,
/// Video duration in seconds (4–15). Default: 5.
pub duration: Option<u8>,
/// Output resolution.
pub resolution: Option<Seedance20FastResolution>,
/// Aspect ratio of the output video.
pub ratio: Option<Seedance20FastRatio>,
/// Random seed for reproducibility (0–4294967295).
pub seed: Option<u32>,
/// Whether to embed a watermark. Default: false.
pub watermark: Option<bool>,
/// Whether to synthesize audio. Default: true.
pub generate_audio: Option<bool>,
/// Whether to enable web search grounding. Default: false.
pub web_search: Option<bool>,
/// First frame image URL for image-to-video generation.
pub first_frame: Option<String>,
/// Last frame image URL for image-to-video generation.
pub last_frame: Option<String>,
/// Reference image URLs.
pub reference_images: Option<Vec<String>>,
/// Reference video URLs.
pub reference_videos: Option<Vec<String>>,
/// Reference audio file URLs.
pub reference_audios: Option<Vec<String>>,
/// Pre-uploaded asset IDs.
pub reference_asset_ids: Option<Vec<String>>,
}
#[derive(Copy, Clone, Debug)]
pub enum Seedance20FastResolution {
FourEightyP,
SevenTwentyP,
}
#[derive(Copy, Clone, Debug)]
pub enum Seedance20FastRatio {
/// 16:9 (landscape)
Landscape16x9,
/// 4:3
Standard4x3,
/// 1:1 (square)
Square,
/// 3:4
Portrait3x4,
/// 9:16 (portrait)
Portrait9x16,
/// 21:9 (ultra-wide)
UltraWide21x9,
/// Adaptive (inferred from input)
Adaptive,
}
impl Seedance20FastRequest {
pub fn to_raw_payload(&self) -> Seedance20FastPayload {
Seedance20FastPayload {
prompt: self.prompt.clone(),
duration: self.duration,
resolution: self.resolution.map(|r| match r {
Seedance20FastResolution::FourEightyP => "480p",
Seedance20FastResolution::SevenTwentyP => "720p",
}.to_string()),
ratio: self.ratio.map(|r| match r {
Seedance20FastRatio::Landscape16x9 => "16:9",
Seedance20FastRatio::Standard4x3 => "4:3",
Seedance20FastRatio::Square => "1:1",
Seedance20FastRatio::Portrait3x4 => "3:4",
Seedance20FastRatio::Portrait9x16 => "9:16",
Seedance20FastRatio::UltraWide21x9 => "21:9",
Seedance20FastRatio::Adaptive => "adaptive",
}.to_string()),
seed: self.seed,
watermark: self.watermark,
generate_audio: self.generate_audio,
web_search: self.web_search,
first_frame: self.first_frame.clone(),
last_frame: self.last_frame.clone(),
reference_images: self.reference_images.clone(),
reference_videos: self.reference_videos.clone(),
reference_audios: self.reference_audios.clone(),
reference_asset_ids: self.reference_asset_ids.clone(),
}
}
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
}
/// The effective duration in seconds for cost calculation.
pub fn effective_duration_seconds(&self) -> u8 {
self.duration.unwrap_or(5)
}
}
fn minimal_request() -> Seedance20FastRequest {
Seedance20FastRequest {
prompt: "test".to_string(),
duration: None,
resolution: None,
ratio: None,
seed: None,
watermark: None,
generate_audio: None,
web_search: None,
first_frame: None,
last_frame: None,
reference_images: None,
reference_videos: None,
reference_audios: None,
reference_asset_ids: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
mod raw_payload_tests {
use super::*;
#[test]
fn minimal_request_serializes_only_prompt() {
let request = minimal_request();
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["prompt"], "test");
assert!(json.get("duration").is_none());
assert!(json.get("resolution").is_none());
assert!(json.get("ratio").is_none());
}
#[test]
fn full_request_serializes_all_fields() {
let request = Seedance20FastRequest {
prompt: "a cat sitting on a windowsill".to_string(),
duration: Some(10),
resolution: Some(Seedance20FastResolution::SevenTwentyP),
ratio: Some(Seedance20FastRatio::Portrait9x16),
seed: Some(123),
watermark: Some(true),
generate_audio: Some(false),
web_search: Some(true),
first_frame: Some("https://example.com/first.png".to_string()),
last_frame: Some("https://example.com/last.png".to_string()),
reference_images: Some(vec!["https://example.com/ref.png".to_string()]),
reference_videos: Some(vec!["https://example.com/ref.mp4".to_string()]),
reference_audios: Some(vec!["https://example.com/ref.wav".to_string()]),
reference_asset_ids: Some(vec!["asset_456".to_string()]),
};
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["prompt"], "a cat sitting on a windowsill");
assert_eq!(json["duration"], 10);
assert_eq!(json["resolution"], "720p");
assert_eq!(json["ratio"], "9:16");
assert_eq!(json["seed"], 123);
assert_eq!(json["watermark"], true);
assert_eq!(json["generate_audio"], false);
assert_eq!(json["web_search"], true);
assert_eq!(json["first_frame"], "https://example.com/first.png");
assert_eq!(json["last_frame"], "https://example.com/last.png");
}
#[test]
fn all_ratios_serialize() {
let cases = [
(Seedance20FastRatio::Landscape16x9, "16:9"),
(Seedance20FastRatio::Standard4x3, "4:3"),
(Seedance20FastRatio::Square, "1:1"),
(Seedance20FastRatio::Portrait3x4, "3:4"),
(Seedance20FastRatio::Portrait9x16, "9:16"),
(Seedance20FastRatio::UltraWide21x9, "21:9"),
(Seedance20FastRatio::Adaptive, "adaptive"),
];
for (ratio, expected) in cases {
let mut request = minimal_request();
request.ratio = Some(ratio);
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
assert_eq!(json["ratio"], expected, "{ratio:?}");
}
}
#[test]
fn create_request_body_shape() {
let mut request = minimal_request();
request.duration = Some(5);
request.ratio = Some(Seedance20FastRatio::Square);
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"]["ratio"], "1:1");
}
}
mod live_api_tests {
use super::*;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
#[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 mut request = minimal_request();
request.prompt = "a golden retriever puppy playing in autumn leaves".to_string();
request.duration = Some(5);
request.ratio = Some(Seedance20FastRatio::Landscape16x9);
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);
}
// $47.83 before run
// [fast 480p] Request ID: 1b654a85-d99e-4380-b615-c7a8985ed15b
// $47.73 after run
// $0.10 = $0.01 per second
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let api_key = crate::test_utils::load_api_key();
let mut request = minimal_request();
request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string();
request.duration = Some(10);
request.resolution = Some(Seedance20FastResolution::FourEightyP);
request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string());
let result = request.send_request(&api_key).await.unwrap();
println!("[fast 480p] Request ID: {}", result.request_id);
assert!(!result.request_id.is_empty());
assert_eq!(result.model, MODEL_ID);
}
// $47.73 before run
// [fast 720p] Request ID: 2fbbe377-7c92-482e-b1eb-92fd296b0a68
// $47.52 after run
// $0.31 = $0.031 per second
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_720p() {
let api_key = crate::test_utils::load_api_key();
let mut request = minimal_request();
request.prompt = "the dog in this photo starts running and splashing in the lake water".to_string();
request.duration = Some(10);
request.resolution = Some(Seedance20FastResolution::SevenTwentyP);
request.first_frame = Some(JUNO_AT_LAKE_IMAGE_URL.to_string());
let result = request.send_request(&api_key).await.unwrap();
println!("[fast 720p] 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,186 @@
use crate::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRequest, Seedance20FastResolution,
};
use crate::traits::gmicloud_request_cost_calculator_trait::{
GmiCloudRequestCostCalculator, UsdCents,
};
impl GmiCloudRequestCostCalculator for Seedance20FastRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
let duration_seconds = self.effective_duration_seconds() as u64;
// Cost per second in tenths of a US cent, by resolution.
// Observed from GmiCloud billing:
// 480p: $0.01/s = 1.0 tenths/s
// 720p: $0.031/s = 3.1 tenths/s (default)
let tenths_per_second: f64 = match self.resolution {
Some(Seedance20FastResolution::FourEightyP) => 1.0,
Some(Seedance20FastResolution::SevenTwentyP) | None => 3.1,
};
let tenths = (tenths_per_second * duration_seconds as f64).ceil() as u64;
tenths.div_ceil(10)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRatio;
fn make_request(duration: Option<u8>, resolution: Option<Seedance20FastResolution>) -> Seedance20FastRequest {
Seedance20FastRequest {
prompt: "test".to_string(),
duration,
resolution,
ratio: None,
seed: None,
watermark: None,
generate_audio: None,
web_search: None,
first_frame: None,
last_frame: None,
reference_images: None,
reference_videos: None,
reference_audios: None,
reference_asset_ids: None,
}
}
mod default_resolution_tests {
use super::*;
#[test]
fn cost_default_5s() {
// 720p default: 3.1 * 5 = 15.5 → ceil = 16 tenths → 2 cents
assert_eq!(make_request(None, None).calculate_cost_in_cents(), 2);
}
#[test]
fn cost_default_10s() {
// 3.1 * 10 = 31 tenths → 4 cents
assert_eq!(make_request(Some(10), None).calculate_cost_in_cents(), 4);
}
#[test]
fn cost_default_15s() {
// 3.1 * 15 = 46.5 → ceil = 47 tenths → 5 cents
assert_eq!(make_request(Some(15), None).calculate_cost_in_cents(), 5);
}
}
mod resolution_480p_tests {
use super::*;
#[test]
fn cost_480p_5s() {
// 1.0 * 5 = 5 tenths → 1 cent
assert_eq!(make_request(Some(5), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 1);
}
#[test]
fn cost_480p_10s() {
// 1.0 * 10 = 10 tenths → 1 cent
assert_eq!(make_request(Some(10), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 1);
}
#[test]
fn cost_480p_15s() {
// 1.0 * 15 = 15 tenths → 2 cents
assert_eq!(make_request(Some(15), Some(Seedance20FastResolution::FourEightyP)).calculate_cost_in_cents(), 2);
}
}
mod resolution_720p_tests {
use super::*;
#[test]
fn cost_720p_5s() {
// 3.1 * 5 = 15.5 → ceil = 16 tenths → 2 cents
assert_eq!(make_request(Some(5), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 2);
}
#[test]
fn cost_720p_10s() {
// 3.1 * 10 = 31 tenths → 4 cents
assert_eq!(make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 4);
}
#[test]
fn cost_720p_15s() {
// 3.1 * 15 = 46.5 → ceil = 47 tenths → 5 cents
assert_eq!(make_request(Some(15), Some(Seedance20FastResolution::SevenTwentyP)).calculate_cost_in_cents(), 5);
}
}
mod ratio_independence_tests {
use super::*;
#[test]
fn cost_is_independent_of_ratio() {
let ratios = [
Seedance20FastRatio::Landscape16x9,
Seedance20FastRatio::Portrait9x16,
Seedance20FastRatio::Square,
Seedance20FastRatio::Standard4x3,
Seedance20FastRatio::Portrait3x4,
Seedance20FastRatio::UltraWide21x9,
Seedance20FastRatio::Adaptive,
];
let base = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP))
.calculate_cost_in_cents();
for ratio in ratios {
let mut request = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP));
request.ratio = Some(ratio);
assert_eq!(request.calculate_cost_in_cents(), base, "{ratio:?}");
}
}
}
mod comparison_tests {
use super::*;
#[test]
fn fast_is_cheaper_than_standard_at_720p() {
use crate::requests::api::video::seedance_2_0_260128::api::{
Seedance20Request, Seedance20Resolution,
};
use crate::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
let standard = Seedance20Request {
prompt: "test".to_string(),
duration: Some(10),
resolution: Some(Seedance20Resolution::SevenTwentyP),
ratio: None,
seed: None,
watermark: None,
generate_audio: None,
web_search: None,
first_frame: None,
last_frame: None,
reference_images: None,
reference_videos: None,
reference_audios: None,
reference_asset_ids: None,
};
let fast = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP));
assert!(
fast.calculate_cost_in_cents() < standard.calculate_cost_in_cents(),
"Fast 720p ({}¢) should be cheaper than Standard 720p ({}¢)",
fast.calculate_cost_in_cents(),
standard.calculate_cost_in_cents(),
);
}
#[test]
fn higher_resolution_costs_more() {
let cost_480 = make_request(Some(10), Some(Seedance20FastResolution::FourEightyP))
.calculate_cost_in_cents();
let cost_720 = make_request(Some(10), Some(Seedance20FastResolution::SevenTwentyP))
.calculate_cost_in_cents();
assert!(cost_480 < cost_720, "480p ({}¢) should be cheaper than 720p ({}¢)", cost_480, cost_720);
}
}
}
@@ -0,0 +1,3 @@
pub mod api;
mod raw_request;
mod cost;
@@ -0,0 +1,59 @@
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 (4–15). Default: 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u8>,
/// Output resolution: "480p", "720p", "1080p". Default: "720p".
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
/// Aspect ratio: "16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive".
#[serde(skip_serializing_if = "Option::is_none")]
pub ratio: Option<String>,
/// Random seed for reproducibility (0–4294967295).
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u32>,
/// Whether to embed a watermark. Default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub watermark: Option<bool>,
/// Whether to synthesize audio. Default: true.
#[serde(skip_serializing_if = "Option::is_none")]
pub generate_audio: Option<bool>,
/// Whether to enable web search grounding. Default: false.
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search: Option<bool>,
/// First frame image URL for image-to-video generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub first_frame: Option<String>,
/// Last frame image URL for image-to-video generation.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_frame: Option<String>,
/// Reference image URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_images: Option<Vec<String>>,
/// Reference video URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_videos: Option<Vec<String>>,
/// Reference audio file URLs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_audios: Option<Vec<String>>,
/// Pre-uploaded asset IDs.
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_asset_ids: Option<Vec<String>>,
}
@@ -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 @@
pub mod create_request;
@@ -0,0 +1,3 @@
pub mod api;
pub mod common;
pub mod poll_request_queue;
@@ -0,0 +1 @@
pub mod poll_request_queue;
@@ -0,0 +1,281 @@
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 org_id: Option<String>,
pub user_id: Option<String>,
pub model: String,
pub status: String,
pub is_public: Option<bool>,
pub payload: Option<serde_json::Value>,
pub outcome: Option<GmiCloudOutcome>,
pub created_at: Option<u64>,
pub updated_at: Option<u64>,
pub queued_at: Option<u64>,
}
/// The outcome of a completed request.
#[derive(Debug, Deserialize, PartialEq)]
pub struct GmiCloudOutcome {
pub video_url: Option<String>,
pub thumbnail_image_url: Option<String>,
}
impl GmiCloudPollResponse {
/// Whether the request has completed successfully.
pub fn is_success(&self) -> bool {
self.status == "success"
}
/// Whether the request is still in progress (dispatched or processing).
pub fn is_in_progress(&self) -> bool {
self.status == "dispatched" || self.status == "processing"
}
/// Whether the request has failed.
pub fn is_failed(&self) -> bool {
self.status == "failed"
}
/// Get the video URL if the request completed successfully.
pub fn video_url(&self) -> Option<&str> {
self.outcome.as_ref()?.video_url.as_deref()
}
/// Get the thumbnail URL if the request completed successfully.
pub fn thumbnail_url(&self) -> Option<&str> {
self.outcome.as_ref()?.thumbnail_image_url.as_deref()
}
}
/// 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)
}
#[cfg(test)]
mod tests {
use super::*;
mod deserialization_tests {
use super::*;
#[test]
fn deserialize_success_response() {
let json = r#"{
"request_id": "40f63632-dd9d-4725-ab73-5bb764e773be",
"org_id": "3e7939ae-467e-4c5d-ae35-fd6097cee454",
"user_id": "07e393c5-f2f1-45fe-8838-cc4046837693",
"model": "seedance-2-0-260128",
"status": "success",
"is_public": false,
"payload": {
"duration": 10,
"first_frame": "https://example.com/image.jpg",
"prompt": "a dog running",
"resolution": "720p"
},
"outcome": {
"thumbnail_image_url": "https://example.com/thumb.jpg",
"video_url": "https://example.com/video.mp4"
},
"created_at": 1778928208,
"updated_at": 1778928397,
"queued_at": 1778928208
}"#;
let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.request_id, "40f63632-dd9d-4725-ab73-5bb764e773be");
assert_eq!(response.model, "seedance-2-0-260128");
assert!(response.is_success());
assert!(!response.is_in_progress());
assert!(!response.is_failed());
assert_eq!(response.video_url(), Some("https://example.com/video.mp4"));
assert_eq!(response.thumbnail_url(), Some("https://example.com/thumb.jpg"));
assert_eq!(response.created_at, Some(1778928208));
assert_eq!(response.updated_at, Some(1778928397));
}
#[test]
fn deserialize_processing_response() {
let json = r#"{
"request_id": "6fb2b474-fb3a-480f-a142-f200537c6de4",
"org_id": "63729242-1870-4ff4-80b0-d485980ae31c",
"model": "seedance-2-0-260128",
"status": "processing",
"is_public": false,
"payload": {
"prompt": "a dog running",
"duration": 5
},
"outcome": null,
"created_at": 1753468514,
"updated_at": 1753468514,
"queued_at": 1753468514
}"#;
let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap();
assert!(!response.is_success());
assert!(response.is_in_progress());
assert!(!response.is_failed());
assert_eq!(response.video_url(), None);
assert_eq!(response.outcome, None);
}
#[test]
fn deserialize_dispatched_response() {
let json = r#"{
"request_id": "abc",
"model": "seedance-2-0-fast-260128",
"status": "dispatched",
"outcome": null
}"#;
let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap();
assert!(response.is_in_progress());
assert!(!response.is_success());
}
#[test]
fn deserialize_failed_response() {
let json = r#"{
"request_id": "abc",
"model": "seedance-2-0-260128",
"status": "failed",
"outcome": null
}"#;
let response: GmiCloudPollResponse = serde_json::from_str(json).unwrap();
assert!(response.is_failed());
assert!(!response.is_success());
assert!(!response.is_in_progress());
}
}
mod live_api_tests {
use super::*;
// seedance-2-0-260128, 10s 720p, image-to-video
#[tokio::test]
#[ignore] // requires real API key
async fn poll_seedance_20_720p() {
let api_key = crate::test_utils::load_api_key();
let response = poll_gmicloud_request(&api_key, "40f63632-dd9d-4725-ab73-5bb764e773be")
.await.unwrap();
assert_eq!(response.request_id, "40f63632-dd9d-4725-ab73-5bb764e773be");
assert_eq!(response.model, "seedance-2-0-260128");
assert!(response.is_success());
let payload = response.payload.as_ref().unwrap();
assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water");
assert_eq!(payload["duration"], 10);
assert_eq!(payload["resolution"], "720p");
assert!(payload["first_frame"].as_str().unwrap().contains("svcpp441gyb6mjtt8413kf5zxw1ehy4t"));
assert!(response.video_url().unwrap().ends_with(".mp4"));
}
// seedance-2-0-260128, 10s 480p, image-to-video
#[tokio::test]
#[ignore] // requires real API key
async fn poll_seedance_20_480p() {
let api_key = crate::test_utils::load_api_key();
let response = poll_gmicloud_request(&api_key, "4a0efe0d-990f-480d-9792-5bb71dd26b85")
.await.unwrap();
assert_eq!(response.request_id, "4a0efe0d-990f-480d-9792-5bb71dd26b85");
assert_eq!(response.model, "seedance-2-0-260128");
assert!(response.is_success());
let payload = response.payload.as_ref().unwrap();
assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water");
assert_eq!(payload["duration"], 10);
assert_eq!(payload["resolution"], "480p");
assert!(response.video_url().unwrap().ends_with(".mp4"));
}
// seedance-2-0-fast-260128, 10s 720p, image-to-video
#[tokio::test]
#[ignore] // requires real API key
async fn poll_seedance_20_fast_720p() {
let api_key = crate::test_utils::load_api_key();
let response = poll_gmicloud_request(&api_key, "2fbbe377-7c92-482e-b1eb-92fd296b0a68")
.await.unwrap();
assert_eq!(response.request_id, "2fbbe377-7c92-482e-b1eb-92fd296b0a68");
assert_eq!(response.model, "seedance-2-0-fast-260128");
assert!(response.is_success());
let payload = response.payload.as_ref().unwrap();
assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water");
assert_eq!(payload["duration"], 10);
assert_eq!(payload["resolution"], "720p");
assert!(response.video_url().unwrap().ends_with(".mp4"));
}
// seedance-2-0-fast-260128, 10s 480p, image-to-video
#[tokio::test]
#[ignore] // requires real API key
async fn poll_seedance_20_fast_480p() {
let api_key = crate::test_utils::load_api_key();
let response = poll_gmicloud_request(&api_key, "1b654a85-d99e-4380-b615-c7a8985ed15b")
.await.unwrap();
assert_eq!(response.request_id, "1b654a85-d99e-4380-b615-c7a8985ed15b");
assert_eq!(response.model, "seedance-2-0-fast-260128");
assert!(response.is_success());
let payload = response.payload.as_ref().unwrap();
assert_eq!(payload["prompt"], "the dog in this photo starts running and splashing in the lake water");
assert_eq!(payload["duration"], 10);
assert_eq!(payload["resolution"], "480p");
assert!(response.video_url().unwrap().ends_with(".mp4"));
}
}
}
@@ -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;