mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 03:09:30 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f50b329b | ||
|
|
6438d7499e | ||
|
|
47b7bf18cd | ||
|
|
ee06475417 | ||
|
|
6e0754c5b2 | ||
|
|
5ad8287460 | ||
|
|
70d7b932f0 | ||
|
|
78d97401fc |
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+163
@@ -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;
|
||||
+59
@@ -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>>,
|
||||
}
|
||||
+293
@@ -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
|
||||
}
|
||||
+186
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
mod raw_request;
|
||||
mod cost;
|
||||
+59
@@ -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;
|
||||
+281
@@ -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)
|
||||
}
|
||||
+7
@@ -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;
|
||||
Reference in New Issue
Block a user