mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-08 02:52:12 +00:00
gmicloud client
This commit is contained in:
1 parent
00132478b1
commit
78d97401fc
28 files changed
+1059
No files matched your search
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,247 @@
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::requests::api::video::seedance_2_0_260128::raw_request::Seedance20Payload;
|
||||
use crate::requests::common::create_request::{
|
||||
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
};
|
||||
|
||||
const MODEL_ID: &str = "seedance-2-0-260128";
|
||||
|
||||
/// User-facing request for the Seedance 2.0 model via GmiCloud.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Seedance20Request {
|
||||
/// Text prompt describing the video to generate.
|
||||
pub prompt: String,
|
||||
|
||||
/// Video duration.
|
||||
pub duration: Option<Seedance20Duration>,
|
||||
|
||||
/// Aspect ratio of the output video.
|
||||
pub aspect_ratio: Option<Seedance20AspectRatio>,
|
||||
|
||||
/// Negative prompt (terms to avoid in generation).
|
||||
pub negative_prompt: Option<String>,
|
||||
|
||||
/// An image URL for image-to-video generation.
|
||||
pub start_frame_url: Option<String>,
|
||||
|
||||
/// Seed for deterministic generation.
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Seedance20Duration {
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Seedance20AspectRatio {
|
||||
/// 16:9 (landscape)
|
||||
Landscape16x9,
|
||||
/// 9:16 (portrait)
|
||||
Portrait9x16,
|
||||
/// 1:1 (square)
|
||||
Square,
|
||||
/// 4:3
|
||||
Standard4x3,
|
||||
/// 3:4
|
||||
Portrait3x4,
|
||||
/// 21:9 (ultra-wide)
|
||||
UltraWide21x9,
|
||||
}
|
||||
|
||||
impl Seedance20Request {
|
||||
pub fn to_raw_payload(&self) -> Seedance20Payload {
|
||||
Seedance20Payload {
|
||||
prompt: self.prompt.clone(),
|
||||
duration: self.duration.map(|d| match d {
|
||||
Seedance20Duration::FiveSeconds => "5",
|
||||
Seedance20Duration::TenSeconds => "10",
|
||||
}.to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| match ar {
|
||||
Seedance20AspectRatio::Landscape16x9 => "16:9",
|
||||
Seedance20AspectRatio::Portrait9x16 => "9:16",
|
||||
Seedance20AspectRatio::Square => "1:1",
|
||||
Seedance20AspectRatio::Standard4x3 => "4:3",
|
||||
Seedance20AspectRatio::Portrait3x4 => "3:4",
|
||||
Seedance20AspectRatio::UltraWide21x9 => "21:9",
|
||||
}.to_string()),
|
||||
negative_prompt: self.negative_prompt.clone(),
|
||||
start_frame_url: self.start_frame_url.clone(),
|
||||
seed: self.seed,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_request(
|
||||
&self,
|
||||
api_key: &GmiCloudApiKey,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: MODEL_ID.to_string(),
|
||||
payload: self.to_raw_payload(),
|
||||
};
|
||||
create_gmicloud_request(api_key, &body).await
|
||||
}
|
||||
|
||||
pub fn model_id() -> &'static str {
|
||||
MODEL_ID
|
||||
}
|
||||
}
|
||||
|
||||
impl Seedance20Duration {
|
||||
pub fn to_seconds(&self) -> u8 {
|
||||
match self {
|
||||
Seedance20Duration::FiveSeconds => 5,
|
||||
Seedance20Duration::TenSeconds => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod raw_payload_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_request_serializes() {
|
||||
let request = Seedance20Request {
|
||||
prompt: "a dog running through a field".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let payload = request.to_raw_payload();
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["prompt"], "a dog running through a field");
|
||||
assert!(json.get("duration").is_none());
|
||||
assert!(json.get("aspectRatio").is_none());
|
||||
assert!(json.get("negativePrompt").is_none());
|
||||
assert!(json.get("startFrameUrl").is_none());
|
||||
assert!(json.get("seed").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_request_serializes() {
|
||||
let request = Seedance20Request {
|
||||
prompt: "a cat sitting on a windowsill".to_string(),
|
||||
duration: Some(Seedance20Duration::TenSeconds),
|
||||
aspect_ratio: Some(Seedance20AspectRatio::Landscape16x9),
|
||||
negative_prompt: Some("blurry, low quality".to_string()),
|
||||
start_frame_url: Some("https://example.com/image.png".to_string()),
|
||||
seed: Some(42),
|
||||
};
|
||||
let payload = request.to_raw_payload();
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["prompt"], "a cat sitting on a windowsill");
|
||||
assert_eq!(json["duration"], "10");
|
||||
assert_eq!(json["aspectRatio"], "16:9");
|
||||
assert_eq!(json["negativePrompt"], "blurry, low quality");
|
||||
assert_eq!(json["startFrameUrl"], "https://example.com/image.png");
|
||||
assert_eq!(json["seed"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_aspect_ratios_serialize() {
|
||||
let cases = [
|
||||
(Seedance20AspectRatio::Landscape16x9, "16:9"),
|
||||
(Seedance20AspectRatio::Portrait9x16, "9:16"),
|
||||
(Seedance20AspectRatio::Square, "1:1"),
|
||||
(Seedance20AspectRatio::Standard4x3, "4:3"),
|
||||
(Seedance20AspectRatio::Portrait3x4, "3:4"),
|
||||
(Seedance20AspectRatio::UltraWide21x9, "21:9"),
|
||||
];
|
||||
for (ar, expected) in cases {
|
||||
let request = Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: Some(ar),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
|
||||
assert_eq!(json["aspectRatio"], expected, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_durations_serialize() {
|
||||
let cases = [
|
||||
(Seedance20Duration::FiveSeconds, "5"),
|
||||
(Seedance20Duration::TenSeconds, "10"),
|
||||
];
|
||||
for (dur, expected) in cases {
|
||||
let request = Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(dur),
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
|
||||
assert_eq!(json["duration"], expected, "{dur:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_body_shape() {
|
||||
let request = Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(Seedance20Duration::FiveSeconds),
|
||||
aspect_ratio: Some(Seedance20AspectRatio::Square),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: Seedance20Request::model_id().to_string(),
|
||||
payload: request.to_raw_payload(),
|
||||
};
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["model"], "seedance-2-0-260128");
|
||||
assert_eq!(json["payload"]["prompt"], "test");
|
||||
assert_eq!(json["payload"]["duration"], "5");
|
||||
assert_eq!(json["payload"]["aspectRatio"], "1:1");
|
||||
}
|
||||
}
|
||||
|
||||
mod duration_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_seconds() {
|
||||
assert_eq!(Seedance20Duration::FiveSeconds.to_seconds(), 5);
|
||||
assert_eq!(Seedance20Duration::TenSeconds.to_seconds(), 10);
|
||||
}
|
||||
}
|
||||
|
||||
mod live_api_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video() {
|
||||
let api_key = crate::test_utils::load_api_key();
|
||||
let request = Seedance20Request {
|
||||
prompt: "a golden retriever puppy playing in autumn leaves".to_string(),
|
||||
duration: Some(Seedance20Duration::FiveSeconds),
|
||||
aspect_ratio: Some(Seedance20AspectRatio::Landscape16x9),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let result = request.send_request(&api_key).await.unwrap();
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
assert_eq!(result.model, MODEL_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Duration, Seedance20Request,
|
||||
};
|
||||
use crate::traits::gmicloud_request_cost_calculator_trait::{
|
||||
GmiCloudRequestCostCalculator, UsdCents,
|
||||
};
|
||||
|
||||
/// Default duration when not specified.
|
||||
const DEFAULT_DURATION_SECONDS: u8 = 5;
|
||||
|
||||
/// Cost per second of video in tenths of a US cent.
|
||||
/// GmiCloud Seedance 2.0 (standard) pricing.
|
||||
const TENTHS_PER_SECOND: u64 = 40;
|
||||
|
||||
impl GmiCloudRequestCostCalculator for Seedance20Request {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_seconds = self.duration
|
||||
.map(|d| d.to_seconds())
|
||||
.unwrap_or(DEFAULT_DURATION_SECONDS) as u64;
|
||||
|
||||
let tenths = TENTHS_PER_SECOND * duration_seconds;
|
||||
tenths.div_ceil(10)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::seedance_2_0_260128::api::Seedance20AspectRatio;
|
||||
|
||||
fn make_request(duration: Option<Seedance20Duration>) -> Seedance20Request {
|
||||
Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_default_duration() {
|
||||
// Default 5s: 40 * 5 = 200 tenths = 20 cents
|
||||
assert_eq!(make_request(None).calculate_cost_in_cents(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_five_seconds() {
|
||||
assert_eq!(
|
||||
make_request(Some(Seedance20Duration::FiveSeconds)).calculate_cost_in_cents(),
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_ten_seconds() {
|
||||
// 40 * 10 = 400 tenths = 40 cents
|
||||
assert_eq!(
|
||||
make_request(Some(Seedance20Duration::TenSeconds)).calculate_cost_in_cents(),
|
||||
40,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
Seedance20AspectRatio::Landscape16x9,
|
||||
Seedance20AspectRatio::Portrait9x16,
|
||||
Seedance20AspectRatio::Square,
|
||||
Seedance20AspectRatio::Standard4x3,
|
||||
Seedance20AspectRatio::Portrait3x4,
|
||||
Seedance20AspectRatio::UltraWide21x9,
|
||||
];
|
||||
for ar in aspect_ratios {
|
||||
let request = Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(Seedance20Duration::FiveSeconds),
|
||||
aspect_ratio: Some(ar),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
assert_eq!(request.calculate_cost_in_cents(), 20, "{ar:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
mod raw_request;
|
||||
mod cost;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-260128.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Seedance20Payload {
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds, e.g. "5", "10".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
/// Aspect ratio, e.g. "16:9", "9:16", "1:1", "4:3", "3:4", "21:9".
|
||||
#[serde(rename = "aspectRatio")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Negative prompt (terms to avoid).
|
||||
#[serde(rename = "negativePrompt")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_prompt: Option<String>,
|
||||
|
||||
/// An image URL for image-to-video generation (start frame).
|
||||
#[serde(rename = "startFrameUrl")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_frame_url: Option<String>,
|
||||
|
||||
/// Seed for deterministic generation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::requests::api::video::seedance_2_0_fast_260128::raw_request::Seedance20FastPayload;
|
||||
use crate::requests::common::create_request::{
|
||||
create_gmicloud_request, GmiCloudCreateRequest, GmiCloudCreateResponse,
|
||||
};
|
||||
|
||||
const MODEL_ID: &str = "seedance-2-0-fast-260128";
|
||||
|
||||
/// User-facing request for the Seedance 2.0 Fast model via GmiCloud.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Seedance20FastRequest {
|
||||
/// Text prompt describing the video to generate.
|
||||
pub prompt: String,
|
||||
|
||||
/// Video duration.
|
||||
pub duration: Option<Seedance20FastDuration>,
|
||||
|
||||
/// Aspect ratio of the output video.
|
||||
pub aspect_ratio: Option<Seedance20FastAspectRatio>,
|
||||
|
||||
/// Negative prompt (terms to avoid in generation).
|
||||
pub negative_prompt: Option<String>,
|
||||
|
||||
/// An image URL for image-to-video generation.
|
||||
pub start_frame_url: Option<String>,
|
||||
|
||||
/// Seed for deterministic generation.
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Seedance20FastDuration {
|
||||
FiveSeconds,
|
||||
TenSeconds,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Seedance20FastAspectRatio {
|
||||
/// 16:9 (landscape)
|
||||
Landscape16x9,
|
||||
/// 9:16 (portrait)
|
||||
Portrait9x16,
|
||||
/// 1:1 (square)
|
||||
Square,
|
||||
/// 4:3
|
||||
Standard4x3,
|
||||
/// 3:4
|
||||
Portrait3x4,
|
||||
/// 21:9 (ultra-wide)
|
||||
UltraWide21x9,
|
||||
}
|
||||
|
||||
impl Seedance20FastRequest {
|
||||
pub fn to_raw_payload(&self) -> Seedance20FastPayload {
|
||||
Seedance20FastPayload {
|
||||
prompt: self.prompt.clone(),
|
||||
duration: self.duration.map(|d| match d {
|
||||
Seedance20FastDuration::FiveSeconds => "5",
|
||||
Seedance20FastDuration::TenSeconds => "10",
|
||||
}.to_string()),
|
||||
aspect_ratio: self.aspect_ratio.map(|ar| match ar {
|
||||
Seedance20FastAspectRatio::Landscape16x9 => "16:9",
|
||||
Seedance20FastAspectRatio::Portrait9x16 => "9:16",
|
||||
Seedance20FastAspectRatio::Square => "1:1",
|
||||
Seedance20FastAspectRatio::Standard4x3 => "4:3",
|
||||
Seedance20FastAspectRatio::Portrait3x4 => "3:4",
|
||||
Seedance20FastAspectRatio::UltraWide21x9 => "21:9",
|
||||
}.to_string()),
|
||||
negative_prompt: self.negative_prompt.clone(),
|
||||
start_frame_url: self.start_frame_url.clone(),
|
||||
seed: self.seed,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_request(
|
||||
&self,
|
||||
api_key: &GmiCloudApiKey,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: MODEL_ID.to_string(),
|
||||
payload: self.to_raw_payload(),
|
||||
};
|
||||
create_gmicloud_request(api_key, &body).await
|
||||
}
|
||||
|
||||
pub fn model_id() -> &'static str {
|
||||
MODEL_ID
|
||||
}
|
||||
}
|
||||
|
||||
impl Seedance20FastDuration {
|
||||
pub fn to_seconds(&self) -> u8 {
|
||||
match self {
|
||||
Seedance20FastDuration::FiveSeconds => 5,
|
||||
Seedance20FastDuration::TenSeconds => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod raw_payload_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_request_serializes() {
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "a dog running through a field".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let payload = request.to_raw_payload();
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["prompt"], "a dog running through a field");
|
||||
assert!(json.get("duration").is_none());
|
||||
assert!(json.get("aspectRatio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_request_serializes() {
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "a cat sitting on a windowsill".to_string(),
|
||||
duration: Some(Seedance20FastDuration::TenSeconds),
|
||||
aspect_ratio: Some(Seedance20FastAspectRatio::Portrait9x16),
|
||||
negative_prompt: Some("blurry".to_string()),
|
||||
start_frame_url: Some("https://example.com/image.png".to_string()),
|
||||
seed: Some(123),
|
||||
};
|
||||
let payload = request.to_raw_payload();
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["prompt"], "a cat sitting on a windowsill");
|
||||
assert_eq!(json["duration"], "10");
|
||||
assert_eq!(json["aspectRatio"], "9:16");
|
||||
assert_eq!(json["negativePrompt"], "blurry");
|
||||
assert_eq!(json["startFrameUrl"], "https://example.com/image.png");
|
||||
assert_eq!(json["seed"], 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_aspect_ratios_serialize() {
|
||||
let cases = [
|
||||
(Seedance20FastAspectRatio::Landscape16x9, "16:9"),
|
||||
(Seedance20FastAspectRatio::Portrait9x16, "9:16"),
|
||||
(Seedance20FastAspectRatio::Square, "1:1"),
|
||||
(Seedance20FastAspectRatio::Standard4x3, "4:3"),
|
||||
(Seedance20FastAspectRatio::Portrait3x4, "3:4"),
|
||||
(Seedance20FastAspectRatio::UltraWide21x9, "21:9"),
|
||||
];
|
||||
for (ar, expected) in cases {
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration: None,
|
||||
aspect_ratio: Some(ar),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let json = serde_json::to_value(&request.to_raw_payload()).unwrap();
|
||||
assert_eq!(json["aspectRatio"], expected, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_body_shape() {
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(Seedance20FastDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Seedance20FastAspectRatio::Square),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let body = GmiCloudCreateRequest {
|
||||
model: Seedance20FastRequest::model_id().to_string(),
|
||||
payload: request.to_raw_payload(),
|
||||
};
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["model"], "seedance-2-0-fast-260128");
|
||||
assert_eq!(json["payload"]["prompt"], "test");
|
||||
assert_eq!(json["payload"]["duration"], "5");
|
||||
assert_eq!(json["payload"]["aspectRatio"], "1:1");
|
||||
}
|
||||
}
|
||||
|
||||
mod live_api_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires real API key, incurs costs
|
||||
async fn test_text_to_video() {
|
||||
let api_key = crate::test_utils::load_api_key();
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "a golden retriever puppy playing in autumn leaves".to_string(),
|
||||
duration: Some(Seedance20FastDuration::FiveSeconds),
|
||||
aspect_ratio: Some(Seedance20FastAspectRatio::Landscape16x9),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
let result = request.send_request(&api_key).await.unwrap();
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
assert_eq!(result.model, MODEL_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
use crate::requests::api::video::seedance_2_0_fast_260128::api::{
|
||||
Seedance20FastDuration, Seedance20FastRequest,
|
||||
};
|
||||
use crate::traits::gmicloud_request_cost_calculator_trait::{
|
||||
GmiCloudRequestCostCalculator, UsdCents,
|
||||
};
|
||||
|
||||
/// Default duration when not specified.
|
||||
const DEFAULT_DURATION_SECONDS: u8 = 5;
|
||||
|
||||
/// Cost per second of video in tenths of a US cent.
|
||||
/// GmiCloud Seedance 2.0 Fast is cheaper than the standard model.
|
||||
const TENTHS_PER_SECOND: u64 = 28;
|
||||
|
||||
impl GmiCloudRequestCostCalculator for Seedance20FastRequest {
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
let duration_seconds = self.duration
|
||||
.map(|d| d.to_seconds())
|
||||
.unwrap_or(DEFAULT_DURATION_SECONDS) as u64;
|
||||
|
||||
let tenths = TENTHS_PER_SECOND * duration_seconds;
|
||||
tenths.div_ceil(10)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastAspectRatio;
|
||||
|
||||
fn make_request(duration: Option<Seedance20FastDuration>) -> Seedance20FastRequest {
|
||||
Seedance20FastRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration,
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_default_duration() {
|
||||
// Default 5s: 28 * 5 = 140 tenths = 14 cents
|
||||
assert_eq!(make_request(None).calculate_cost_in_cents(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_five_seconds() {
|
||||
assert_eq!(
|
||||
make_request(Some(Seedance20FastDuration::FiveSeconds)).calculate_cost_in_cents(),
|
||||
14,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_ten_seconds() {
|
||||
// 28 * 10 = 280 tenths = 28 cents
|
||||
assert_eq!(
|
||||
make_request(Some(Seedance20FastDuration::TenSeconds)).calculate_cost_in_cents(),
|
||||
28,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_is_independent_of_aspect_ratio() {
|
||||
let aspect_ratios = [
|
||||
Seedance20FastAspectRatio::Landscape16x9,
|
||||
Seedance20FastAspectRatio::Portrait9x16,
|
||||
Seedance20FastAspectRatio::Square,
|
||||
Seedance20FastAspectRatio::Standard4x3,
|
||||
Seedance20FastAspectRatio::Portrait3x4,
|
||||
Seedance20FastAspectRatio::UltraWide21x9,
|
||||
];
|
||||
for ar in aspect_ratios {
|
||||
let request = Seedance20FastRequest {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(Seedance20FastDuration::FiveSeconds),
|
||||
aspect_ratio: Some(ar),
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
assert_eq!(request.calculate_cost_in_cents(), 14, "{ar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_is_cheaper_than_standard() {
|
||||
use crate::requests::api::video::seedance_2_0_260128::api::{
|
||||
Seedance20Duration, Seedance20Request,
|
||||
};
|
||||
use crate::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
|
||||
|
||||
let standard = Seedance20Request {
|
||||
prompt: "test".to_string(),
|
||||
duration: Some(Seedance20Duration::FiveSeconds),
|
||||
aspect_ratio: None,
|
||||
negative_prompt: None,
|
||||
start_frame_url: None,
|
||||
seed: None,
|
||||
};
|
||||
|
||||
let fast = make_request(Some(Seedance20FastDuration::FiveSeconds));
|
||||
|
||||
assert!(
|
||||
fast.calculate_cost_in_cents() < standard.calculate_cost_in_cents(),
|
||||
"Fast ({}¢) should be cheaper than Standard ({}¢)",
|
||||
fast.calculate_cost_in_cents(),
|
||||
standard.calculate_cost_in_cents(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
mod raw_request;
|
||||
mod cost;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The payload sent inside `GmiCloudCreateRequest.payload` for seedance-2-0-fast-260128.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Seedance20FastPayload {
|
||||
pub prompt: String,
|
||||
|
||||
/// Duration in seconds, e.g. "5", "10".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>,
|
||||
|
||||
/// Aspect ratio, e.g. "16:9", "9:16", "1:1", "4:3", "3:4", "21:9".
|
||||
#[serde(rename = "aspectRatio")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Negative prompt (terms to avoid).
|
||||
#[serde(rename = "negativePrompt")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_prompt: Option<String>,
|
||||
|
||||
/// An image URL for image-to-video generation (start frame).
|
||||
#[serde(rename = "startFrameUrl")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_frame_url: Option<String>,
|
||||
|
||||
/// Seed for deterministic generation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError;
|
||||
use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError;
|
||||
|
||||
const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey";
|
||||
|
||||
/// The top-level request body sent to `POST /requests`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GmiCloudCreateRequest<P: Serialize> {
|
||||
pub model: String,
|
||||
pub payload: P,
|
||||
}
|
||||
|
||||
/// The response from `POST /requests`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GmiCloudCreateResponse {
|
||||
pub request_id: String,
|
||||
pub model: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Submit a video generation request to GmiCloud.
|
||||
pub async fn create_gmicloud_request<P: Serialize>(
|
||||
api_key: &GmiCloudApiKey,
|
||||
body: &GmiCloudCreateRequest<P>,
|
||||
) -> Result<GmiCloudCreateResponse, GmiCloudError> {
|
||||
let url = format!("{}/requests", BASE_URL);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key.as_str()))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GmiCloudGenericApiError::from)?;
|
||||
|
||||
let status = response.status();
|
||||
let body_text = response.text().await
|
||||
.map_err(GmiCloudGenericApiError::from)?;
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(GmiCloudSpecificApiError::Unauthorized.into());
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
warn!("GmiCloud API error: status={}, body={}", status, body_text);
|
||||
return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status.as_u16(),
|
||||
body: body_text,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let parsed: GmiCloudCreateResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?;
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_request;
|
||||
pub mod poll_request;
|
||||
@@ -0,0 +1,62 @@
|
||||
use log::warn;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use crate::error::gmicloud_error::GmiCloudError;
|
||||
use crate::error::gmicloud_generic_api_error::GmiCloudGenericApiError;
|
||||
use crate::error::gmicloud_specific_api_error::GmiCloudSpecificApiError;
|
||||
|
||||
const BASE_URL: &str = "https://console.gmicloud.ai/api/v1/ie/requestqueue/apikey";
|
||||
|
||||
/// The response from `GET /requests/{request_id}`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GmiCloudPollResponse {
|
||||
pub request_id: String,
|
||||
pub model: String,
|
||||
pub status: String,
|
||||
pub outcome: Option<GmiCloudOutcome>,
|
||||
}
|
||||
|
||||
/// The outcome of a completed request.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GmiCloudOutcome {
|
||||
pub video_url: Option<String>,
|
||||
pub thumbnail_image_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Poll the status of a GmiCloud request.
|
||||
pub async fn poll_gmicloud_request(
|
||||
api_key: &GmiCloudApiKey,
|
||||
request_id: &str,
|
||||
) -> Result<GmiCloudPollResponse, GmiCloudError> {
|
||||
let url = format!("{}/requests/{}", BASE_URL, request_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key.as_str()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(GmiCloudGenericApiError::from)?;
|
||||
|
||||
let status = response.status();
|
||||
let body_text = response.text().await
|
||||
.map_err(GmiCloudGenericApiError::from)?;
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(GmiCloudSpecificApiError::Unauthorized.into());
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
warn!("GmiCloud poll error: status={}, body={}", status, body_text);
|
||||
return Err(GmiCloudGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status.as_u16(),
|
||||
body: body_text,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let parsed: GmiCloudPollResponse = serde_json::from_str(&body_text)
|
||||
.map_err(|err| GmiCloudGenericApiError::SerdeResponseParseErrorWithBody(err, body_text))?;
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod common;
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::creds::gmicloud_api_key::GmiCloudApiKey;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/gmicloud_api_key.txt";
|
||||
|
||||
pub fn load_api_key() -> GmiCloudApiKey {
|
||||
let secret = read_to_string(API_KEY_PATH)
|
||||
.expect("Failed to read GmiCloud API key file");
|
||||
GmiCloudApiKey::from_str(&secret)
|
||||
}
|
||||
+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