seedance global (#1527)

This commit is contained in:
Brandon Thomas
2026-05-16 11:52:25 -04:00
committed by GitHub
parent a1bc472125
commit 084e1f68b8
47 changed files with 1704 additions and 26 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = NULL,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = NULL,\n failure_reason = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "99dae5c5f3b3a8e9ce105bff9a9754c3aee92dbf217288c9a7eb925004e3d259"
}
Generated
+2
View File
@@ -773,6 +773,7 @@ dependencies = [
"artcraft_client",
"enums",
"fal_client",
"gmicloud_client",
"log",
"muapi_client",
"reqwest 0.12.23",
@@ -11295,6 +11296,7 @@ dependencies = [
"futures 0.1.31",
"futures 0.3.32",
"futures-util",
"gmicloud_client",
"google_sign_in",
"hashing",
"hostname 0.4.0",
@@ -20,6 +20,7 @@ artcraft_api_defs.workspace = true
enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
muapi_client.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
@@ -41,6 +41,12 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -6,6 +6,7 @@ use serde_derive::{Deserialize, Serialize};
pub enum Provider {
Artcraft,
Fal,
GmiCloud,
Muapi,
Seedance2Pro,
}
@@ -6,5 +6,6 @@ pub mod router_artcraft_client;
pub mod router_client;
pub mod router_fal_client;
pub mod router_fal_webhook_optional_client;
pub mod router_gmicloud_client;
pub mod router_muapi_client;
pub mod router_seedance2pro_client;
@@ -1,6 +1,7 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -9,6 +10,7 @@ pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) muapi_client: Option<RouterMuapiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -23,11 +25,17 @@ impl MultiRouterClient {
self.fal_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Fal))
}
pub fn get_fal_webhook_optional_client_ref(&self) -> Result<&RouterFalWebhookOptionalClient, ClientError> {
self.fal_webhook_optional_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::FalWebhookOptional))
}
pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> {
self.gmicloud_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::GmiCloud))
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
self.muapi_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Muapi))
@@ -2,6 +2,7 @@ use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
@@ -9,6 +10,7 @@ pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
fal_client: Option<RouterFalClient>,
fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
muapi_client: Option<RouterMuapiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -19,6 +21,7 @@ impl MultiRouterClientBuilder {
artcraft_client: None,
fal_client: None,
fal_webhook_optional_client: None,
gmicloud_client: None,
muapi_client: None,
seedance2pro_client: None,
}
@@ -33,12 +36,17 @@ impl MultiRouterClientBuilder {
self.fal_client = Some(client);
self
}
pub fn set_fal_webhook_optional_client(mut self, client: RouterFalWebhookOptionalClient) -> Self {
self.fal_webhook_optional_client = Some(client);
self
}
pub fn set_gmicloud_client(mut self, client: RouterGmiCloudClient) -> Self {
self.gmicloud_client = Some(client);
self
}
pub fn set_muapi_client(mut self, client: RouterMuapiClient) -> Self {
self.muapi_client = Some(client);
self
@@ -54,6 +62,7 @@ impl MultiRouterClientBuilder {
artcraft_client: self.artcraft_client,
fal_client: self.fal_client,
fal_webhook_optional_client: self.fal_webhook_optional_client,
gmicloud_client: self.gmicloud_client,
muapi_client: self.muapi_client,
seedance2pro_client: self.seedance2pro_client,
}
@@ -2,6 +2,7 @@ use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,6 +12,7 @@ pub enum RouterClient {
Artcraft(RouterArtcraftClient),
Fal(RouterFalClient),
FalWebhookOptional(RouterFalWebhookOptionalClient),
GmiCloud(RouterGmiCloudClient),
Muapi(RouterMuapiClient),
Seedance2Pro(RouterSeedance2ProClient),
}
@@ -40,6 +42,14 @@ impl RouterClient {
}
}
pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> {
match self {
RouterClient::GmiCloud(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_gmicloud_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::GmiCloud)),
}
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
match self {
RouterClient::Muapi(client) => Ok(client),
@@ -0,0 +1,11 @@
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
pub struct RouterGmiCloudClient {
pub(crate) api_key: GmiCloudApiKey,
}
impl RouterGmiCloudClient {
pub fn new(api_key: GmiCloudApiKey) -> Self {
RouterGmiCloudClient { api_key }
}
}
@@ -8,6 +8,7 @@ pub enum ClientType {
Artcraft,
Fal,
FalWebhookOptional,
GmiCloud,
Muapi,
Seedance2Pro,
}
@@ -18,6 +19,7 @@ impl Display for ClientType {
Self::Artcraft => write!(f, "Artcraft"),
Self::Fal => write!(f, "Fal"),
Self::FalWebhookOptional => write!(f, "FalWebhookOptional"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::Muapi => write!(f, "Muapi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
}
@@ -2,6 +2,7 @@ use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use muapi_client::error::muapi_error::MuapiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
@@ -9,6 +10,7 @@ use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
pub enum ProviderError {
Storyteller(StorytellerError),
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Muapi(MuapiError),
Seedance2Pro(Seedance2ProError),
}
@@ -20,6 +22,7 @@ impl Display for ProviderError {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Muapi(e) => write!(f, "Muapi provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
}
@@ -38,6 +41,12 @@ impl From<FalErrorPlus> for ProviderError {
}
}
impl From<GmiCloudError> for ProviderError {
fn from(error: GmiCloudError) -> Self {
Self::GmiCloud(error)
}
}
impl From<MuapiError> for ProviderError {
fn from(error: MuapiError) -> Self {
Self::Muapi(error)
@@ -52,6 +52,8 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::build
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::build::build_artcraft_seedance_2p0_fast;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::build::build_kinovi_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::build_kinovi_seedance_2p0;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_g;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_fast_g;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
@@ -147,6 +149,8 @@ impl GenerateVideoRequestBuilder {
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => true,
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Global) => true,
(Provider::GmiCloud, CommonVideoModel::Seedance2p0FastGlobal) => true,
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => true,
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => true,
@@ -160,6 +164,8 @@ impl GenerateVideoRequestBuilder {
(Provider::Artcraft, CommonVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0) => build_artcraft_seedance_2p0(self),
(Provider::Artcraft, CommonVideoModel::Seedance2p0Fast) => build_artcraft_seedance_2p0_fast(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0Global) => build_gmicloud_seedance_2p0_g(self),
(Provider::GmiCloud, CommonVideoModel::Seedance2p0FastGlobal) => build_gmicloud_seedance_2p0_fast_g(self),
(Provider::Seedance2Pro, CommonVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
(Provider::Seedance2Pro, CommonVideoModel::Seedance2p0Fast) => build_kinovi_seedance_2p0_fast(self),
@@ -172,9 +178,9 @@ impl GenerateVideoRequestBuilder {
match self.provider {
Provider::Artcraft => self.build_artcraft(),
Provider::Fal => self.build_fal(),
Provider::GmiCloud => self.unsupported_provider(), // GmiCloud uses build2() only
Provider::Muapi => self.build_muapi(),
Provider::Seedance2Pro => self.build_seedance2pro(),
_ => self.unsupported_provider(),
}
}
@@ -34,9 +34,15 @@ pub struct FalVideoResponsePayload {
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub struct GmiCloudVideoResponsePayload {
pub request_id: String,
}
#[derive(Clone, Debug)]
pub enum GenerateVideoResponse {
Artcraft(ArtcraftVideoResponsePayload),
GmiCloud(GmiCloudVideoResponsePayload),
Muapi(MuapiVideoResponsePayload),
Seedance2Pro(Seedance2proVideoResponsePayload),
Fal(FalVideoResponsePayload),
@@ -64,6 +70,13 @@ impl GenerateVideoResponse {
}
}
pub fn get_gmicloud_payload(&self) -> Option<GmiCloudVideoResponsePayload> {
match self {
Self::GmiCloud(p) => Some(p.clone()),
_ => None,
}
}
pub fn get_fal_payload(&self) -> Option<FalVideoResponsePayload> {
match self {
Self::Fal(p) => Some(p.clone()),
@@ -0,0 +1,2 @@
pub mod seedance_2p0_g;
pub mod seedance_2p0_fast_g;
@@ -0,0 +1,323 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastRequest, Seedance20FastResolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_fast_g(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20FastRequest {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0FastGRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0FastG(state)))
}
fn plan_ratio(
aspect_ratio: Option<CommonAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastRatio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Seedance20FastRatio::Landscape16x9)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Seedance20FastRatio::Portrait9x16)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(Seedance20FastRatio::Square)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance20FastRatio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance20FastRatio::Portrait3x4)),
Some(CommonAspectRatio::WideTwentyOneByNine) | Some(CommonAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20FastRatio::UltraWide21x9))
}
Some(CommonAspectRatio::WideThreeByTwo) | Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20FastRatio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20FastResolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
// Fast model doesn't support 1080p — fall back to 720p
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance20FastResolution::SevenTwentyP)),
Some(CommonResolution::HalfK) | Some(CommonResolution::OneK) => Ok(Some(Seedance20FastResolution::FourEightyP)),
Some(CommonResolution::TwoK) | Some(CommonResolution::ThreeK) | Some(CommonResolution::FourK) => {
Ok(Some(Seedance20FastResolution::SevenTwentyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::{
Seedance20FastRatio, Seedance20FastResolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("fast test".to_string()); }));
assert_eq!(req.request.prompt, "fast test");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
assert_eq!(req.request.duration, Some(8));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(2); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(30); }));
assert_eq!(req.request.duration, Some(15));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn res_1080p_falls_back_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20FastResolution::SevenTwentyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Landscape16x9));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20FastRatio::Square));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/img.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/img.png".to_string()));
}
#[test]
fn media_file_token_rejected() {
let result = build_gmicloud_seedance_2p0_fast_g(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_x".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/ref.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/ref.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/ref.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
provider: Provider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0FastGRequestState {
let result = build_gmicloud_seedance_2p0_fast_g(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0FastG(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0FastG request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -0,0 +1,119 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
pub struct GmiCloudSeedance2p0FastGCostState {
request: GmiCloudSeedance2p0FastGRequestState,
}
impl GmiCloudSeedance2p0FastGCostState {
pub fn from_request(request: &GmiCloudSeedance2p0FastGRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p default: 3.1 tenths/s * 5 = 15.5 → ceil = 16 → div_ceil(10) = 2¢
assert_eq!(cost_cents(None, 5), 2);
}
#[test]
fn default_10s() {
assert_eq!(cost_cents(None, 10), 4);
}
#[test]
fn default_15s() {
assert_eq!(cost_cents(None, 15), 5);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5), 1);
}
#[test]
fn p480_10s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10), 1);
}
#[test]
fn p480_15s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15), 2);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 10);
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
}
#[test]
fn fast_cheaper_than_standard() {
let fast = cost_cents(Some(CommonResolution::SevenTwentyP), 10);
let standard = {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
provider: Provider::GmiCloud,
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(10),
video_batch_count: Some(1),
..Default::default()
};
builder.build2().unwrap().estimate_cost().unwrap().cost_in_usd_cents.unwrap()
};
assert!(fast < standard, "Fast ({fast}¢) should be < Standard ({standard}¢)");
}
}
fn cost_cents(resolution: Option<CommonResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
provider: Provider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,100 @@
use gmicloud_client::requests::api::video::seedance_2_0_fast_260128::api::Seedance20FastRequest;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0FastGRequestState {
pub request: Seedance20FastRequest,
}
impl GmiCloudSeedance2p0FastGRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
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_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A shiba inu playing in autumn leaves.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(CommonResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_fast_builder()
}).await;
println!("response: {:?}", response);
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_fast_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0FastGlobal,
provider: Provider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -0,0 +1,355 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Request, Seedance20Resolution,
};
use crate::api::audio_list_ref::AudioListRef;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
pub fn build_gmicloud_seedance_2p0_g(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
let ratio = plan_ratio(builder.aspect_ratio.take(), strategy)?;
let resolution = plan_resolution(builder.resolution.take(), strategy)?;
let duration = builder.duration_seconds.take().map(|d| (d as u8).clamp(4, 15));
let prompt = builder.prompt.take().unwrap_or_default();
let first_frame = resolve_url(builder.start_frame.take())?;
let last_frame = resolve_url(builder.end_frame.take())?;
let reference_images = resolve_url_list_from_images(builder.reference_images.take())?;
let reference_videos = resolve_url_list_from_videos(builder.reference_videos.take())?;
let reference_audios = resolve_url_list_from_audios(builder.reference_audio.take())?;
let request = Seedance20Request {
prompt,
duration,
resolution,
ratio,
seed: None,
watermark: Some(false),
generate_audio: Some(true),
web_search: None,
first_frame,
last_frame,
reference_images,
reference_videos,
reference_audios,
reference_asset_ids: None,
};
let state = GmiCloudSeedance2p0GRequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0G(state)))
}
fn plan_ratio(
aspect_ratio: Option<CommonAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Ratio>, ArtcraftRouterError> {
match aspect_ratio {
None | Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => Ok(None),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(Seedance20Ratio::Landscape16x9)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(Seedance20Ratio::Portrait9x16)),
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(Seedance20Ratio::Square)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(Seedance20Ratio::Standard4x3)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(Seedance20Ratio::Portrait3x4)),
Some(CommonAspectRatio::WideTwentyOneByNine) | Some(CommonAspectRatio::TallNineByTwentyOne) => {
Ok(Some(Seedance20Ratio::UltraWide21x9))
}
Some(CommonAspectRatio::WideThreeByTwo) | Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::TallFourByFive) | Some(CommonAspectRatio::TallTwoByThree) => {
Ok(Some(Seedance20Ratio::Adaptive))
}
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<Seedance20Resolution>, ArtcraftRouterError> {
match resolution {
None => Ok(None),
Some(CommonResolution::FourEightyP) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(CommonResolution::SevenTwentyP) => Ok(Some(Seedance20Resolution::SevenTwentyP)),
Some(CommonResolution::TenEightyP) => Ok(Some(Seedance20Resolution::TenEightyP)),
Some(CommonResolution::HalfK) | Some(CommonResolution::OneK) => Ok(Some(Seedance20Resolution::FourEightyP)),
Some(CommonResolution::TwoK) | Some(CommonResolution::ThreeK) | Some(CommonResolution::FourK) => {
Ok(Some(Seedance20Resolution::TenEightyP))
}
}
}
fn resolve_url(image_ref: Option<ImageRef>) -> Result<Option<String>, ArtcraftRouterError> {
match image_ref {
None => Ok(None),
Some(ImageRef::Url(url)) => Ok(Some(url)),
Some(ImageRef::MediaFileToken(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "start_frame/end_frame",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_images(
list_ref: Option<ImageListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(ImageListRef::Urls(urls)) => Ok(Some(urls)),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_images",
value: "GmiCloud only supports image URLs, not media file tokens".to_string(),
}))
}
}
}
fn resolve_url_list_from_videos(
list_ref: Option<VideoListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(VideoListRef::Urls(urls)) => Ok(Some(urls)),
Some(VideoListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_videos",
value: "GmiCloud only supports video URLs, not media file tokens".to_string(),
}))
}
}
}
#[cfg(test)]
mod tests {
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::{
Seedance20Ratio, Seedance20Resolution,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
assert_eq!(req.request.prompt, "test prompt");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration, Some(10));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(1); }));
assert_eq!(req.request.duration, Some(4));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration, Some(15));
}
#[test]
fn duration_none_stays_none() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
assert_eq!(req.request.duration, None);
}
}
mod aspect_ratio_tests {
use super::*;
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Landscape16x9));
}
#[test]
fn portrait_9x16() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Portrait9x16));
}
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Square); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Square));
}
#[test]
fn four_by_three() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideFourByThree); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Standard4x3));
}
#[test]
fn unsupported_maps_to_adaptive() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::WideThreeByTwo); }));
assert_eq!(req.request.ratio, Some(Seedance20Ratio::Adaptive));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(CommonAspectRatio::Auto); }));
assert_eq!(req.request.ratio, None);
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::SevenTwentyP));
}
#[test]
fn res_1080p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(CommonResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(Seedance20Resolution::TenEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_url_tests {
use super::*;
#[test]
fn start_frame_url_passed_through() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
}));
assert_eq!(req.request.first_frame, Some("https://example.com/start.png".to_string()));
}
#[test]
fn media_file_token_rejected_for_start_frame() {
let result = build_gmicloud_seedance_2p0_g(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_urls_passed_through() {
let urls = vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_images, Some(urls));
}
#[test]
fn reference_image_tokens_rejected() {
let result = build_gmicloud_seedance_2p0_g(GenerateVideoRequestBuilder {
reference_images: Some(ImageListRef::MediaFileTokens(vec![MediaFileToken::new("mf_a".to_string())])),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_video_urls_passed_through() {
let urls = vec!["https://example.com/v.mp4".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_videos, Some(urls));
}
#[test]
fn reference_audio_urls_passed_through() {
let urls = vec!["https://example.com/a.wav".to_string()];
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(urls.clone()));
}));
assert_eq!(req.request.reference_audios, Some(urls));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
provider: Provider::GmiCloud,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GmiCloudSeedance2p0GRequestState {
let result = build_gmicloud_seedance_2p0_g(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GmiCloudSeedance2p0G(state)) => state,
_ => panic!("expected GmiCloudSeedance2p0G request"),
}
}
}
fn resolve_url_list_from_audios(
list_ref: Option<AudioListRef>,
) -> Result<Option<Vec<String>>, ArtcraftRouterError> {
match list_ref {
None => Ok(None),
Some(AudioListRef::Urls(urls)) => Ok(Some(urls)),
Some(AudioListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "reference_audios",
value: "GmiCloud only supports audio URLs, not media file tokens".to_string(),
}))
}
}
}
@@ -0,0 +1,132 @@
use gmicloud_client::traits::gmicloud_request_cost_calculator_trait::GmiCloudRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
pub struct GmiCloudSeedance2p0GCostState {
request: GmiCloudSeedance2p0GRequestState,
}
impl GmiCloudSeedance2p0GCostState {
pub fn from_request(request: &GmiCloudSeedance2p0GRequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::provider::Provider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
mod pricing_720p {
use super::*;
#[test]
fn default_5s() {
// 720p default: 5.2 tenths/s * 5 = 26 → ceil = 26 → div_ceil(10) = 3¢
assert_eq!(cost_cents(None, 5), 3);
}
#[test]
fn default_10s() {
assert_eq!(cost_cents(None, 10), 6);
}
#[test]
fn explicit_720p_5s() {
assert_eq!(cost_cents(Some(CommonResolution::SevenTwentyP), 5), 3);
}
}
mod pricing_480p {
use super::*;
#[test]
fn p480_5s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 5), 2);
}
#[test]
fn p480_10s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 10), 3);
}
#[test]
fn p480_15s() {
assert_eq!(cost_cents(Some(CommonResolution::FourEightyP), 15), 4);
}
}
mod pricing_1080p {
use super::*;
#[test]
fn p1080_5s() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 5), 6);
}
#[test]
fn p1080_10s() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 10), 12);
}
#[test]
fn p1080_15s() {
assert_eq!(cost_cents(Some(CommonResolution::TenEightyP), 15), 18);
}
}
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let c480 = cost_cents(Some(CommonResolution::FourEightyP), 10);
let c720 = cost_cents(Some(CommonResolution::SevenTwentyP), 10);
let c1080 = cost_cents(Some(CommonResolution::TenEightyP), 10);
assert!(c480 < c720, "480p ({c480}) should be < 720p ({c720})");
assert!(c720 < c1080, "720p ({c720}) should be < 1080p ({c1080})");
}
#[test]
fn longer_duration_costs_more() {
let c5 = cost_cents(None, 5);
let c10 = cost_cents(None, 10);
let c15 = cost_cents(None, 15);
assert!(c5 < c10);
assert!(c10 < c15);
}
}
fn cost_cents(resolution: Option<CommonResolution>, duration_seconds: u16) -> u64 {
let builder = GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
provider: Provider::GmiCloud,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,98 @@
use gmicloud_client::requests::api::video::seedance_2_0_260128::api::Seedance20Request;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GmiCloudVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GmiCloudSeedance2p0GRequestState {
pub request: Seedance20Request,
}
impl GmiCloudSeedance2p0GRequestState {
pub async fn send(&self, client: &RouterGmiCloudClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let response = self.request.send_request(&client.api_key)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::GmiCloud(err)))?;
Ok(GenerateVideoResponse::GmiCloud(GmiCloudVideoResponsePayload {
request_id: response.request_id,
}))
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_resolution::CommonResolution;
use crate::api::common_video_model::CommonVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::provider::Provider;
use crate::client::router_client::RouterClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
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_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A corgi running through a field of wildflowers at sunset.".to_string()),
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::SevenTwentyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog starts running and splashing in the lake.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(CommonResolution::FourEightyP),
duration_seconds: Some(5),
..gmicloud_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::GmiCloud(_)));
}
fn gmicloud_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: CommonVideoModel::Seedance2p0Global,
provider: Provider::GmiCloud,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_gmicloud_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/gmicloud_api_key.txt")
.expect("Failed to read GmiCloud API key");
let api_key = GmiCloudApiKey::from_str(&secret);
RouterClient::GmiCloud(RouterGmiCloudClient::new(api_key))
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_gmicloud_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (GmiCloud skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::GmiCloud(p) => println!("request_id={}", p.request_id),
other => println!("response: {:?}", other),
}
response
}
}
@@ -1,2 +1,3 @@
pub mod artcraft;
pub mod gmicloud;
pub mod kinovi;
@@ -9,6 +9,10 @@ use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::cost:
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0::request::ArtcraftSeedance2p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::cost::ArtcraftSeedance2p0FastCostState;
use crate::generate::generate_video_v2::providers::artcraft::seedance_2p0_fast::request::ArtcraftSeedance2p0FastRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::cost::GmiCloudSeedance2p0GCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::request::GmiCloudSeedance2p0GRequestState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::cost::GmiCloudSeedance2p0FastGCostState;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0FastGRequestState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
@@ -21,6 +25,8 @@ pub enum VideoGenerationRequest {
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftSeedance2p0(ArtcraftSeedance2p0RequestState),
ArtcraftSeedance2p0Fast(ArtcraftSeedance2p0FastRequestState),
GmiCloudSeedance2p0G(GmiCloudSeedance2p0GRequestState),
GmiCloudSeedance2p0FastG(GmiCloudSeedance2p0FastGRequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -33,6 +39,8 @@ impl VideoGenerationRequest {
Self::ArtcraftHappyHorse1p0(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0(_) => Provider::Artcraft,
Self::ArtcraftSeedance2p0Fast(_) => Provider::Artcraft,
Self::GmiCloudSeedance2p0G(_) => Provider::GmiCloud,
Self::GmiCloudSeedance2p0FastG(_) => Provider::GmiCloud,
Self::KinoviHappyHorse1p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => Provider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => Provider::Seedance2Pro,
@@ -45,6 +53,8 @@ impl VideoGenerationRequest {
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0(request) => Ok(ArtcraftSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftSeedance2p0Fast(request) => Ok(ArtcraftSeedance2p0FastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0G(request) => Ok(GmiCloudSeedance2p0GCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0FastG(request) => Ok(GmiCloudSeedance2p0FastGCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
@@ -67,6 +77,14 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0G(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GmiCloudSeedance2p0FastG(request) => {
let client_ref = client.get_gmicloud_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
@@ -1,4 +1,5 @@
/// A GmiCloud API key used for Bearer token authentication.
#[derive(Clone)]
pub struct GmiCloudApiKey(pub String);
impl GmiCloudApiKey {
@@ -53,14 +53,14 @@ pub struct Seedance20Request {
pub reference_asset_ids: Option<Vec<String>>,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Seedance20Resolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Seedance20Ratio {
/// 16:9 (landscape)
Landscape16x9,
@@ -53,13 +53,13 @@ pub struct Seedance20FastRequest {
pub reference_asset_ids: Option<Vec<String>>,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Seedance20FastResolution {
FourEightyP,
SevenTwentyP,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Seedance20FastRatio {
/// 16:9 (landscape)
Landscape16x9,
@@ -0,0 +1,119 @@
use log::info;
use sqlx::{Executor, MySql};
use std::marker::PhantomData;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::by_table::generic_inference_jobs::inference_job_product_category::InferenceJobProductCategory;
use enums::by_table::generic_inference_jobs::inference_job_type::InferenceJobType;
use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::errors::database_query_error::DatabaseQueryError;
pub struct InsertGmiCloudInferenceJobArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub apriori_job_token: &'e InferenceJobToken,
pub uuid_idempotency_token: &'e str,
pub external_request_id: &'e str,
pub maybe_prompt_token: Option<&'e PromptToken>,
pub maybe_creator_user_token: Option<&'e UserToken>,
pub maybe_avt_token: Option<&'e AnonymousVisitorTrackingToken>,
pub creator_ip_address: &'e str,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn insert_generic_inference_job_for_gmicloud<'e, 'c: 'e, E>(
args: InsertGmiCloudInferenceJobArgs<'e, 'c, E>,
) -> Result<InferenceJobToken, DatabaseQueryError>
where
E: 'e + Executor<'c, Database = MySql>,
{
let query = sqlx::query!(
r#"
INSERT INTO generic_inference_jobs
SET
token = ?,
uuid_idempotency_token = ?,
job_type = ?,
maybe_external_third_party = ?,
maybe_external_third_party_id = ?,
product_category = ?,
inference_category = ?,
maybe_model_type = NULL,
maybe_model_token = NULL,
maybe_input_source_token = NULL,
maybe_input_source_token_type = NULL,
maybe_download_url = NULL,
maybe_cover_image_media_file_token = NULL,
maybe_prompt_token = ?,
maybe_raw_inference_text = NULL,
maybe_inference_args = NULL,
maybe_creator_user_token = ?,
maybe_creator_anonymous_visitor_token = ?,
creator_ip_address = ?,
creator_set_visibility = ?,
priority_level = 0,
is_keepalive_required = FALSE,
max_duration_seconds = 0,
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
frontend_failure_category = NULL,
failure_reason = NULL,
status = ?
"#,
args.apriori_job_token.as_str(),
args.uuid_idempotency_token,
InferenceJobType::GmiCloudQueue.to_str(),
InferenceJobExternalThirdParty::GmiCloud.to_str(),
args.external_request_id,
InferenceJobProductCategory::GmiCloudVideo.to_str(),
InferenceCategory::VideoGeneration.to_str(),
args.maybe_prompt_token.map(|t| t.to_string()),
args.maybe_creator_user_token.map(|t| t.to_string()),
args.maybe_avt_token.map(|t| t.to_string()),
args.creator_ip_address,
Visibility::Public.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
JobStatusPlus::Pending.to_str(),
);
let query_result = query.execute(args.mysql_executor).await;
let record_id = match query_result {
Err(err) => return Err(DatabaseQueryError::from(err)),
Ok(res) => res.last_insert_id(),
};
info!(
"Insert generic inference job for GmiCloud: {} with record ID {}",
args.apriori_job_token, record_id
);
Ok(args.apriori_job_token.clone())
}
@@ -0,0 +1 @@
pub mod insert_generic_inference_job_for_gmicloud;
@@ -1,5 +1,6 @@
pub mod beeble;
pub mod fal;
pub mod gmicloud;
pub mod job;
pub mod moderation;
pub mod seedance2pro;
@@ -21,6 +21,10 @@ pub enum InferenceJobExternalThirdParty {
#[default]
Fal,
/// GmiCloud jobs
#[serde(rename = "gmicloud")]
GmiCloud,
/// Seedance 2 Pro jobs
#[serde(rename = "seedance2pro")]
Seedance2Pro,
@@ -40,6 +44,7 @@ impl InferenceJobExternalThirdParty {
match self {
Self::Beeble => "beeble",
Self::Fal => "fal",
Self::GmiCloud => "gmicloud",
Self::Seedance2Pro => "seedance2pro",
Self::Worldlabs => "worldlabs",
}
@@ -49,6 +54,7 @@ impl InferenceJobExternalThirdParty {
match value {
"beeble" => Ok(Self::Beeble),
"fal" => Ok(Self::Fal),
"gmicloud" => Ok(Self::GmiCloud),
"seedance2pro" => Ok(Self::Seedance2Pro),
"worldlabs" => Ok(Self::Worldlabs),
_ => Err(format!("invalid value: {:?}", value)),
@@ -61,6 +67,7 @@ impl InferenceJobExternalThirdParty {
BTreeSet::from([
Self::Beeble,
Self::Fal,
Self::GmiCloud,
Self::Seedance2Pro,
Self::Worldlabs,
])
@@ -79,6 +86,7 @@ mod tests {
fn test_serialization() {
assert_serialization(InferenceJobExternalThirdParty::Beeble, "beeble");
assert_serialization(InferenceJobExternalThirdParty::Fal, "fal");
assert_serialization(InferenceJobExternalThirdParty::GmiCloud, "gmicloud");
assert_serialization(InferenceJobExternalThirdParty::Seedance2Pro, "seedance2pro");
assert_serialization(InferenceJobExternalThirdParty::Worldlabs, "worldlabs");
}
@@ -87,6 +95,7 @@ mod tests {
fn to_str() {
assert_eq!(InferenceJobExternalThirdParty::Beeble.to_str(), "beeble");
assert_eq!(InferenceJobExternalThirdParty::Fal.to_str(), "fal");
assert_eq!(InferenceJobExternalThirdParty::GmiCloud.to_str(), "gmicloud");
assert_eq!(InferenceJobExternalThirdParty::Seedance2Pro.to_str(), "seedance2pro");
assert_eq!(InferenceJobExternalThirdParty::Worldlabs.to_str(), "worldlabs");
}
@@ -95,6 +104,7 @@ mod tests {
fn from_str() {
assert_eq!(InferenceJobExternalThirdParty::from_str("beeble").unwrap(), InferenceJobExternalThirdParty::Beeble);
assert_eq!(InferenceJobExternalThirdParty::from_str("fal").unwrap(), InferenceJobExternalThirdParty::Fal);
assert_eq!(InferenceJobExternalThirdParty::from_str("gmicloud").unwrap(), InferenceJobExternalThirdParty::GmiCloud);
assert_eq!(InferenceJobExternalThirdParty::from_str("seedance2pro").unwrap(), InferenceJobExternalThirdParty::Seedance2Pro);
assert_eq!(InferenceJobExternalThirdParty::from_str("worldlabs").unwrap(), InferenceJobExternalThirdParty::Worldlabs);
}
@@ -102,7 +112,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 4;
const EXPECTED_COUNT : usize = 5;
assert_eq!(InferenceJobExternalThirdParty::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobExternalThirdParty::iter().len(), EXPECTED_COUNT);
@@ -30,6 +30,11 @@ pub enum InferenceJobProductCategory {
FalObject,
FalBgRemoval,
// =============== GMICLOUD ===============
#[serde(rename = "gmicloud_video")]
GmiCloudVideo,
// =============== BEEBLE ===============
#[serde(rename = "beeble_video")]
@@ -144,6 +149,7 @@ impl InferenceJobProductCategory {
Self::FalVideo => "fal_video",
Self::FalObject => "fal_object",
Self::FalBgRemoval => "fal_bg_removal",
Self::GmiCloudVideo => "gmicloud_video",
Self::BeebleVideo => "beeble_video",
Self::Seedance2ProVideo => "seedance2pro_video",
Self::Seedance2ProCharacter => "seedance2pro_character",
@@ -180,6 +186,7 @@ impl InferenceJobProductCategory {
"fal_video" => Ok(Self::FalVideo),
"fal_object" => Ok(Self::FalObject),
"fal_bg_removal" => Ok(Self::FalBgRemoval),
"gmicloud_video" => Ok(Self::GmiCloudVideo),
"beeble_video" => Ok(Self::BeebleVideo),
"seedance2pro_video" => Ok(Self::Seedance2ProVideo),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
@@ -219,6 +226,7 @@ impl InferenceJobProductCategory {
Self::FalVideo,
Self::FalObject,
Self::FalBgRemoval,
Self::GmiCloudVideo,
Self::BeebleVideo,
Self::Seedance2ProVideo,
Self::Seedance2ProCharacter,
@@ -264,6 +272,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::FalVideo, "fal_video");
assert_serialization(InferenceJobProductCategory::FalObject, "fal_object");
assert_serialization(InferenceJobProductCategory::FalBgRemoval, "fal_bg_removal");
assert_serialization(InferenceJobProductCategory::GmiCloudVideo, "gmicloud_video");
assert_serialization(InferenceJobProductCategory::BeebleVideo, "beeble_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideo, "seedance2pro_video");
assert_serialization(InferenceJobProductCategory::Seedance2ProCharacter, "seedance2pro_character");
@@ -299,6 +308,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::FalVideo.to_str(), "fal_video");
assert_eq!(InferenceJobProductCategory::FalObject.to_str(), "fal_object");
assert_eq!(InferenceJobProductCategory::FalBgRemoval.to_str(), "fal_bg_removal");
assert_eq!(InferenceJobProductCategory::GmiCloudVideo.to_str(), "gmicloud_video");
assert_eq!(InferenceJobProductCategory::BeebleVideo.to_str(), "beeble_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideo.to_str(), "seedance2pro_video");
assert_eq!(InferenceJobProductCategory::Seedance2ProCharacter.to_str(), "seedance2pro_character");
@@ -333,6 +343,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("fal_video").unwrap(), InferenceJobProductCategory::FalVideo);
assert_eq!(InferenceJobProductCategory::from_str("fal_object").unwrap(), InferenceJobProductCategory::FalObject);
assert_eq!(InferenceJobProductCategory::from_str("fal_bg_removal").unwrap(), InferenceJobProductCategory::FalBgRemoval);
assert_eq!(InferenceJobProductCategory::from_str("gmicloud_video").unwrap(), InferenceJobProductCategory::GmiCloudVideo);
assert_eq!(InferenceJobProductCategory::from_str("beeble_video").unwrap(), InferenceJobProductCategory::BeebleVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video").unwrap(), InferenceJobProductCategory::Seedance2ProVideo);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_character").unwrap(), InferenceJobProductCategory::Seedance2ProCharacter);
@@ -363,7 +374,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 31;
const EXPECTED_COUNT : usize = 32;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -19,6 +19,10 @@ pub enum InferenceJobType {
/// Instead, we receive web hook updates from Fal.
FalQueue,
/// GmiCloud jobs. We poll for results.
#[serde(rename = "gmicloud_queue")]
GmiCloudQueue,
/// Seedance 2 Pro jobs. We poll for results.
#[serde(rename = "seedance2pro_queue")]
Seedance2ProQueue,
@@ -126,6 +130,7 @@ impl InferenceJobType {
pub fn to_str(&self) -> &'static str {
match self {
Self::FalQueue => "fal_queue",
Self::GmiCloudQueue => "gmicloud_queue",
Self::BeebleQueue => "beeble_queue",
Self::Seedance2ProQueue => "seedance2pro_queue",
Self::Seedance2ProCharacter => "seedance2pro_character",
@@ -156,6 +161,7 @@ impl InferenceJobType {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"fal_queue" => Ok(Self::FalQueue),
"gmicloud_queue" => Ok(Self::GmiCloudQueue),
"beeble_queue" => Ok(Self::BeebleQueue),
"seedance2pro_queue" => Ok(Self::Seedance2ProQueue),
"seedance2pro_character" => Ok(Self::Seedance2ProCharacter),
@@ -189,6 +195,7 @@ impl InferenceJobType {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::FalQueue,
Self::GmiCloudQueue,
Self::BeebleQueue,
Self::Seedance2ProQueue,
Self::Seedance2ProCharacter,
@@ -233,6 +240,7 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(InferenceJobType::FalQueue, "fal_queue");
assert_serialization(InferenceJobType::GmiCloudQueue, "gmicloud_queue");
assert_serialization(InferenceJobType::BeebleQueue, "beeble_queue");
assert_serialization(InferenceJobType::Seedance2ProQueue, "seedance2pro_queue");
assert_serialization(InferenceJobType::Seedance2ProCharacter, "seedance2pro_character");
@@ -262,6 +270,7 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(InferenceJobType::FalQueue.to_str(), "fal_queue");
assert_eq!(InferenceJobType::GmiCloudQueue.to_str(), "gmicloud_queue");
assert_eq!(InferenceJobType::BeebleQueue.to_str(), "beeble_queue");
assert_eq!(InferenceJobType::Seedance2ProQueue.to_str(), "seedance2pro_queue");
assert_eq!(InferenceJobType::Seedance2ProCharacter.to_str(), "seedance2pro_character");
@@ -291,6 +300,7 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(InferenceJobType::from_str("fal_queue").unwrap(), InferenceJobType::FalQueue);
assert_eq!(InferenceJobType::from_str("gmicloud_queue").unwrap(), InferenceJobType::GmiCloudQueue);
assert_eq!(InferenceJobType::from_str("beeble_queue").unwrap(), InferenceJobType::BeebleQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_queue").unwrap(), InferenceJobType::Seedance2ProQueue);
assert_eq!(InferenceJobType::from_str("seedance2pro_character").unwrap(), InferenceJobType::Seedance2ProCharacter);
@@ -320,7 +330,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 25;
const EXPECTED_COUNT : usize = 26;
assert_eq!(InferenceJobType::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobType::iter().len(), EXPECTED_COUNT);
@@ -130,6 +130,10 @@ pub enum CommonModelType {
Seedance2p0,
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "sora_2")]
Sora2,
#[serde(rename = "sora_2_pro")]
@@ -219,6 +223,8 @@ impl CommonModelType {
Self::Seedance1p5Pro => "seedance_1p5_pro",
Self::Seedance2p0 => "seedance_2p0",
Self::Seedance2p0Fast => "seedance_2p0_fast",
Self::Seedance2p0Global => "seedance_2p0_g",
Self::Seedance2p0FastGlobal => "seedance_2p0_fast_g",
Self::Sora2 => "sora_2",
Self::Sora2Pro => "sora_2_pro",
Self::Veo2 => "veo_2",
@@ -289,6 +295,8 @@ impl CommonModelType {
"seedance_1p5_pro" => Ok(Self::Seedance1p5Pro),
"seedance_2p0" => Ok(Self::Seedance2p0),
"seedance_2p0_fast" => Ok(Self::Seedance2p0Fast),
"seedance_2p0_g" => Ok(Self::Seedance2p0Global),
"seedance_2p0_fast_g" => Ok(Self::Seedance2p0FastGlobal),
"sora_2" => Ok(Self::Sora2),
"sora_2_pro" => Ok(Self::Sora2Pro),
"veo_2" => Ok(Self::Veo2),
@@ -363,6 +371,8 @@ impl CommonModelType {
Self::Seedance1p5Pro,
Self::Seedance2p0,
Self::Seedance2p0Fast,
Self::Seedance2p0Global,
Self::Seedance2p0FastGlobal,
Self::Sora2,
Self::Sora2Pro,
Self::Veo2,
@@ -434,6 +444,8 @@ impl CommonModelType {
Self::Seedance1p5Pro => CommonModelClass::Video,
Self::Seedance2p0 => CommonModelClass::Video,
Self::Seedance2p0Fast => CommonModelClass::Video,
Self::Seedance2p0Global => CommonModelClass::Video,
Self::Seedance2p0FastGlobal => CommonModelClass::Video,
Self::Sora2 => CommonModelClass::Video,
Self::Sora2Pro => CommonModelClass::Video,
Self::Veo2 => CommonModelClass::Video,
@@ -514,6 +526,8 @@ mod tests {
assert_serialization(CommonModelType::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonModelType::Seedance2p0, "seedance_2p0");
assert_serialization(CommonModelType::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonModelType::Seedance2p0Global, "seedance_2p0_g");
assert_serialization(CommonModelType::Seedance2p0FastGlobal, "seedance_2p0_fast_g");
assert_serialization(CommonModelType::Sora2, "sora_2");
assert_serialization(CommonModelType::Sora2Pro, "sora_2_pro");
assert_serialization(CommonModelType::Veo2, "veo_2");
@@ -581,6 +595,8 @@ mod tests {
assert_eq!(CommonModelType::Seedance1p5Pro.to_str(), "seedance_1p5_pro");
assert_eq!(CommonModelType::Seedance2p0.to_str(), "seedance_2p0");
assert_eq!(CommonModelType::Seedance2p0Fast.to_str(), "seedance_2p0_fast");
assert_eq!(CommonModelType::Seedance2p0Global.to_str(), "seedance_2p0_g");
assert_eq!(CommonModelType::Seedance2p0FastGlobal.to_str(), "seedance_2p0_fast_g");
assert_eq!(CommonModelType::Sora2.to_str(), "sora_2");
assert_eq!(CommonModelType::Sora2Pro.to_str(), "sora_2_pro");
assert_eq!(CommonModelType::Veo2.to_str(), "veo_2");
@@ -648,6 +664,8 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedance_1p5_pro").unwrap(), CommonModelType::Seedance1p5Pro);
assert_eq!(CommonModelType::from_str("seedance_2p0").unwrap(), CommonModelType::Seedance2p0);
assert_eq!(CommonModelType::from_str("seedance_2p0_fast").unwrap(), CommonModelType::Seedance2p0Fast);
assert_eq!(CommonModelType::from_str("seedance_2p0_g").unwrap(), CommonModelType::Seedance2p0Global);
assert_eq!(CommonModelType::from_str("seedance_2p0_fast_g").unwrap(), CommonModelType::Seedance2p0FastGlobal);
assert_eq!(CommonModelType::from_str("sora_2").unwrap(), CommonModelType::Sora2);
assert_eq!(CommonModelType::from_str("sora_2_pro").unwrap(), CommonModelType::Sora2Pro);
assert_eq!(CommonModelType::from_str("veo_2").unwrap(), CommonModelType::Veo2);
@@ -669,7 +687,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 58);
assert_eq!(variants.len(), 60);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -717,6 +735,8 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance1p5Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Fast));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0Global));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedance2p0FastGlobal));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2));
assert_eq!(variants.pop_first(), Some(CommonModelType::Sora2Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Veo2));
@@ -44,6 +44,12 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "sora_2")]
Sora2,
@@ -83,6 +89,8 @@ impl CommonVideoModel {
Self::Seedance1p5Pro => CommonModelType::Seedance1p5Pro,
Self::Seedance2p0 => CommonModelType::Seedance2p0,
Self::Seedance2p0Fast => CommonModelType::Seedance2p0Fast,
Self::Seedance2p0Global => CommonModelType::Seedance2p0Global,
Self::Seedance2p0FastGlobal => CommonModelType::Seedance2p0FastGlobal,
Self::Sora2 => CommonModelType::Sora2,
Self::Sora2Pro => CommonModelType::Sora2Pro,
Self::Veo2 => CommonModelType::Veo2,
@@ -115,6 +123,8 @@ mod tests {
assert_serialization(CommonVideoModel::Seedance1p5Pro, "seedance_1p5_pro");
assert_serialization(CommonVideoModel::Seedance2p0, "seedance_2p0");
assert_serialization(CommonVideoModel::Seedance2p0Fast, "seedance_2p0_fast");
assert_serialization(CommonVideoModel::Seedance2p0Global, "seedance_2p0_g");
assert_serialization(CommonVideoModel::Seedance2p0FastGlobal, "seedance_2p0_fast_g");
assert_serialization(CommonVideoModel::Sora2, "sora_2");
assert_serialization(CommonVideoModel::Sora2Pro, "sora_2_pro");
assert_serialization(CommonVideoModel::Veo2, "veo_2");
@@ -140,6 +150,8 @@ mod tests {
("seedance_1p5_pro", CommonVideoModel::Seedance1p5Pro),
("seedance_2p0", CommonVideoModel::Seedance2p0),
("seedance_2p0_fast", CommonVideoModel::Seedance2p0Fast),
("seedance_2p0_g", CommonVideoModel::Seedance2p0Global),
("seedance_2p0_fast_g", CommonVideoModel::Seedance2p0FastGlobal),
("sora_2", CommonVideoModel::Sora2),
("sora_2_pro", CommonVideoModel::Sora2Pro),
("veo_2", CommonVideoModel::Veo2),
@@ -172,6 +184,8 @@ mod tests {
CommonVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Global,
CommonVideoModel::Seedance2p0FastGlobal,
CommonVideoModel::Sora2,
CommonVideoModel::Sora2Pro,
CommonVideoModel::Veo2,
@@ -203,6 +217,8 @@ mod tests {
(CommonVideoModel::Seedance1p5Pro, CommonModelType::Seedance1p5Pro),
(CommonVideoModel::Seedance2p0, CommonModelType::Seedance2p0),
(CommonVideoModel::Seedance2p0Fast, CommonModelType::Seedance2p0Fast),
(CommonVideoModel::Seedance2p0Global, CommonModelType::Seedance2p0Global),
(CommonVideoModel::Seedance2p0FastGlobal, CommonModelType::Seedance2p0FastGlobal),
(CommonVideoModel::Sora2, CommonModelType::Sora2),
(CommonVideoModel::Sora2Pro, CommonModelType::Sora2Pro),
(CommonVideoModel::Veo2, CommonModelType::Veo2),
@@ -36,6 +36,65 @@ which reuses an existing DB connection.
When adding new request/response/error types, add them to `src/docs/api_doc.rs` in the
schemas section (alphabetically sorted). Types from `artcraft_api_defs` need explicit imports.
## Running locally
Environment variables are auto-loaded by Rust at startup from these files:
- `.env` (repo root)
- `crates/service/web/storyteller_web/config/storyteller-web.common.env`
- `crates/service/web/storyteller_web/config/storyteller-web.development.env`
- `crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env`
**Do NOT** use `SQLX_OFFLINE=true` — the server needs live DB access.
```bash
# From the repo root, just run:
cargo run --bin storyteller-web
```
The server binds to `0.0.0.0:12345` by default. Compilation takes ~3 minutes on first build.
## Testing endpoints with curl
```bash
# 1. Log in to get session cookies:
curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"newuser4","password":"newuser4"}'
# 2. Generate video (GmiCloud Seedance 2.0 Fast Global):
# Requires a UUID-format idempotency_token.
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_fast_g",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}'
# 3. Generate video (GmiCloud Seedance 2.0 Global, 720p):
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/omni_gen/generate/video \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0_g",
"prompt": "a corgi running through a field",
"duration_seconds": 5,
"resolution": "seven_twenty_p",
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
}'
```
A successful response returns 200 with `{"success": true, "inference_job_token": "jinf_..."}`.
**Notes:**
- `idempotency_token` must be UUID format (32 hex chars or 36 with dashes)
- `resolution` uses enum names: `four_eighty_p`, `seven_twenty_p`, `ten_eighty_p`
- `aspect_ratio` uses enum names: `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`, etc.
- Use a new `idempotency_token` for each request to avoid duplicate detection
## Pager Integration
- The `Pager` is available on `ServerState` as `server_state.pager`
@@ -54,6 +54,7 @@ beeble_client.workspace = true
error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
filesys.workspace = true
google_sign_in = { path = "../../../api_clients/google_sign_in" }
hashing = { path = "../../../lib/files/hashing" }
@@ -155,6 +155,85 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
// models.push(OmniGenVideoModelDetails {
// model: CommonVideoModel::Seedance2p0Global,
// model_creator: Some(ModelCreator::Bytedance),
// full_name: Some("Seedance 2.0 (Global)".to_string()),
// text_prompt_supported: Some(true),
// starting_keyframe_supported: Some(true),
// ending_keyframe_supported: Some(true),
// image_references_supported: Some(true),
// image_references_max: Some(9),
// audio_references_supported: Some(true),
// audio_references_max: Some(3),
// audio_references_max_total_duration_seconds: Some(15),
// video_references_supported: Some(true),
// video_references_max: Some(3),
// video_references_max_total_duration_seconds: Some(15),
// character_references_supported: Some(true),
// character_references_max: Some(9),
// aspect_ratio_options: Some(vec![
// CommonAspectRatio::WideTwentyOneByNine,
// CommonAspectRatio::WideSixteenByNine,
// CommonAspectRatio::WideFourByThree,
// CommonAspectRatio::Square,
// CommonAspectRatio::TallThreeByFour,
// CommonAspectRatio::TallNineBySixteen,
// ]),
// aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// resolution_options: Some(vec![
// CommonResolution::FourEightyP,
// CommonResolution::SevenTwentyP,
// CommonResolution::TenEightyP,
// ]),
// resolution_default: Some(CommonResolution::SevenTwentyP),
// batch_size_options: Some(vec![1, 2, 4]),
// batch_size_default: Some(1),
// duration_seconds_min: Some(4),
// duration_seconds_max: Some(15),
// duration_seconds_default: Some(5),
// ..Default::default()
// });
//
// models.push(OmniGenVideoModelDetails {
// model: CommonVideoModel::Seedance2p0FastGlobal,
// model_creator: Some(ModelCreator::Bytedance),
// full_name: Some("Seedance 2.0 Fast (Global)".to_string()),
// text_prompt_supported: Some(true),
// starting_keyframe_supported: Some(true),
// ending_keyframe_supported: Some(true),
// image_references_supported: Some(true),
// image_references_max: Some(9),
// audio_references_supported: Some(true),
// audio_references_max: Some(3),
// audio_references_max_total_duration_seconds: Some(15),
// video_references_supported: Some(true),
// video_references_max: Some(3),
// video_references_max_total_duration_seconds: Some(15),
// character_references_supported: Some(true),
// character_references_max: Some(9),
// aspect_ratio_options: Some(vec![
// CommonAspectRatio::WideTwentyOneByNine,
// CommonAspectRatio::WideSixteenByNine,
// CommonAspectRatio::WideFourByThree,
// CommonAspectRatio::Square,
// CommonAspectRatio::TallThreeByFour,
// CommonAspectRatio::TallNineBySixteen,
// ]),
// aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
// resolution_options: Some(vec![
// CommonResolution::FourEightyP,
// CommonResolution::SevenTwentyP,
// ]),
// resolution_default: Some(CommonResolution::SevenTwentyP),
// batch_size_options: Some(vec![1, 2, 4]),
// batch_size_default: Some(1),
// duration_seconds_min: Some(4),
// duration_seconds_max: Some(15),
// duration_seconds_default: Some(5),
// ..Default::default()
// });
// TODO(bt,2026-04-10): Veo 2 image-to-video doesn't support aspect ratio
models.push(OmniGenVideoModelDetails {
is_disabled: Some(true), // TODO: Temporarily disable
@@ -141,6 +141,8 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
CommonVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
CommonVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
CommonVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
CommonVideoModel::Seedance2p0Global => RouterVideoModel::Seedance2p0Global,
CommonVideoModel::Seedance2p0FastGlobal => RouterVideoModel::Seedance2p0FastGlobal,
CommonVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
CommonVideoModel::Sora2 => RouterVideoModel::Sora2,
CommonVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
@@ -3,6 +3,7 @@
use artcraft_router::api::provider::Provider;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::client::router_gmicloud_client::RouterGmiCloudClient;
use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClient;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
@@ -27,6 +28,11 @@ pub fn build_router_client(
);
Ok(RouterClient::Fal(fal_client))
}
Provider::GmiCloud => {
Ok(RouterClient::GmiCloud(RouterGmiCloudClient::new(
server_state.gmicloud.api_key.clone(),
)))
}
other => {
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported provider for video generation: {:?}", other),
@@ -0,0 +1,45 @@
use log::warn;
use mysql_queries::queries::generic_inference::gmicloud::insert_generic_inference_job_for_gmicloud::{
insert_generic_inference_job_for_gmicloud, InsertGmiCloudInferenceJobArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::shared_job_args::SharedJobArgs;
pub struct InsertGmiCloudJobArgs<'a, 'tx> {
pub external_request_id: &'a str,
pub shared: SharedJobArgs<'a, 'tx>,
}
pub async fn insert_gmicloud_job(args: InsertGmiCloudJobArgs<'_, '_>) -> Result<InferenceJobToken, AdvancedCommonWebError> {
let InsertGmiCloudJobArgs {
external_request_id,
shared,
} = args;
let db_result = insert_generic_inference_job_for_gmicloud(
InsertGmiCloudInferenceJobArgs {
apriori_job_token: shared.apriori_job_token,
uuid_idempotency_token: shared.idempotency_token,
external_request_id,
maybe_prompt_token: shared.maybe_prompt_token,
maybe_creator_user_token: Some(shared.user_token),
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
phantom: Default::default(),
}
).await;
match db_result {
Ok(token) => Ok(token),
Err(err) => {
warn!("Error inserting GmiCloud inference job: {:?}", err);
Err(AdvancedCommonWebError::from_error(err))
}
}
}
@@ -1,3 +1,4 @@
pub mod insert_fal_job;
pub mod insert_gmicloud_job;
pub mod insert_seedance2pro_jobs;
pub mod shared_job_args;
@@ -33,6 +33,7 @@ use crate::http_server::common_responses::advanced_common_web_error::AdvancedCom
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v1::run_pipeline_v1::{run_pipeline_v1, RunPipelineV1Args};
@@ -186,6 +187,8 @@ pub async fn omni_gen_video_generate_handler(
Some(CommonVideoModel::HappyHorse1p0) => true,
Some(CommonVideoModel::Seedance2p0) => true,
Some(CommonVideoModel::Seedance2p0Fast) => true,
Some(CommonVideoModel::Seedance2p0Global) => true,
Some(CommonVideoModel::Seedance2p0FastGlobal) => true,
_ => false,
};
@@ -377,6 +380,27 @@ pub async fn omni_gen_video_generate_handler(
vec![payload.inference_job_token.clone()],
)
}
GenerateVideoResponse::GmiCloud(payload) => {
info!("Inserting GmiCloud job with token: {:?}", pipeline_result.billing.apriori_job_token);
let token = insert_gmicloud_job(InsertGmiCloudJobArgs {
external_request_id: &payload.request_id,
shared: SharedJobArgs {
apriori_job_token: &pipeline_result.billing.apriori_job_token,
idempotency_token: &idempotency_token,
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
}).await?;
(
token.clone(),
vec![token],
)
}
other => {
error!("Unexpected generation response variant: {:?}", other);
return Err(AdvancedCommonWebError::server_error_with_message("Unexpected generation response"));
@@ -42,6 +42,8 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
CommonVideoModel::HappyHorse1p0 => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0 => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0Fast => Provider::Seedance2Pro,
CommonVideoModel::Seedance2p0Global => Provider::GmiCloud,
CommonVideoModel::Seedance2p0FastGlobal => Provider::GmiCloud,
_ => Provider::Fal,
};
@@ -55,22 +57,34 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
AdvancedCommonWebError::from_error(e)
})?;
// 2. Calculate cost (swap provider to Artcraft for billing)
let mut cost_builder = router_builder.clone();
cost_builder.provider = Provider::Artcraft;
// 2. Calculate cost.
// For Artcraft-billable models, swap provider to Artcraft so credits = cents.
// For GmiCloud, use the execution request's cost directly (no Artcraft equivalent).
let cost = if matches!(provider, Provider::GmiCloud) {
draft_or_request.estimate_cost()
.map_err(|e| {
warn!("Failed to estimate cost for v2 (GmiCloud): {}", e);
AdvancedCommonWebError::from_error(e)
})?
.cost_in_usd_cents
.unwrap_or(0)
} else {
let mut cost_builder = router_builder.clone();
cost_builder.provider = Provider::Artcraft;
let cost = cost_builder.build2()
.map_err(|e| {
warn!("Failed to build2 cost estimate for v2: {}", e);
AdvancedCommonWebError::from_error(e)
})?
.estimate_cost()
.map_err(|e| {
warn!("Failed to estimate cost for v2: {}", e);
AdvancedCommonWebError::from_error(e)
})?
.cost_in_credits
.unwrap_or(0);
cost_builder.build2()
.map_err(|e| {
warn!("Failed to build2 cost estimate for v2: {}", e);
AdvancedCommonWebError::from_error(e)
})?
.estimate_cost()
.map_err(|e| {
warn!("Failed to estimate cost for v2: {}", e);
AdvancedCommonWebError::from_error(e)
})?
.cost_in_credits
.unwrap_or(0)
};
info!("v2 estimated cost: {} credits", cost);
@@ -80,7 +80,7 @@ use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator
use crate::startup::build_pager::build_pager;
use crate::state::certs::google_sign_in_cert::GoogleSignInCert;
use crate::state::memory_cache::model_token_to_info_cache::ModelTokenToInfoCache;
use crate::state::server_state::{BeebleData, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::state::server_state::{BeebleData, DurableInMemoryCaches, EnvConfig, EphemeralInMemoryCaches, FalData, GmiCloudData, InMemoryCaches, OpenAiData, ResendData, Seedance2ProData, ServerInfo, ServerState, StaticFeatureFlags, StripeSettings, TrollBans, WorldLabsData};
use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
@@ -422,6 +422,8 @@ async fn main() -> AnyhowResult<()> {
let resend_api_key = easyenv::get_env_string_required("RESEND_API_KEY")?;
let gmicloud_api_key = easyenv::get_env_string_required("GMICLOUD_API_KEY")?;
let worldlabs_api_key = easyenv::get_env_string_required("WORLDLABS_API_KEY")?;
let startup_time = Utc::now();
@@ -485,6 +487,9 @@ async fn main() -> AnyhowResult<()> {
api_key: fal_api_key,
webhook_url: fal_webhook_url,
},
gmicloud: GmiCloudData {
api_key: gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey::new(gmicloud_api_key),
},
seedance2pro: Seedance2ProData {
cookies: easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")?,
cookies_whitelist: easyenv::get_env_string_required("SEEDANCE2PRO_WHITELIST_COOKIES")?,
@@ -96,6 +96,8 @@ pub struct ServerState {
pub fal: FalData,
pub gmicloud: GmiCloudData,
pub beeble: BeebleData,
pub seedance2pro: Seedance2ProData,
@@ -324,6 +326,12 @@ pub struct FalData {
pub webhook_url: String,
}
/// GmiCloud integration
#[derive(Clone)]
pub struct GmiCloudData {
pub api_key: gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey,
}
/// Beeble SwitchX integration
#[derive(Clone)]
pub struct BeebleData {