adding hf to tauri+frontend, wip
This commit is contained in:
parent
fd691beb10
commit
2368f9f824
|
|
@ -0,0 +1,138 @@
|
|||
//! Shared pieces of the first-party (cookie-session) Higgsfield enqueue path
|
||||
//! for images and videos: the router client for a stored credential, the
|
||||
//! media-token → CDN URL map the router needs to re-upload references to
|
||||
//! Higgsfield, and running a built request through the draft phase.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use log::{info, warn};
|
||||
use router::client::router_client::RouterClient;
|
||||
use router::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use router::generate::generate_image::generate_image_response::GenerateImageResponse;
|
||||
use router::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
|
||||
use router::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use router::generate::generate_video::generate_video_response::GenerateVideoResponse;
|
||||
use router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_image::utils::map_media_files_to_urls::map_media_file_tokens_to_cdn_urls;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
use crate::services::higgsfield::higgsfield_session_from_credential::higgsfield_session_from_credential;
|
||||
|
||||
/// Batch job ids are stored on the task as one comma-separated
|
||||
/// `provider_job_id`, so the poller can follow every job of the set.
|
||||
pub const HIGGSFIELD_JOB_ID_SEPARATOR: char = ',';
|
||||
|
||||
/// The router client for a stored Higgsfield credential.
|
||||
pub fn higgsfield_router_client(credential: &AuthCredential) -> Result<RouterClient, GenerateError> {
|
||||
let session = higgsfield_session_from_credential(credential)?;
|
||||
Ok(RouterClient::Higgsfield(RouterHiggsfieldClient::new(session)))
|
||||
}
|
||||
|
||||
/// Resolve reference media tokens to their ArtCraft CDN URLs. Higgsfield
|
||||
/// can't fetch ArtCraft media itself, so the router downloads each URL and
|
||||
/// re-uploads the bytes as Higgsfield reference media.
|
||||
pub async fn higgsfield_media_url_map(tokens: &[MediaFileToken]) -> Result<HashMap<MediaFileToken, String>, GenerateError> {
|
||||
if tokens.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let urls = map_media_file_tokens_to_cdn_urls(tokens, &ApiHost::Storyteller).await?;
|
||||
Ok(tokens.iter().cloned().zip(urls).collect())
|
||||
}
|
||||
|
||||
/// Build, finalize (uploading references) and send an image request.
|
||||
pub async fn send_higgsfield_image_request(
|
||||
builder: GenerateImageRequestBuilder,
|
||||
client: &RouterClient,
|
||||
media_url_map: &HashMap<MediaFileToken, String>,
|
||||
) -> Result<GenerateImageResponse, GenerateError> {
|
||||
let request = match builder.build2().map_err(|err| {
|
||||
warn!("Could not build Higgsfield image request: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})? {
|
||||
ImageGenerationDraftOrRequest::Request(request) => request,
|
||||
ImageGenerationDraftOrRequest::Draft(draft) => {
|
||||
info!("Higgsfield image request has references; uploading them first");
|
||||
let context = ImageGenerationDraftContext {
|
||||
client: Some(client),
|
||||
media_file_to_artcraft_url_map: Some(media_url_map),
|
||||
};
|
||||
draft.finalize(context).await.map_err(|err| {
|
||||
warn!("Could not upload references to Higgsfield: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
request.send_request(client).await.map_err(|err| {
|
||||
warn!("Higgsfield image generation failed: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build, finalize (uploading keyframes and references) and send a video
|
||||
/// request.
|
||||
pub async fn send_higgsfield_video_request(
|
||||
builder: GenerateVideoRequestBuilder,
|
||||
client: &RouterClient,
|
||||
media_url_map: &HashMap<MediaFileToken, String>,
|
||||
) -> Result<GenerateVideoResponse, GenerateError> {
|
||||
let request = match builder.build2().map_err(|err| {
|
||||
warn!("Could not build Higgsfield video request: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})? {
|
||||
VideoGenerationDraftOrRequest::Request(request) => request,
|
||||
VideoGenerationDraftOrRequest::Draft(draft) => {
|
||||
info!("Higgsfield video request has media; uploading it first");
|
||||
let context = VideoGenerationDraftContext {
|
||||
client: Some(client),
|
||||
media_file_to_artcraft_url_map: Some(media_url_map),
|
||||
character_token_to_kinovi_id_map: None,
|
||||
};
|
||||
draft.finalize(context).await.map_err(|err| {
|
||||
warn!("Could not upload media to Higgsfield: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
request.send_request(client).await.map_err(|err| {
|
||||
warn!("Higgsfield video generation failed: {:?}", err);
|
||||
GenerateError::from(err)
|
||||
})
|
||||
}
|
||||
|
||||
/// One `provider_job_id` for a Higgsfield job set.
|
||||
pub fn join_higgsfield_job_ids(job_ids: &[String]) -> String {
|
||||
job_ids.join(&HIGGSFIELD_JOB_ID_SEPARATOR.to_string())
|
||||
}
|
||||
|
||||
/// The job ids stored on a task by [`join_higgsfield_job_ids`].
|
||||
pub fn split_higgsfield_job_ids(provider_job_id: &str) -> Vec<String> {
|
||||
provider_job_id
|
||||
.split(HIGGSFIELD_JOB_ID_SEPARATOR)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn job_ids_round_trip() {
|
||||
let ids = vec!["job_a".to_string(), "job_b".to_string()];
|
||||
let joined = join_higgsfield_job_ids(&ids);
|
||||
assert_eq!(joined, "job_a,job_b");
|
||||
assert_eq!(split_higgsfield_job_ids(&joined), ids);
|
||||
assert_eq!(split_higgsfield_job_ids("solo"), vec!["solo".to_string()]);
|
||||
assert!(split_higgsfield_job_ids(" , ").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ fn billing_error(
|
|||
let provider = match reason.provider {
|
||||
BillingProvider::Artcraft => GenerationSource::Artcraft,
|
||||
BillingProvider::Fal => GenerationSource::Fal,
|
||||
BillingProvider::Higgsfield => GenerationSource::Higgsfield,
|
||||
BillingProvider::Kinovi => GenerationSource::Artcraft, // NB: We don't support Kinovi yet.
|
||||
BillingProvider::Midjourney => GenerationSource::Midjourney,
|
||||
BillingProvider::Sora => GenerationSource::Sora,
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ pub mod maybe_notify_frontend_of_credential_errors;
|
|||
pub mod maybe_notify_frontend_of_grok_errors;
|
||||
pub mod notify_frontend_of_errors;
|
||||
pub mod generation_credential;
|
||||
pub mod higgsfield_generation;
|
||||
pub mod router_image_request_to_artcraft_prompt;
|
||||
pub mod router_video_request_to_artcraft_prompt;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
|
|||
RouterImageModel::GptImage2 => Some(CommonModelType::GptImage2),
|
||||
RouterImageModel::NanoBanana => Some(CommonModelType::NanoBanana),
|
||||
RouterImageModel::NanoBanana2 => Some(CommonModelType::NanoBanana2),
|
||||
// Higgsfield-only; storyteller-web has no prompt type for it.
|
||||
RouterImageModel::NanoBanana2Lite => None,
|
||||
RouterImageModel::NanoBananaPro => Some(CommonModelType::NanoBananaPro),
|
||||
RouterImageModel::Seedream4 => Some(CommonModelType::Seedream4),
|
||||
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
|
||||
|
|
@ -71,6 +73,7 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationSource
|
|||
RouterProvider::Artcraft => GenerationSource::Artcraft,
|
||||
RouterProvider::Fal => GenerationSource::Fal,
|
||||
RouterProvider::Grok => GenerationSource::Grok,
|
||||
RouterProvider::Higgsfield => GenerationSource::Higgsfield,
|
||||
RouterProvider::Midjourney => GenerationSource::Midjourney,
|
||||
// Unused providers -> ArtCraft
|
||||
RouterProvider::Seedance2Pro => GenerationSource::Artcraft ,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
|
|||
RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast),
|
||||
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
|
||||
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
|
||||
// Higgsfield-only; storyteller-web has no prompt types for them.
|
||||
RouterVideoModel::Seedance2p5
|
||||
| RouterVideoModel::Seedance2p5Edit
|
||||
| RouterVideoModel::MinimaxH3 => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +96,7 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationSource
|
|||
RouterProvider::Artcraft => GenerationSource::Artcraft,
|
||||
RouterProvider::Fal => GenerationSource::Fal,
|
||||
RouterProvider::Grok => GenerationSource::Grok,
|
||||
RouterProvider::Higgsfield => GenerationSource::Higgsfield,
|
||||
RouterProvider::Midjourney => GenerationSource::Midjourney,
|
||||
// Unused providers -> ArtCraft
|
||||
RouterProvider::Seedance2Pro => GenerationSource::Artcraft ,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use core_types::enums::generation_source::GenerationSource;
|
|||
use artcraft_client::enums::common::generation::common_model_type::CommonModelType;
|
||||
use errors::AnyhowError;
|
||||
use grok_consumer_client::error::grok_error::GrokError;
|
||||
use higgsfield_client::error::higgsfield_error::HiggsfieldError;
|
||||
use midjourney_client::error::midjourney_error::MidjourneyError;
|
||||
use openai_sora_client::error::sora_error::SoraError;
|
||||
use artcraft_client::error::storyteller_error::StorytellerError;
|
||||
|
|
@ -112,6 +113,7 @@ pub struct BillingIssueReason {
|
|||
pub enum BillingProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Higgsfield,
|
||||
Kinovi,
|
||||
Midjourney,
|
||||
Sora,
|
||||
|
|
@ -123,6 +125,9 @@ pub enum ProviderFailureReason {
|
|||
/// NB: The Grok client doesn't say why certain errors (eg. missing fields) happen, so we synthesize this.
|
||||
GrokJobEnqueueFailed,
|
||||
//Fal(FalErrorPlus),
|
||||
/// First-party Higgsfield failed. `needs_browser_reauth()` on the inner
|
||||
/// error means the user has to log into Higgsfield again.
|
||||
HiggsfieldError(HiggsfieldError),
|
||||
MidjourneyError(MidjourneyError),
|
||||
/// NB: The midjourney client doesn't categorize all errors, so we have to do so on our end.
|
||||
MidjourneyJobEnqueueFailed,
|
||||
|
|
@ -219,6 +224,12 @@ impl From<GrokError> for GenerateError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<HiggsfieldError> for GenerateError {
|
||||
fn from(value: HiggsfieldError) -> Self {
|
||||
Self::ProviderFailure(ProviderFailureReason::HiggsfieldError(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MidjourneyError> for GenerateError {
|
||||
fn from(value: MidjourneyError) -> Self {
|
||||
Self::ProviderFailure(ProviderFailureReason::MidjourneyError(value))
|
||||
|
|
@ -260,6 +271,7 @@ impl From<ArtcraftRouterError> for GenerateError {
|
|||
ProviderError::GmiCloud(_) => BillingProvider::Artcraft,
|
||||
ProviderError::GrokApi(_) => BillingProvider::Artcraft,
|
||||
ProviderError::Grok(_) => BillingProvider::Artcraft,
|
||||
ProviderError::Higgsfield(_) => BillingProvider::Higgsfield,
|
||||
ProviderError::WorldLabs(_) => BillingProvider::Artcraft,
|
||||
};
|
||||
Self::BillingIssue(BillingIssueReason { provider })
|
||||
|
|
@ -270,6 +282,8 @@ impl From<ArtcraftRouterError> for GenerateError {
|
|||
ArtcraftRouterError::Provider(ProviderError::GrokApi(_)) => Self::ArtcraftRouterNotYetSupportedProvider("grok_api"),
|
||||
// First-party (cookie-session) Grok Imagine websocket/POST failure. The string carries the raw detail.
|
||||
ArtcraftRouterError::Provider(ProviderError::Grok(message)) => Self::ProviderRejected(message),
|
||||
// First-party (cookie-session) Higgsfield.
|
||||
ArtcraftRouterError::Provider(ProviderError::Higgsfield(e)) => Self::ProviderFailure(ProviderFailureReason::HiggsfieldError(e)),
|
||||
ArtcraftRouterError::Provider(ProviderError::Seedance2Pro(_)) => Self::ArtcraftRouterNotYetSupportedProvider("seedance2pro"),
|
||||
ArtcraftRouterError::Provider(ProviderError::Midjourney(e)) => Self::ProviderFailure(ProviderFailureReason::MidjourneyError(e)),
|
||||
ArtcraftRouterError::Provider(ProviderError::MidjourneySubscriptionRequired(message)) => Self::ProviderRejected(message),
|
||||
|
|
@ -279,6 +293,7 @@ impl From<ArtcraftRouterError> for GenerateError {
|
|||
ArtcraftRouterError::Provider(ProviderError::WorldLabs(_)) => Self::ArtcraftRouterNotYetSupportedProvider("world_labs"),
|
||||
ArtcraftRouterError::UnsupportedModel(model) => Self::NotYetImplemented(format!("Unsupported model: {}", model)),
|
||||
ArtcraftRouterError::UnsupportedProviderAndModelForNewApi(_message) => Self::ArtcraftRouterNotYetSupportedProvider("unsupported model for new router API"),
|
||||
ArtcraftRouterError::ProviderResponseInvalid(message) => Self::ProviderRejected(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use crate::commands::generate::common::generation_credential::{
|
|||
credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential,
|
||||
};
|
||||
use crate::commands::generate::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::commands::generate::generate_image::higgsfield::enqueue_via_higgsfield::enqueue_via_higgsfield;
|
||||
use crate::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
use crate::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
use crate::commands::generate::generate_image::utils::convert_enums_to_router::{
|
||||
|
|
@ -89,6 +90,10 @@ pub async fn enqueue_image_generation(
|
|||
enqueue_via_grok(request, app, app_data_root, &credential, grok_websockets).await
|
||||
}
|
||||
|
||||
GenerationSource::HiggsfieldCookies | GenerationSource::Higgsfield => {
|
||||
enqueue_via_higgsfield(request, &credential).await
|
||||
}
|
||||
|
||||
other => Err(credential_not_usable(
|
||||
&credential,
|
||||
&format!("accounts for service {} can't generate images yet", other),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
//! Image generation on the user's own Higgsfield account, via the router's
|
||||
//! first-party Higgsfield provider.
|
||||
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
use log::info;
|
||||
use router::api::image_list_ref::ImageListRef;
|
||||
use router::api::router_provider::RouterProvider;
|
||||
use router::client::generation_mode_mismatch_strategy::GenerationModeMismatchStrategy;
|
||||
use router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use router::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use sqlite_identifiers::enums::task_type::TaskType;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::higgsfield_generation::{
|
||||
higgsfield_media_url_map, higgsfield_router_client, join_higgsfield_job_ids, send_higgsfield_image_request,
|
||||
};
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
|
||||
use crate::commands::generate::generate_image::utils::convert_enums_to_router::{convert_aspect_ratio, convert_quality, convert_resolution};
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::utils::api_adapters::models::image::tauri_image_model_to_generation_model::tauri_image_model_to_generation_model;
|
||||
use crate::commands::utils::api_adapters::models::image::tauri_image_model_to_router_model::tauri_image_model_to_router_model;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
||||
/// Enqueue via the router's first-party Higgsfield provider.
|
||||
///
|
||||
/// Reference images (canvas, scene, and the prompt box's image references)
|
||||
/// travel as ArtCraft media tokens; the router resolves them to CDN URLs
|
||||
/// through the map built here, downloads them, and uploads them to
|
||||
/// Higgsfield before enqueuing. The returned task carries every job id of
|
||||
/// the Higgsfield job set so the poller can follow a batch.
|
||||
pub async fn enqueue_via_higgsfield(
|
||||
request: &TauriGenerateImageRequest,
|
||||
credential: &AuthCredential,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?;
|
||||
|
||||
let router_model = tauri_image_model_to_router_model(tauri_model).ok_or(
|
||||
GenerateError::NotYetImplemented(format!(
|
||||
"Model {:?} is not supported for a Higgsfield account",
|
||||
tauri_model
|
||||
)),
|
||||
)?;
|
||||
|
||||
let tokens = collect_reference_tokens(request);
|
||||
let media_url_map = higgsfield_media_url_map(&tokens).await?;
|
||||
let image_inputs = (!tokens.is_empty()).then(|| ImageListRef::MediaFileTokens(tokens));
|
||||
|
||||
let router_request = GenerateImageRequestBuilder {
|
||||
model: router_model,
|
||||
provider: RouterProvider::Higgsfield,
|
||||
prompt: request.prompt.clone(),
|
||||
image_inputs,
|
||||
resolution: request.resolution.map(convert_resolution),
|
||||
aspect_ratio: request.aspect_ratio.map(convert_aspect_ratio),
|
||||
quality: request.quality.map(convert_quality),
|
||||
image_batch_count: request.batch_size.map(|n| n as u16),
|
||||
horizontal_angle: request.adjust_horizontal_angle,
|
||||
vertical_angle: request.adjust_vertical_angle,
|
||||
zoom: request.adjust_zoom,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway),
|
||||
idempotency_token: None,
|
||||
};
|
||||
|
||||
let client = higgsfield_router_client(credential)?;
|
||||
let response = send_higgsfield_image_request(router_request, &client, &media_url_map).await?;
|
||||
|
||||
let payload = response
|
||||
.get_higgsfield_payload()
|
||||
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
|
||||
|
||||
info!(
|
||||
"Higgsfield image generation enqueued: job_set={} jobs={:?}",
|
||||
payload.job_set_id, payload.job_ids,
|
||||
);
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
task_type: TaskType::ImageGeneration,
|
||||
model: Some(tauri_image_model_to_generation_model(tauri_model)),
|
||||
// NB: the task uses the bare `Higgsfield` provider (what the polling
|
||||
// thread queries), not the `HiggsfieldCookies` credential service.
|
||||
provider: GenerationSource::Higgsfield,
|
||||
provider_job_id: Some(join_higgsfield_job_ids(&payload.job_ids)),
|
||||
is_batch_generation: payload.job_ids.len() > 1,
|
||||
maybe_queue_status_url: None,
|
||||
maybe_queue_response_url: None,
|
||||
maybe_prompt_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reference images in priority order: canvas first, then scene, then the
|
||||
/// un-semantic references. (Raw canvas / scene bytes aren't accepted on this
|
||||
/// path — they'd need an ArtCraft session to become tokens.)
|
||||
fn collect_reference_tokens(request: &TauriGenerateImageRequest) -> Vec<MediaFileToken> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
if let Some(canvas_token) = &request.canvas_image_media_token {
|
||||
tokens.push(canvas_token.clone());
|
||||
}
|
||||
if let Some(scene_token) = &request.scene_image_media_token {
|
||||
tokens.push(scene_token.clone());
|
||||
}
|
||||
if let Some(media_tokens) = &request.image_media_tokens {
|
||||
tokens.extend(media_tokens.clone());
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Live smoke tests that hit the REAL Higgsfield API with the REAL stored
|
||||
/// credential and SPEND HIGGSFIELD CREDITS. `#[ignore]`; run explicitly:
|
||||
/// SQLX_OFFLINE=true cargo test -p artcraftx live_higgsfield_image -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_higgsfield_image_tests {
|
||||
use artcraft_client::enums::common::generation::common_aspect_ratio::CommonAspectRatio as EnumsAspectRatio;
|
||||
use artcraft_client::enums::common::generation::common_quality::CommonQuality as EnumsQuality;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A production ArtCraft media file (see `test_data::web::image_media_tokens`).
|
||||
const JUNO_AT_LAKE_MEDIA_TOKEN: &str = "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g";
|
||||
|
||||
fn higgsfield_credential() -> AuthCredential {
|
||||
let app_data_root = AppDataRoot::create_default().expect("app data root");
|
||||
app_data_root
|
||||
.credentials_dir()
|
||||
.load_credentials()
|
||||
.expect("load credentials")
|
||||
.into_iter()
|
||||
.find(|c| c.service == GenerationSource::HiggsfieldCookies)
|
||||
.expect("no `higgsfield_cookies` credential on disk; log into Higgsfield via the app first")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: spends Higgsfield credits
|
||||
async fn live_higgsfield_image_text_to_image() {
|
||||
let credential = higgsfield_credential();
|
||||
let request = TauriGenerateImageRequest {
|
||||
credential_id: Some(credential.id.as_str().to_string()),
|
||||
model: Some(TauriImageModel::NanoBanana2Lite),
|
||||
prompt: Some("a shiba inu doing a kickflip on a skateboard, golden hour".to_string()),
|
||||
aspect_ratio: Some(EnumsAspectRatio::WideSixteenByNine),
|
||||
quality: Some(EnumsQuality::Low),
|
||||
batch_size: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let success = enqueue_via_higgsfield(&request, &credential).await.expect("enqueue should succeed");
|
||||
println!("[live] Higgsfield image enqueued: provider_job_id={:?}", success.provider_job_id);
|
||||
assert_eq!(success.provider, GenerationSource::Higgsfield);
|
||||
assert!(success.provider_job_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: spends Higgsfield credits and uploads a reference image
|
||||
async fn live_higgsfield_image_edit_with_reference() {
|
||||
let credential = higgsfield_credential();
|
||||
let request = TauriGenerateImageRequest {
|
||||
credential_id: Some(credential.id.as_str().to_string()),
|
||||
model: Some(TauriImageModel::NanoBanana2),
|
||||
prompt: Some("the same scene at night under the northern lights".to_string()),
|
||||
image_media_tokens: Some(vec![MediaFileToken::new_from_str(JUNO_AT_LAKE_MEDIA_TOKEN)]),
|
||||
batch_size: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let success = enqueue_via_higgsfield(&request, &credential).await.expect("enqueue should succeed");
|
||||
println!("[live] Higgsfield image edit enqueued: provider_job_id={:?}", success.provider_job_id);
|
||||
assert!(success.provider_job_id.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod enqueue_via_higgsfield;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod enqueue_image_generation;
|
||||
pub mod generate_image_command;
|
||||
pub mod higgsfield;
|
||||
pub mod tauri_generate_image_request;
|
||||
pub mod tauri_image_model;
|
||||
pub mod utils;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ pub enum TauriImageModel {
|
|||
NanoBanana,
|
||||
#[serde(rename = "nano_banana_2")]
|
||||
NanoBanana2,
|
||||
// Higgsfield's Nano Banana 2 Lite (fixed 1K, High / Minimal quality).
|
||||
#[serde(rename = "nano_banana_2_lite")]
|
||||
NanoBanana2Lite,
|
||||
#[serde(rename = "nano_banana_pro")]
|
||||
NanoBananaPro,
|
||||
#[serde(rename = "seedream_4")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use crate::commands::generate::generate_error::GenerateError;
|
|||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::generate::common::generation_credential::{credential_not_usable, resolve_generation_credential, storyteller_creds_from_credential};
|
||||
use crate::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router;
|
||||
use crate::commands::generate::generate_video::higgsfield::handle_higgsfield_video_via_router::handle_higgsfield_video_via_router;
|
||||
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::utils::services::artcraft_api_host::maybe_artcraft_api_host_for_service;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
|
@ -36,6 +37,10 @@ pub async fn handle_credential_router(
|
|||
| GenerationSource::ArtcraftCookies => {
|
||||
handle_artcraft_credential(request, &credential).await
|
||||
}
|
||||
GenerationSource::HiggsfieldCookies
|
||||
| GenerationSource::Higgsfield => {
|
||||
handle_higgsfield_video_via_router(request, &credential).await
|
||||
}
|
||||
other => Err(credential_not_usable(
|
||||
&credential,
|
||||
&format!("accounts for service {} can't generate videos yet", other),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
//! Video generation on the user's own Higgsfield account, via the router's
|
||||
//! first-party Higgsfield provider.
|
||||
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
use log::info;
|
||||
use router::api::audio_list_ref::AudioListRef;
|
||||
use router::api::image_list_ref::ImageListRef;
|
||||
use router::api::image_ref::ImageRef;
|
||||
use router::api::router_provider::RouterProvider;
|
||||
use router::api::video_list_ref::VideoListRef;
|
||||
use router::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use sqlite_identifiers::enums::task_type::TaskType;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::commands::generate::common::higgsfield_generation::{
|
||||
higgsfield_media_url_map, higgsfield_router_client, join_higgsfield_job_ids, send_higgsfield_video_request,
|
||||
};
|
||||
use crate::commands::generate::generate_error::GenerateError;
|
||||
use crate::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::commands::generate::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::commands::utils::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model;
|
||||
use crate::commands::utils::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model;
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
|
||||
/// Enqueue via the router's first-party Higgsfield provider.
|
||||
///
|
||||
/// Keyframes and image / video / audio references travel as ArtCraft media
|
||||
/// tokens; the router resolves them to CDN URLs through the map built here,
|
||||
/// downloads them, and uploads them to Higgsfield (running its IP check
|
||||
/// where the model demands it) before enqueuing. The returned task carries
|
||||
/// every job id of the Higgsfield job set so the poller can follow a batch.
|
||||
pub async fn handle_higgsfield_video_via_router(
|
||||
request: &TauriGenerateVideoRequest,
|
||||
credential: &AuthCredential,
|
||||
) -> Result<TaskEnqueueSuccess, GenerateError> {
|
||||
let tauri_model = request.model.ok_or(GenerateError::no_model_specified())?;
|
||||
let router_model = tauri_video_model_to_router_model(tauri_model);
|
||||
let generation_model = tauri_video_model_to_generation_model(tauri_model);
|
||||
|
||||
let media_url_map = higgsfield_media_url_map(&collect_media_tokens(request)).await?;
|
||||
|
||||
// Character references have no Higgsfield equivalent; the router drops
|
||||
// them with a warning, so they aren't forwarded at all.
|
||||
let router_request = GenerateVideoRequestBuilder {
|
||||
model: router_model,
|
||||
provider: RouterProvider::Higgsfield,
|
||||
prompt: request.prompt.clone(),
|
||||
negative_prompt: request.negative_prompt.clone(),
|
||||
start_frame: request.start_frame_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
end_frame: request.end_frame_image_media_token.clone().map(ImageRef::MediaFileToken),
|
||||
reference_images: request.reference_image_media_tokens.clone().map(ImageListRef::MediaFileTokens),
|
||||
reference_videos: request.reference_video_media_tokens.clone().map(VideoListRef::MediaFileTokens),
|
||||
reference_audio: request.reference_audio_media_tokens.clone().map(AudioListRef::MediaFileTokens),
|
||||
reference_character_tokens: None,
|
||||
resolution: request.resolution,
|
||||
aspect_ratio: request.aspect_ratio,
|
||||
bitrate: None,
|
||||
duration_seconds: request.duration_seconds,
|
||||
video_batch_count: request.video_batch_count,
|
||||
generate_audio: request.generate_audio,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
idempotency_token: None,
|
||||
};
|
||||
|
||||
let client = higgsfield_router_client(credential)?;
|
||||
let response = send_higgsfield_video_request(router_request, &client, &media_url_map).await?;
|
||||
|
||||
let payload = response
|
||||
.get_higgsfield_payload()
|
||||
.ok_or(GenerateError::ResponseHadNoJobTokens)?;
|
||||
|
||||
info!(
|
||||
"Higgsfield video generation enqueued: job_set={} jobs={:?}",
|
||||
payload.job_set_id, payload.job_ids,
|
||||
);
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
task_type: TaskType::VideoGeneration,
|
||||
model: Some(generation_model),
|
||||
// NB: the task uses the bare `Higgsfield` provider (what the polling
|
||||
// thread queries), not the `HiggsfieldCookies` credential service.
|
||||
provider: GenerationSource::Higgsfield,
|
||||
provider_job_id: Some(join_higgsfield_job_ids(&payload.job_ids)),
|
||||
is_batch_generation: payload.job_ids.len() > 1,
|
||||
maybe_queue_status_url: None,
|
||||
maybe_queue_response_url: None,
|
||||
maybe_prompt_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every media token the request references, so each can be resolved to a
|
||||
/// CDN URL for re-upload.
|
||||
fn collect_media_tokens(request: &TauriGenerateVideoRequest) -> Vec<MediaFileToken> {
|
||||
let mut tokens: Vec<MediaFileToken> = Vec::new();
|
||||
tokens.extend(request.start_frame_image_media_token.clone());
|
||||
tokens.extend(request.end_frame_image_media_token.clone());
|
||||
tokens.extend(request.reference_image_media_tokens.clone().unwrap_or_default());
|
||||
tokens.extend(request.reference_video_media_tokens.clone().unwrap_or_default());
|
||||
tokens.extend(request.reference_audio_media_tokens.clone().unwrap_or_default());
|
||||
tokens.dedup();
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Live smoke tests that hit the REAL Higgsfield API with the REAL stored
|
||||
/// credential and SPEND HIGGSFIELD CREDITS. `#[ignore]`; run explicitly:
|
||||
/// SQLX_OFFLINE=true cargo test -p artcraftx live_higgsfield_video -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_higgsfield_video_tests {
|
||||
use router::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use router::api::router_resolution::RouterResolution;
|
||||
|
||||
use crate::commands::generate::generate_video::request::TauriVideoModel;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A production ArtCraft media file (see `test_data::web::image_media_tokens`).
|
||||
const JUNO_AT_LAKE_MEDIA_TOKEN: &str = "m_m1bz02z1kkzanxy6rb4vk1kvq9de9g";
|
||||
|
||||
fn higgsfield_credential() -> AuthCredential {
|
||||
let app_data_root = AppDataRoot::create_default().expect("app data root");
|
||||
app_data_root
|
||||
.credentials_dir()
|
||||
.load_credentials()
|
||||
.expect("load credentials")
|
||||
.into_iter()
|
||||
.find(|c| c.service == GenerationSource::HiggsfieldCookies)
|
||||
.expect("no `higgsfield_cookies` credential on disk; log into Higgsfield via the app first")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: spends Higgsfield credits
|
||||
async fn live_higgsfield_video_text_to_video() {
|
||||
let credential = higgsfield_credential();
|
||||
let request = TauriGenerateVideoRequest {
|
||||
credential_id: Some(credential.id.as_str().to_string()),
|
||||
model: Some(TauriVideoModel::Seedance2p0Mini),
|
||||
prompt: Some("a shiba inu surfing a big wave, action photo".to_string()),
|
||||
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(4),
|
||||
video_batch_count: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let success = handle_higgsfield_video_via_router(&request, &credential).await.expect("enqueue should succeed");
|
||||
println!("[live] Higgsfield video enqueued: provider_job_id={:?}", success.provider_job_id);
|
||||
assert_eq!(success.provider, GenerationSource::Higgsfield);
|
||||
assert!(success.provider_job_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: spends Higgsfield credits and uploads a start frame (with IP check)
|
||||
async fn live_higgsfield_video_from_start_frame() {
|
||||
let credential = higgsfield_credential();
|
||||
let request = TauriGenerateVideoRequest {
|
||||
credential_id: Some(credential.id.as_str().to_string()),
|
||||
model: Some(TauriVideoModel::Seedance2p0Mini),
|
||||
prompt: Some("gentle camera push-in, ripples on the water".to_string()),
|
||||
start_frame_image_media_token: Some(MediaFileToken::new_from_str(JUNO_AT_LAKE_MEDIA_TOKEN)),
|
||||
resolution: Some(RouterResolution::FourEightyP),
|
||||
duration_seconds: Some(4),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let success = handle_higgsfield_video_via_router(&request, &credential).await.expect("enqueue should succeed");
|
||||
println!("[live] Higgsfield image-to-video enqueued: provider_job_id={:?}", success.provider_job_id);
|
||||
assert!(success.provider_job_id.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod handle_higgsfield_video_via_router;
|
||||
|
|
@ -2,3 +2,4 @@ pub mod generate_video_command;
|
|||
mod handle_credential_router;
|
||||
pub mod request;
|
||||
pub mod artcraft;
|
||||
pub mod higgsfield;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,18 @@ pub enum TauriVideoModel {
|
|||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
|
||||
#[serde(rename = "seedance_2p0_mini")]
|
||||
Seedance2p0Mini,
|
||||
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
|
||||
#[serde(rename = "seedance_2p5_edit")]
|
||||
Seedance2p5Edit,
|
||||
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
#[serde(rename = "sora_2")]
|
||||
Sora2,
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ impl TaskEnqueueSuccess{
|
|||
Some(GenerationModel::Gemini25Flash) => Some(TaskModelType::Gemini25Flash),
|
||||
Some(GenerationModel::NanoBanana) => Some(TaskModelType::NanoBanana),
|
||||
Some(GenerationModel::NanoBanana2) => Some(TaskModelType::NanoBanana2),
|
||||
Some(GenerationModel::NanoBanana2Lite) => Some(TaskModelType::NanoBanana2Lite),
|
||||
Some(GenerationModel::NanoBananaPro) => Some(TaskModelType::NanoBananaPro),
|
||||
Some(GenerationModel::GptImage1) => Some(TaskModelType::GptImage1),
|
||||
Some(GenerationModel::GptImage1p5) => Some(TaskModelType::GptImage1p5),
|
||||
|
|
@ -94,6 +95,10 @@ impl TaskEnqueueSuccess{
|
|||
Some(GenerationModel::Seedance1p5Pro) => Some(TaskModelType::Seedance1p5Pro),
|
||||
Some(GenerationModel::Seedance2p0) => Some(TaskModelType::Seedance2p0),
|
||||
Some(GenerationModel::Seedance2p0Fast) => Some(TaskModelType::Seedance2p0Fast),
|
||||
Some(GenerationModel::Seedance2p0Mini) => Some(TaskModelType::Seedance2p0Mini),
|
||||
Some(GenerationModel::Seedance2p5) => Some(TaskModelType::Seedance2p5),
|
||||
Some(GenerationModel::Seedance2p5Edit) => Some(TaskModelType::Seedance2p5Edit),
|
||||
Some(GenerationModel::MinimaxH3) => Some(TaskModelType::MinimaxH3),
|
||||
Some(GenerationModel::Sora2) => Some(TaskModelType::Sora2),
|
||||
Some(GenerationModel::Sora2Pro) => Some(TaskModelType::Sora2Pro),
|
||||
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub fn tauri_image_model_to_common_model_type(model: TauriImageModel) -> CommonM
|
|||
TauriImageModel::GptImage2 => CommonModelType::GptImage2,
|
||||
TauriImageModel::NanoBanana => CommonModelType::NanoBanana,
|
||||
TauriImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
TauriImageModel::NanoBanana2Lite => CommonModelType::NanoBanana2, // NB: no dedicated legacy type; nearest.
|
||||
TauriImageModel::NanoBananaPro => CommonModelType::NanoBananaPro,
|
||||
TauriImageModel::Seedream4 => CommonModelType::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common
|
|||
TauriImageModel::GptImage2 => Some(CommonImageModel::GptImage2),
|
||||
TauriImageModel::NanoBanana => Some(CommonImageModel::NanoBanana),
|
||||
TauriImageModel::NanoBanana2 => Some(CommonImageModel::NanoBanana2),
|
||||
TauriImageModel::NanoBanana2Lite => None, // Higgsfield only; not on the omni endpoint.
|
||||
TauriImageModel::NanoBananaPro => Some(CommonImageModel::NanoBananaPro),
|
||||
TauriImageModel::Seedream4 => Some(CommonImageModel::Seedream4),
|
||||
TauriImageModel::Seedream4p5 => Some(CommonImageModel::Seedream4p5),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub fn tauri_image_model_to_generation_model(model: TauriImageModel) -> Generati
|
|||
TauriImageModel::GptImage2 => GenerationModel::GptImage2,
|
||||
TauriImageModel::NanoBanana => GenerationModel::NanoBanana,
|
||||
TauriImageModel::NanoBanana2 => GenerationModel::NanoBanana2,
|
||||
TauriImageModel::NanoBanana2Lite => GenerationModel::NanoBanana2Lite,
|
||||
TauriImageModel::NanoBananaPro => GenerationModel::NanoBananaPro,
|
||||
TauriImageModel::Seedream4 => GenerationModel::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => GenerationModel::Seedream4p5,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub fn tauri_image_model_to_router_model(model: TauriImageModel) -> Option<Route
|
|||
TauriImageModel::GptImage2 => Some(RouterImageModel::GptImage2), // Text-to-Image
|
||||
TauriImageModel::NanoBanana => Some(RouterImageModel::NanoBanana), // Text-to-Image
|
||||
TauriImageModel::NanoBanana2 => Some(RouterImageModel::NanoBanana2), // Text-to-Image
|
||||
TauriImageModel::NanoBanana2Lite => Some(RouterImageModel::NanoBanana2Lite), // Higgsfield only
|
||||
TauriImageModel::NanoBananaPro => Some(RouterImageModel::NanoBananaPro), // Text-to-Image
|
||||
TauriImageModel::Seedream4 => Some(RouterImageModel::Seedream4), // Text-to-Image
|
||||
TauriImageModel::Seedream4p5 => Some(RouterImageModel::Seedream4p5), // Text-to-Image
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ pub fn tauri_video_model_to_generation_model(model: TauriVideoModel) -> Generati
|
|||
TauriVideoModel::Seedance1p5Pro => GenerationModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => GenerationModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => GenerationModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Seedance2p0Mini => GenerationModel::Seedance2p0Mini,
|
||||
TauriVideoModel::Seedance2p5 => GenerationModel::Seedance2p5,
|
||||
TauriVideoModel::Seedance2p5Edit => GenerationModel::Seedance2p5Edit,
|
||||
TauriVideoModel::MinimaxH3 => GenerationModel::MinimaxH3,
|
||||
TauriVideoModel::Sora2 => GenerationModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => GenerationModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => GenerationModel::Veo2,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ pub fn tauri_video_model_to_router_model(model: TauriVideoModel) -> RouterVideoM
|
|||
TauriVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Seedance2p0Mini => RouterVideoModel::Seedance2p0Mini,
|
||||
TauriVideoModel::Seedance2p5 => RouterVideoModel::Seedance2p5,
|
||||
TauriVideoModel::Seedance2p5Edit => RouterVideoModel::Seedance2p5Edit,
|
||||
TauriVideoModel::MinimaxH3 => RouterVideoModel::MinimaxH3,
|
||||
TauriVideoModel::Sora2 => RouterVideoModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => RouterVideoModel::Veo2,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ impl LoginWebsite {
|
|||
Self::ArtCraft => Some(GenerationSource::Artcraft),
|
||||
Self::Midjourney => Some(GenerationSource::Midjourney),
|
||||
Self::XAi => Some(GenerationSource::Grok),
|
||||
Self::OpenArt | Self::Higgsfield | Self::Runway | Self::Magnific => None,
|
||||
Self::Higgsfield => Some(GenerationSource::Higgsfield),
|
||||
Self::OpenArt | Self::Runway | Self::Magnific => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ pub fn cookie_origin_for_service(service: GenerationSource) -> Option<Url> {
|
|||
| GenerationSource::SoraCookies => "https://chatgpt.com/",
|
||||
GenerationSource::WorldLabs
|
||||
| GenerationSource::WorldLabsCookies => "https://worldlabs.ai/",
|
||||
GenerationSource::HiggsfieldCookies => "https://higgsfield.ai/",
|
||||
GenerationSource::Higgsfield
|
||||
| GenerationSource::HiggsfieldCookies => "https://higgsfield.ai/",
|
||||
GenerationSource::MagnificCookies => "https://www.magnific.com/",
|
||||
GenerationSource::OpenArtCookies => "https://openart.ai/",
|
||||
GenerationSource::RunwayCookies => "https://app.runwayml.com/",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ pub enum GenerationServiceProvider {
|
|||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Higgsfield,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
|
|
@ -43,6 +44,8 @@ pub enum GenerationModel {
|
|||
NanoBanana,
|
||||
#[serde(rename = "nano_banana_2")]
|
||||
NanoBanana2,
|
||||
#[serde(rename = "nano_banana_2_lite")]
|
||||
NanoBanana2Lite,
|
||||
#[serde(rename = "nano_banana_pro")]
|
||||
NanoBananaPro,
|
||||
#[serde(rename = "seedream_4")]
|
||||
|
|
@ -163,6 +166,14 @@ pub enum GenerationModel {
|
|||
Seedance2p0,
|
||||
#[serde(rename = "seedance_2p0_fast")]
|
||||
Seedance2p0Fast,
|
||||
#[serde(rename = "seedance_2p0_mini")]
|
||||
Seedance2p0Mini,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
#[serde(rename = "seedance_2p5_edit")]
|
||||
Seedance2p5Edit,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ fn task_model_type_to_generation_model(model: TaskModelType) -> Option<Generatio
|
|||
TaskModelType::GptImage2 => Some(GenerationModel::GptImage2),
|
||||
TaskModelType::NanoBanana => Some(GenerationModel::NanoBanana),
|
||||
TaskModelType::NanoBanana2 => Some(GenerationModel::NanoBanana2),
|
||||
TaskModelType::NanoBanana2Lite => Some(GenerationModel::NanoBanana2Lite),
|
||||
TaskModelType::NanoBananaPro => Some(GenerationModel::NanoBananaPro),
|
||||
TaskModelType::Seedream4 => Some(GenerationModel::Seedream4),
|
||||
TaskModelType::Seedream4p5 => Some(GenerationModel::Seedream4p5),
|
||||
|
|
@ -265,6 +266,10 @@ fn task_model_type_to_generation_model(model: TaskModelType) -> Option<Generatio
|
|||
TaskModelType::Seedance1p5Pro => Some(GenerationModel::Seedance1p5Pro),
|
||||
TaskModelType::Seedance2p0 => Some(GenerationModel::Seedance2p0),
|
||||
TaskModelType::Seedance2p0Fast => Some(GenerationModel::Seedance2p0Fast),
|
||||
TaskModelType::Seedance2p0Mini => Some(GenerationModel::Seedance2p0Mini),
|
||||
TaskModelType::Seedance2p5 => Some(GenerationModel::Seedance2p5),
|
||||
TaskModelType::Seedance2p5Edit => Some(GenerationModel::Seedance2p5Edit),
|
||||
TaskModelType::MinimaxH3 => Some(GenerationModel::MinimaxH3),
|
||||
TaskModelType::Sora2 => Some(GenerationModel::Sora2),
|
||||
TaskModelType::Sora2Pro => Some(GenerationModel::Sora2Pro),
|
||||
TaskModelType::Veo2 => Some(GenerationModel::Veo2),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
use artcraft_client::enums::common::generation::common_model_type::CommonModelType;
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
use errors::AnyhowResult;
|
||||
use higgsfield_client::endpoints::jobs::job_status::JobStatusResponse;
|
||||
use log::{error, info};
|
||||
use sqlite_database::queries::task::Task;
|
||||
use sqlite_identifiers::enums::task_media_file_class::TaskMediaFileClass;
|
||||
use sqlite_identifiers::enums::task_model_type::TaskModelType;
|
||||
use sqlite_identifiers::enums::task_type::TaskType;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::database::task_database::TaskDatabase;
|
||||
use crate::threads::task_completion::complete_task_with_local_files::{complete_task_with_local_files, CompleteTaskArgs};
|
||||
use crate::threads::task_completion::upload_results_to_artcraft::CompletionPrompt;
|
||||
use crate::utils::download::download_url_to_temp_dir::download_url_to_temp_dir;
|
||||
|
||||
/// Download filename slug when the task has no model type recorded.
|
||||
const HIGGSFIELD_FALLBACK_MODEL_SLUG: &str = "higgsfield";
|
||||
|
||||
/// A Higgsfield job set finished: fetch every finished job's file, then hand
|
||||
/// them to the shared completion routine.
|
||||
pub async fn handle_higgsfield_complete(
|
||||
app_handle: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_preferences: &AppPreferencesManager,
|
||||
task_database: &TaskDatabase,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
task: &Task,
|
||||
jobs: &[JobStatusResponse],
|
||||
) {
|
||||
info!("[HiggsfieldComplete] Handling completed task {} ({} job(s))", task.id.as_str(), jobs.len());
|
||||
|
||||
let result = handle_higgsfield_complete_inner(
|
||||
app_handle,
|
||||
app_data_root,
|
||||
app_preferences,
|
||||
task_database,
|
||||
storyteller_creds_manager,
|
||||
task,
|
||||
jobs,
|
||||
).await;
|
||||
|
||||
if let Err(err) = result {
|
||||
error!("[HiggsfieldComplete] Failed to handle task {}: {:?}", task.id.as_str(), err);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_higgsfield_complete_inner(
|
||||
app_handle: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_preferences: &AppPreferencesManager,
|
||||
task_database: &TaskDatabase,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
task: &Task,
|
||||
jobs: &[JobStatusResponse],
|
||||
) -> AnyhowResult<()> {
|
||||
// NB: `NamedTempFile`s delete themselves on drop, so keep them alive until
|
||||
// the completion routine has copied and uploaded them.
|
||||
let mut temp_files = Vec::with_capacity(jobs.len());
|
||||
for (index, job) in jobs.iter().enumerate() {
|
||||
let url = job.result_url().expect("caller passes only jobs with a result URL");
|
||||
info!("[HiggsfieldComplete] Downloading result {} of {} for task {} ...", index + 1, jobs.len(), task.id.as_str());
|
||||
temp_files.push(download_url_to_temp_dir(url, app_data_root).await?);
|
||||
}
|
||||
let local_files = temp_files.iter()
|
||||
.map(|file| file.path().to_path_buf())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let media_class = media_class_for(task.task_type);
|
||||
let maybe_prompt = jobs.iter()
|
||||
.filter_map(|job| job.params.prompt.as_deref())
|
||||
.map(str::trim)
|
||||
.find(|prompt| !prompt.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// Attribute a prompt when storyteller-web knows the model; the
|
||||
// Higgsfield-only models have no prompt type there.
|
||||
let prompt = match task.model_type.and_then(common_model_type_for) {
|
||||
Some(model_type) => CompletionPrompt::Create { model_type, maybe_prompt },
|
||||
None => CompletionPrompt::None,
|
||||
};
|
||||
|
||||
let maybe_creds = storyteller_creds_manager.get_credentials()?;
|
||||
|
||||
complete_task_with_local_files(CompleteTaskArgs {
|
||||
app_handle,
|
||||
app_data_root,
|
||||
app_preferences,
|
||||
task_database,
|
||||
maybe_storyteller_creds: maybe_creds.as_ref(),
|
||||
task,
|
||||
generation_provider: GenerationSource::Higgsfield,
|
||||
media_class,
|
||||
prompt,
|
||||
fallback_model_slug: HIGGSFIELD_FALLBACK_MODEL_SLUG,
|
||||
local_files: &local_files,
|
||||
}).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn media_class_for(task_type: TaskType) -> TaskMediaFileClass {
|
||||
match task_type {
|
||||
TaskType::VideoGeneration => TaskMediaFileClass::Video,
|
||||
TaskType::ImageGeneration => TaskMediaFileClass::Image,
|
||||
// Higgsfield only produces images and videos; anything else is a
|
||||
// mis-recorded task, delivered as an image so it isn't lost.
|
||||
TaskType::AudioGeneration | TaskType::MeshGeneration | TaskType::SplatGeneration => TaskMediaFileClass::Image,
|
||||
}
|
||||
}
|
||||
|
||||
/// The storyteller-web prompt type for the models Higgsfield shares with it.
|
||||
fn common_model_type_for(model_type: TaskModelType) -> Option<CommonModelType> {
|
||||
match model_type {
|
||||
TaskModelType::NanoBananaPro => Some(CommonModelType::NanoBananaPro),
|
||||
TaskModelType::NanoBanana2 => Some(CommonModelType::NanoBanana2),
|
||||
TaskModelType::GptImage2 => Some(CommonModelType::GptImage2),
|
||||
TaskModelType::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
|
||||
TaskModelType::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
|
||||
TaskModelType::Seedream4p5 => Some(CommonModelType::Seedream4p5),
|
||||
TaskModelType::Seedance2p0 => Some(CommonModelType::Seedance2p0),
|
||||
TaskModelType::Seedance2p0Mini => Some(CommonModelType::Seedance2p0Mini),
|
||||
TaskModelType::Kling3p0Standard => Some(CommonModelType::Kling3p0Standard),
|
||||
TaskModelType::Kling3p0Pro => Some(CommonModelType::Kling3p0Pro),
|
||||
TaskModelType::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_class_follows_task_type() {
|
||||
assert_eq!(media_class_for(TaskType::ImageGeneration), TaskMediaFileClass::Image);
|
||||
assert_eq!(media_class_for(TaskType::VideoGeneration), TaskMediaFileClass::Video);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higgsfield_only_models_have_no_prompt_type() {
|
||||
assert!(common_model_type_for(TaskModelType::Seedance2p5).is_none());
|
||||
assert!(common_model_type_for(TaskModelType::MinimaxH3).is_none());
|
||||
assert!(common_model_type_for(TaskModelType::NanoBanana2Lite).is_none());
|
||||
assert_eq!(common_model_type_for(TaskModelType::NanoBananaPro), Some(CommonModelType::NanoBananaPro));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
use crate::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
|
||||
use crate::events::generation_events::generation_failed_event::GenerationFailedEvent;
|
||||
use crate::state::database::task_database::TaskDatabase;
|
||||
use log::{error, info};
|
||||
use sqlite_database::queries::task::Task;
|
||||
use sqlite_database::queries::update::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use sqlite_identifiers::enums::task_status::TaskStatus;
|
||||
use sqlite_identifiers::enums::task_type::TaskType;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub async fn handle_higgsfield_failure(
|
||||
app_handle: &AppHandle,
|
||||
task_database: &TaskDatabase,
|
||||
task: &Task,
|
||||
reason: &str,
|
||||
) {
|
||||
info!("[HiggsfieldPolling] Marking task {} as failed: {}", task.id.as_str(), reason);
|
||||
|
||||
let update_result = update_task_status(UpdateTaskArgs {
|
||||
db: task_database.get_connection(),
|
||||
task_id: &task.id,
|
||||
status: TaskStatus::CompleteFailure,
|
||||
}).await;
|
||||
|
||||
if let Err(err) = update_result {
|
||||
error!("[HiggsfieldPolling] Failed to update task status for {}: {:?}", task.id.as_str(), err);
|
||||
}
|
||||
|
||||
let event = GenerationFailedEvent {
|
||||
action: task_type_to_generation_action(task.task_type),
|
||||
service: GenerationServiceProvider::Higgsfield,
|
||||
model: None,
|
||||
reason: Some(reason.to_string()),
|
||||
};
|
||||
|
||||
event.send_infallible(app_handle);
|
||||
}
|
||||
|
||||
fn task_type_to_generation_action(task_type: TaskType) -> GenerationAction {
|
||||
match task_type {
|
||||
TaskType::ImageGeneration => GenerationAction::GenerateImage,
|
||||
TaskType::VideoGeneration => GenerationAction::GenerateVideo,
|
||||
TaskType::AudioGeneration => GenerationAction::GenerateAudio,
|
||||
TaskType::MeshGeneration => GenerationAction::ImageTo3d,
|
||||
TaskType::SplatGeneration => GenerationAction::GenerateGaussian,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use higgsfield_client::session::higgsfield_session::HiggsfieldSession;
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::credentials::auth_credential::AuthCredential;
|
||||
use crate::services::higgsfield::higgsfield_session_from_credential::higgsfield_session_from_credential;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
|
||||
/// One live [`HiggsfieldSession`] per stored Higgsfield credential, kept
|
||||
/// across polling iterations so the short-lived Clerk bearer token is reused
|
||||
/// rather than re-minted every couple of seconds. Sessions follow the
|
||||
/// credential files: a re-login (new cookies) replaces the session, a deleted
|
||||
/// credential drops it.
|
||||
#[derive(Default)]
|
||||
pub struct HiggsfieldPollSessions {
|
||||
sessions: HashMap<String, SessionEntry>,
|
||||
}
|
||||
|
||||
struct SessionEntry {
|
||||
cookie_header: String,
|
||||
session: HiggsfieldSession,
|
||||
}
|
||||
|
||||
impl HiggsfieldPollSessions {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The sessions for every Higgsfield credential currently on disk, in
|
||||
/// credential order. Empty when the user has no Higgsfield account.
|
||||
pub fn refresh(&mut self, app_data_root: &AppDataRoot) -> Vec<(String, HiggsfieldSession)> {
|
||||
let credentials = match app_data_root.credentials_dir().load_credentials() {
|
||||
Ok(credentials) => credentials,
|
||||
Err(err) => {
|
||||
warn!("[HiggsfieldPolling] Could not load credentials: {}", err);
|
||||
return self.sessions.iter().map(|(id, entry)| (id.clone(), entry.session.clone())).collect();
|
||||
}
|
||||
};
|
||||
|
||||
let higgsfield_credentials: Vec<AuthCredential> = credentials.into_iter()
|
||||
.filter(|credential| credential.service == GenerationSource::HiggsfieldCookies)
|
||||
.collect();
|
||||
|
||||
let live_ids: Vec<String> = higgsfield_credentials.iter().map(|c| c.id.as_str().to_string()).collect();
|
||||
self.sessions.retain(|id, _| live_ids.contains(id));
|
||||
|
||||
let mut result = Vec::with_capacity(higgsfield_credentials.len());
|
||||
for credential in &higgsfield_credentials {
|
||||
let id = credential.id.as_str().to_string();
|
||||
let cookie_header = credential.cookies().map(|cookie| cookie.cookie_header()).unwrap_or_default();
|
||||
let is_current = self.sessions.get(&id).is_some_and(|entry| entry.cookie_header == cookie_header);
|
||||
if !is_current {
|
||||
match higgsfield_session_from_credential(credential) {
|
||||
Ok(session) => {
|
||||
info!("[HiggsfieldPolling] Opened a session for credential {}", id);
|
||||
self.sessions.insert(id.clone(), SessionEntry { cookie_header, session });
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("[HiggsfieldPolling] Credential {} can't open a Higgsfield session: {}", id, err);
|
||||
self.sessions.remove(&id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(entry) = self.sessions.get(&id) {
|
||||
result.push((id, entry.session.clone()));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
//! First-party (cookie-session) Higgsfield jobs. A task's `provider_job_id`
|
||||
//! holds every job id of the Higgsfield job set (comma-separated for a
|
||||
//! batch); the poller checks them in one batch-status call per account and,
|
||||
//! once every job is terminal, downloads the finished files and hands them
|
||||
//! to the shared completion routine.
|
||||
|
||||
pub mod handle_higgsfield_complete;
|
||||
pub mod handle_higgsfield_failure;
|
||||
pub mod higgsfield_poll_sessions;
|
||||
pub mod poll_higgsfield_tasks;
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use higgsfield_client::endpoints::jobs::job_status::JobStatusResponse;
|
||||
use higgsfield_client::session::higgsfield_session::HiggsfieldSession;
|
||||
use higgsfield_client::types::ids::JobId;
|
||||
use higgsfield_client::types::job_status::JobStatus;
|
||||
use log::{error, info, warn};
|
||||
use sqlite_database::queries::task::Task;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::generate::common::higgsfield_generation::split_higgsfield_job_ids;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
|
||||
use crate::state::data_dir::app_data_root::AppDataRoot;
|
||||
use crate::state::database::task_database::TaskDatabase;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::higgsfield::handle_higgsfield_complete::handle_higgsfield_complete;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::higgsfield::handle_higgsfield_failure::handle_higgsfield_failure;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::higgsfield::higgsfield_poll_sessions::HiggsfieldPollSessions;
|
||||
|
||||
/// Check every pending Higgsfield task. Jobs are looked up through each
|
||||
/// stored Higgsfield account in turn (a job only answers on the account that
|
||||
/// created it); the first account that knows the job set is used.
|
||||
pub async fn poll_higgsfield_tasks(
|
||||
app_handle: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_preferences: &AppPreferencesManager,
|
||||
task_database: &TaskDatabase,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sessions: &mut HiggsfieldPollSessions,
|
||||
tasks: &[&Task],
|
||||
) {
|
||||
let sessions = sessions.refresh(app_data_root);
|
||||
if sessions.is_empty() {
|
||||
warn!("[HiggsfieldPolling] {} task(s) pending but no Higgsfield account is stored; skipping", tasks.len());
|
||||
return;
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let result = poll_single_task(
|
||||
app_handle,
|
||||
app_data_root,
|
||||
app_preferences,
|
||||
task_database,
|
||||
storyteller_creds_manager,
|
||||
&sessions,
|
||||
task,
|
||||
).await;
|
||||
if let Err(err) = result {
|
||||
error!("[HiggsfieldPolling] Error processing task {}: {:?}", task.id.as_str(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the batch status call said about one task's jobs.
|
||||
enum JobSetProgress {
|
||||
/// At least one job is still running.
|
||||
Pending,
|
||||
/// Every job reached a terminal state.
|
||||
Done(Vec<JobOutcome>),
|
||||
}
|
||||
|
||||
struct JobOutcome {
|
||||
job_id: JobId,
|
||||
status: JobStatus,
|
||||
}
|
||||
|
||||
async fn poll_single_task(
|
||||
app_handle: &AppHandle,
|
||||
app_data_root: &AppDataRoot,
|
||||
app_preferences: &AppPreferencesManager,
|
||||
task_database: &TaskDatabase,
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
sessions: &[(String, HiggsfieldSession)],
|
||||
task: &Task,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_ids: Vec<JobId> = task.provider_job_id.as_deref()
|
||||
.map(split_higgsfield_job_ids)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(JobId::new)
|
||||
.collect();
|
||||
|
||||
if job_ids.is_empty() {
|
||||
warn!("[HiggsfieldPolling] Task {} has no job ids; marking as failed", task.id.as_str());
|
||||
handle_higgsfield_failure(app_handle, task_database, task, "No Higgsfield job ids to check").await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some((session, progress)) = find_job_set(sessions, &job_ids, task).await else {
|
||||
// No account knows the jobs. Leave the task pending: the account may be
|
||||
// temporarily unreachable (expired session, network) and come back.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let outcomes = match progress {
|
||||
JobSetProgress::Pending => return Ok(()),
|
||||
JobSetProgress::Done(outcomes) => outcomes,
|
||||
};
|
||||
|
||||
// Fetch the full record (result URLs, prompt) of every finished job.
|
||||
let mut finished: Vec<JobStatusResponse> = Vec::new();
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
for outcome in &outcomes {
|
||||
if !outcome.status.is_success() {
|
||||
failures.push(format!("{} ended {}", outcome.job_id, outcome.status));
|
||||
continue;
|
||||
}
|
||||
match session.job_status(&outcome.job_id).await {
|
||||
Ok(job) if job.result_url().is_some() => finished.push(job),
|
||||
Ok(job) => failures.push(format!("{} completed without a result URL", job.id)),
|
||||
Err(err) => {
|
||||
// Transient: try again next iteration rather than failing the task.
|
||||
warn!("[HiggsfieldPolling] Could not fetch job {} for task {}: {}", outcome.job_id, task.id.as_str(), err);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if finished.is_empty() {
|
||||
let reason = if failures.is_empty() { "Higgsfield produced no output".to_string() } else { failures.join("; ") };
|
||||
handle_higgsfield_failure(app_handle, task_database, task, &reason).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
warn!(
|
||||
"[HiggsfieldPolling] Task {}: {} of {} job(s) failed ({}); delivering the rest",
|
||||
task.id.as_str(), failures.len(), outcomes.len(), failures.join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
handle_higgsfield_complete(
|
||||
app_handle,
|
||||
app_data_root,
|
||||
app_preferences,
|
||||
task_database,
|
||||
storyteller_creds_manager,
|
||||
task,
|
||||
&finished,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask each account for the jobs' status until one knows them.
|
||||
async fn find_job_set<'a>(
|
||||
sessions: &'a [(String, HiggsfieldSession)],
|
||||
job_ids: &[JobId],
|
||||
task: &Task,
|
||||
) -> Option<(&'a HiggsfieldSession, JobSetProgress)> {
|
||||
for (credential_id, session) in sessions {
|
||||
let batch = match session.job_status_batch(job_ids).await {
|
||||
Ok(batch) => batch,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"[HiggsfieldPolling] Status check failed for task {} on credential {}: {}{}",
|
||||
task.id.as_str(), credential_id, err,
|
||||
if err.needs_browser_reauth() { " (log into Higgsfield again)" } else { "" },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// An account that created none of these jobs reports them all missing.
|
||||
if batch.items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let statuses: HashMap<&JobId, &JobStatus> = batch.items.iter().map(|item| (&item.id, &item.status)).collect();
|
||||
let mut outcomes = Vec::with_capacity(job_ids.len());
|
||||
for job_id in job_ids {
|
||||
let status = match statuses.get(job_id) {
|
||||
Some(status) => (*status).clone(),
|
||||
None if batch.missing.contains(job_id) => JobStatus::Other("missing".to_string()),
|
||||
None => return Some((session, JobSetProgress::Pending)),
|
||||
};
|
||||
if !status.is_terminal() && !batch.missing.contains(job_id) {
|
||||
info!("[HiggsfieldPolling] Task {} job {} is {}", task.id.as_str(), job_id, status);
|
||||
return Some((session, JobSetProgress::Pending));
|
||||
}
|
||||
outcomes.push(JobOutcome { job_id: job_id.clone(), status });
|
||||
}
|
||||
return Some((session, JobSetProgress::Done(outcomes)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod fal;
|
||||
pub mod grok;
|
||||
pub mod higgsfield;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use crate::state::database::task_database::TaskDatabase;
|
|||
use crate::threads::third_party_task_polling_thread::handlers::fal::poll_fal_tasks::poll_fal_tasks;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::grok::grok_image_collector::GrokImageCollector;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::grok::poll_grok_image_tasks::poll_grok_image_tasks;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::higgsfield::higgsfield_poll_sessions::HiggsfieldPollSessions;
|
||||
use crate::threads::third_party_task_polling_thread::handlers::higgsfield::poll_higgsfield_tasks::poll_higgsfield_tasks;
|
||||
use crate::database::task_database_pending_statuses::TASK_DATABASE_PENDING_STATUSES;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use core_types::enums::generation_source::GenerationSource;
|
||||
|
|
@ -33,6 +35,8 @@ pub async fn third_party_task_polling_thread(
|
|||
let mut has_ever_seen_third_party_jobs = false;
|
||||
// Grok streams a prompt's images one frame at a time; accumulate across iterations.
|
||||
let mut grok_image_collector = GrokImageCollector::new();
|
||||
// One Higgsfield session per account, reused so bearer tokens aren't re-minted every poll.
|
||||
let mut higgsfield_sessions = HiggsfieldPollSessions::new();
|
||||
|
||||
loop {
|
||||
let result = poll_iteration(
|
||||
|
|
@ -43,6 +47,7 @@ pub async fn third_party_task_polling_thread(
|
|||
&storyteller_creds_manager,
|
||||
&grok_websockets,
|
||||
&mut grok_image_collector,
|
||||
&mut higgsfield_sessions,
|
||||
&mut has_ever_seen_third_party_jobs,
|
||||
).await;
|
||||
|
||||
|
|
@ -61,6 +66,7 @@ async fn poll_iteration(
|
|||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
grok_websockets: &GrokWebsockets,
|
||||
grok_image_collector: &mut GrokImageCollector,
|
||||
higgsfield_sessions: &mut HiggsfieldPollSessions,
|
||||
has_ever_seen_third_party_jobs: &mut bool,
|
||||
) -> Result<(), PollError> {
|
||||
let task_list = list_non_artcraft_pending_tasks(ListNonArtcraftPendingTasksArgs {
|
||||
|
|
@ -91,8 +97,14 @@ async fn poll_iteration(
|
|||
.filter(|t| t.provider == GenerationSource::Grok && t.task_type == TaskType::ImageGeneration)
|
||||
.collect();
|
||||
|
||||
// First-party Higgsfield images and videos (see handlers/higgsfield).
|
||||
let higgsfield_tasks: Vec<&Task> = tasks.iter()
|
||||
.filter(|t| t.provider == GenerationSource::Higgsfield)
|
||||
.collect();
|
||||
|
||||
let unhandled_tasks: Vec<&Task> = tasks.iter()
|
||||
.filter(|t| t.provider != GenerationSource::Fal)
|
||||
.filter(|t| t.provider != GenerationSource::Higgsfield)
|
||||
.filter(|t| !(t.provider == GenerationSource::Grok && t.task_type == TaskType::ImageGeneration))
|
||||
.collect();
|
||||
|
||||
|
|
@ -119,6 +131,19 @@ async fn poll_iteration(
|
|||
).await;
|
||||
}
|
||||
|
||||
if !higgsfield_tasks.is_empty() {
|
||||
info!("[ThirdPartyPolling] {} Higgsfield job(s) pending", higgsfield_tasks.len());
|
||||
poll_higgsfield_tasks(
|
||||
app_handle,
|
||||
app_data_root,
|
||||
app_preferences,
|
||||
task_database,
|
||||
storyteller_creds_manager,
|
||||
higgsfield_sessions,
|
||||
&higgsfield_tasks,
|
||||
).await;
|
||||
}
|
||||
|
||||
if fal_tasks.is_empty() {
|
||||
// NB: the Grok poll already listened on its sockets for a couple of seconds.
|
||||
if grok_image_tasks.is_empty() {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ pub fn to_generation_service_provider(source: GenerationSource) -> GenerationSer
|
|||
| GenerationSource::OpenAiApi => GenerationServiceProvider::Sora,
|
||||
GenerationSource::WorldLabs
|
||||
| GenerationSource::WorldLabsCookies => GenerationServiceProvider::WorldLabs,
|
||||
GenerationSource::Higgsfield
|
||||
| GenerationSource::HiggsfieldCookies => GenerationServiceProvider::Higgsfield,
|
||||
// No tasks are generated from these sources (yet); fall back to Artcraft.
|
||||
GenerationSource::HiggsfieldCookies
|
||||
| GenerationSource::MagnificCookies
|
||||
GenerationSource::MagnificCookies
|
||||
| GenerationSource::OpenArtCookies
|
||||
| GenerationSource::RunwayCookies
|
||||
| GenerationSource::ReplicateApi => GenerationServiceProvider::Artcraft,
|
||||
|
|
|
|||
|
|
@ -81,9 +81,14 @@ pub enum GenerationSource {
|
|||
#[serde(rename = "worldlabs_cookies")]
|
||||
WorldLabsCookies,
|
||||
|
||||
// ── Other website (cookie) integrations ──
|
||||
// ── Higgsfield ──
|
||||
/// The generation provider recorded on tasks run with a Higgsfield account.
|
||||
#[serde(rename = "higgsfield")]
|
||||
Higgsfield,
|
||||
/// Higgsfield website cookies captured from the login webview.
|
||||
#[serde(rename = "higgsfield_cookies")]
|
||||
HiggsfieldCookies,
|
||||
// ── Other website (cookie) integrations ──
|
||||
#[serde(rename = "magnific_cookies")]
|
||||
MagnificCookies,
|
||||
#[serde(rename = "openart_cookies")]
|
||||
|
|
@ -111,6 +116,7 @@ impl GenerationSource {
|
|||
| Self::MidjourneyCookies
|
||||
| Self::WorldLabs
|
||||
| Self::WorldLabsCookies
|
||||
| Self::Higgsfield
|
||||
| Self::HiggsfieldCookies
|
||||
| Self::MagnificCookies
|
||||
| Self::OpenArtCookies
|
||||
|
|
@ -142,6 +148,7 @@ impl GenerationSource {
|
|||
Self::OpenAiApi => "openai_api",
|
||||
Self::WorldLabs => "world_labs",
|
||||
Self::WorldLabsCookies => "worldlabs_cookies",
|
||||
Self::Higgsfield => "higgsfield",
|
||||
Self::HiggsfieldCookies => "higgsfield_cookies",
|
||||
Self::MagnificCookies => "magnific_cookies",
|
||||
Self::OpenArtCookies => "openart_cookies",
|
||||
|
|
@ -168,6 +175,7 @@ impl GenerationSource {
|
|||
"openai_api" => Ok(Self::OpenAiApi),
|
||||
"world_labs" => Ok(Self::WorldLabs),
|
||||
"worldlabs_cookies" => Ok(Self::WorldLabsCookies),
|
||||
"higgsfield" => Ok(Self::Higgsfield),
|
||||
"higgsfield_cookies" => Ok(Self::HiggsfieldCookies),
|
||||
"magnific_cookies" => Ok(Self::MagnificCookies),
|
||||
"openart_cookies" => Ok(Self::OpenArtCookies),
|
||||
|
|
@ -196,6 +204,7 @@ impl GenerationSource {
|
|||
Self::OpenAiApi,
|
||||
Self::WorldLabs,
|
||||
Self::WorldLabsCookies,
|
||||
Self::Higgsfield,
|
||||
Self::HiggsfieldCookies,
|
||||
Self::MagnificCookies,
|
||||
Self::OpenArtCookies,
|
||||
|
|
@ -245,7 +254,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn all_variants_count() {
|
||||
assert_eq!(GenerationSource::all_variants().len(), 21);
|
||||
assert_eq!(GenerationSource::all_variants().len(), 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -146,6 +146,30 @@ fn google_models() -> Vec<ImageModelConfig> {
|
|||
batch_size_default: 1,
|
||||
..Default::default()
|
||||
},
|
||||
// Higgsfield-only: Nano Banana 2 at a fixed 1K with a High / Minimal
|
||||
// "thinking" menu (surfaced as quality High / Low).
|
||||
ImageModelConfig {
|
||||
model: ImageModel::NanoBanana2Lite,
|
||||
model_creator: ModelCreator::Google,
|
||||
full_name: "Nano Banana 2 Lite".to_string(),
|
||||
selector_name: "Nano Banana 2 Lite".to_string(),
|
||||
selector_description: "Fastest instructive editing".to_string(),
|
||||
selector_badges: strings(&["20 sec."]),
|
||||
tags: vec![ModelTag::InstructiveEdit],
|
||||
progress_bar_ms: 20_000,
|
||||
can_edit_images: true,
|
||||
text_prompt_max_length: None,
|
||||
image_refs_supported: true,
|
||||
image_refs_max: Some(6),
|
||||
aspect_ratio_options: NANO_BANANA_ASPECT_RATIOS.to_vec(),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
|
||||
quality_options: vec![CommonQuality::High, CommonQuality::Low],
|
||||
quality_default: Some(CommonQuality::High),
|
||||
batch_size_max: 4,
|
||||
batch_size_default: 1,
|
||||
..Default::default()
|
||||
},
|
||||
ImageModelConfig {
|
||||
model: ImageModel::NanoBanana,
|
||||
model_creator: ModelCreator::Google,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
use crate::configs::video_model_config::VideoModelConfig;
|
||||
use crate::configs::video_models::strings;
|
||||
use crate::enums::model_creator::ModelCreator;
|
||||
use crate::enums::video_model::VideoModel;
|
||||
|
||||
/// MiniMax (Hailuo) video models.
|
||||
pub fn minimax_video_models() -> Vec<VideoModelConfig> {
|
||||
vec![
|
||||
// Higgsfield-only. Fixed 2K output that follows the references' framing:
|
||||
// no aspect-ratio or resolution controls.
|
||||
VideoModelConfig {
|
||||
model: VideoModel::MinimaxH3,
|
||||
model_creator: ModelCreator::Hailuo,
|
||||
full_name: "MiniMax Hailuo 3".to_string(),
|
||||
selector_name: "MiniMax H3".to_string(),
|
||||
selector_description: "2K video with references".to_string(),
|
||||
extra_info: Some("Always renders at 2K and follows the aspect of the start frame or references.".to_string()),
|
||||
selector_badges: strings(&["~5 min."]),
|
||||
progress_bar_ms: 300_000,
|
||||
supports_system_prompt: false,
|
||||
text_prompt_max_length: Some(5_000),
|
||||
starting_keyframe_supported: true,
|
||||
ending_keyframe_supported: true,
|
||||
image_references_supported: true,
|
||||
image_references_max: Some(9),
|
||||
video_references_supported: true,
|
||||
video_references_max: Some(3),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: true,
|
||||
audio_references_max: Some(3),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
duration_seconds_min: Some(5),
|
||||
duration_seconds_max: Some(15),
|
||||
duration_seconds_options: Some((5..=15).collect()),
|
||||
duration_seconds_default: Some(5),
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ pub mod beeble_video_models;
|
|||
pub mod grok_video_models;
|
||||
pub mod happy_horse_video_models;
|
||||
pub mod kling_video_models;
|
||||
pub mod minimax_video_models;
|
||||
pub mod preview_video_models;
|
||||
pub mod seedance_video_models;
|
||||
pub mod sora_video_models;
|
||||
|
|
|
|||
|
|
@ -15,11 +15,84 @@ const SEEDANCE_ASPECT_RATIOS: &[CommonAspectRatio] = &[
|
|||
CommonAspectRatio::TallNineBySixteen,
|
||||
];
|
||||
|
||||
/// Bytedance Seedance video models: the 2.0 family (with references), 1.5
|
||||
/// Pro, and 1.0 Lite.
|
||||
/// Bytedance Seedance video models: 2.5 (Higgsfield), the 2.0 family (with
|
||||
/// references), 1.5 Pro, and 1.0 Lite.
|
||||
pub fn seedance_video_models() -> Vec<VideoModelConfig> {
|
||||
let mut models = Vec::new();
|
||||
|
||||
// Higgsfield-only. Same reference surface as 2.0, longer clips (up to 30s),
|
||||
// and no 4K tier.
|
||||
models.push(VideoModelConfig {
|
||||
model: VideoModel::Seedance2p5,
|
||||
model_creator: ModelCreator::Bytedance,
|
||||
full_name: "Seedance 2.5".to_string(),
|
||||
selector_name: "Seedance 2.5".to_string(),
|
||||
selector_description: "Newest Seedance; up to 30 seconds".to_string(),
|
||||
selector_badges: strings(&["~10 min."]),
|
||||
progress_bar_ms: 600_000,
|
||||
supports_system_prompt: false,
|
||||
text_prompt_max_length: Some(10_000),
|
||||
starting_keyframe_supported: true,
|
||||
ending_keyframe_supported: true,
|
||||
image_references_supported: true,
|
||||
image_references_max: Some(9),
|
||||
video_references_supported: true,
|
||||
video_references_max: Some(3),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: true,
|
||||
audio_references_max: Some(3),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
show_generate_with_sound_toggle: true,
|
||||
aspect_ratio_options: SEEDANCE_ASPECT_RATIOS.to_vec(),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP],
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: vec![CommonBitrate::Normal, CommonBitrate::High],
|
||||
bitrate_default: Some(CommonBitrate::High),
|
||||
duration_seconds_min: Some(4),
|
||||
duration_seconds_max: Some(30),
|
||||
duration_seconds_options: Some((4..=30).collect()),
|
||||
duration_seconds_default: Some(5),
|
||||
batch_size_max: 4,
|
||||
batch_size_options: Some(vec![1, 2, 3, 4]),
|
||||
batch_size_default: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Higgsfield-only. Video-to-video: the first video reference is the clip to
|
||||
// edit; images and audio are extra references. Output keeps the source's
|
||||
// length and framing, so there is no duration or aspect control.
|
||||
models.push(VideoModelConfig {
|
||||
model: VideoModel::Seedance2p5Edit,
|
||||
model_creator: ModelCreator::Bytedance,
|
||||
full_name: "Seedance 2.5 Edit".to_string(),
|
||||
selector_name: "Seedance 2.5 Edit".to_string(),
|
||||
selector_description: "Edit an existing video with a prompt".to_string(),
|
||||
extra_info: Some("Video-to-video. Attach the clip to edit as a video reference; add image and audio references to guide the edit.".to_string()),
|
||||
selector_badges: strings(&["~10 min."]),
|
||||
progress_bar_ms: 600_000,
|
||||
supports_system_prompt: false,
|
||||
text_to_video_supported: false,
|
||||
text_prompt_max_length: Some(10_000),
|
||||
image_references_supported: true,
|
||||
image_references_max: Some(9),
|
||||
video_references_supported: true,
|
||||
video_references_max: Some(1),
|
||||
video_references_max_total_duration_seconds: Some(15),
|
||||
audio_references_supported: true,
|
||||
audio_references_max: Some(3),
|
||||
audio_references_max_total_duration_seconds: Some(15),
|
||||
show_generate_with_sound_toggle: true,
|
||||
resolution_options: vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP],
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
bitrate_options: vec![CommonBitrate::Normal, CommonBitrate::High],
|
||||
bitrate_default: Some(CommonBitrate::High),
|
||||
batch_size_max: 4,
|
||||
batch_size_options: Some(vec![1, 2, 3, 4]),
|
||||
batch_size_default: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
models.push(seedance_2p0(
|
||||
VideoModel::Seedance2p0,
|
||||
"Seedance 2.0",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ fn video_models() -> Vec<VideoModelConfig> {
|
|||
models.extend(by_type::veo_video_models::veo_video_models());
|
||||
models.extend(by_type::vidu_video_models::vidu_video_models());
|
||||
models.extend(by_type::happy_horse_video_models::happy_horse_video_models());
|
||||
models.extend(by_type::minimax_video_models::minimax_video_models());
|
||||
models.extend(by_type::sora_video_models::sora_video_models());
|
||||
models.extend(by_type::grok_video_models::grok_video_models());
|
||||
models.extend(by_type::beeble_video_models::beeble_video_models());
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pub enum GenerationProvider {
|
|||
Fal,
|
||||
/// First-party (cookie-session) Grok.
|
||||
Grok,
|
||||
/// First-party (cookie-session) Higgsfield.
|
||||
Higgsfield,
|
||||
/// First-party (cookie-session) Midjourney.
|
||||
Midjourney,
|
||||
/// First-party (cookie-session) Sora.
|
||||
|
|
@ -28,5 +30,7 @@ mod tests {
|
|||
fn serializes_to_frontend_values() {
|
||||
assert_eq!(serde_json::to_string(&GenerationProvider::Artcraft).unwrap(), "\"artcraft\"");
|
||||
assert_eq!(serde_json::to_string(&GenerationProvider::WorldLabs).unwrap(), "\"world_labs\"");
|
||||
assert_eq!(serde_json::to_string(&GenerationProvider::Higgsfield).unwrap(), "\"higgsfield\"");
|
||||
assert_eq!(serde_json::from_str::<GenerationProvider>("\"higgsfield\"").unwrap(), GenerationProvider::Higgsfield);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ pub enum ImageModel {
|
|||
NanoBanana,
|
||||
#[serde(rename = "nano_banana_2")]
|
||||
NanoBanana2,
|
||||
/// Higgsfield's Nano Banana 2 Lite pipeline (quality tiers instead of a
|
||||
/// resolution menu).
|
||||
#[serde(rename = "nano_banana_2_lite")]
|
||||
NanoBanana2Lite,
|
||||
#[serde(rename = "nano_banana_pro")]
|
||||
NanoBananaPro,
|
||||
// ── Bytedance ──
|
||||
|
|
@ -79,5 +83,6 @@ mod tests {
|
|||
assert_eq!(serde_json::to_string(&ImageModel::GrokImagineImageQuality).unwrap(), "\"grok_imagine_image_q\"");
|
||||
assert_eq!(serde_json::to_string(&ImageModel::Seedream5p0ProUltra).unwrap(), "\"seedream_5p0_pro_u\"");
|
||||
assert_eq!(serde_json::from_str::<ImageModel>("\"flux_pro_1\"").unwrap(), ImageModel::FluxPro1);
|
||||
assert_eq!(serde_json::to_string(&ImageModel::NanoBanana2Lite).unwrap(), "\"nano_banana_2_lite\"");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ pub enum VideoModel {
|
|||
Seedance2p0BytePlusMini,
|
||||
#[serde(rename = "seedance_2p0_bpu_mini")]
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
/// Seedance 2.5 video-to-video editing: rewrites a source video clip.
|
||||
#[serde(rename = "seedance_2p5_edit")]
|
||||
Seedance2p5Edit,
|
||||
// ── MiniMax (Hailuo) ──
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
// ── Alibaba ──
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
|
@ -103,5 +111,8 @@ mod tests {
|
|||
assert_eq!(serde_json::to_string(&VideoModel::Seedance10Lite).unwrap(), "\"seedance_1p0_lite\"");
|
||||
assert_eq!(serde_json::to_string(&VideoModel::Seedance2p0BytePlusUltraMini).unwrap(), "\"seedance_2p0_bpu_mini\"");
|
||||
assert_eq!(serde_json::from_str::<VideoModel>("\"switch_x\"").unwrap(), VideoModel::SwitchX);
|
||||
assert_eq!(serde_json::to_string(&VideoModel::Seedance2p5).unwrap(), "\"seedance_2p5\"");
|
||||
assert_eq!(serde_json::to_string(&VideoModel::Seedance2p5Edit).unwrap(), "\"seedance_2p5_edit\"");
|
||||
assert_eq!(serde_json::to_string(&VideoModel::MinimaxH3).unwrap(), "\"minimax_h3\"");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ mod tests {
|
|||
#[test]
|
||||
fn offerings_are_consistent_with_the_model_table() {
|
||||
let known: Vec<AudioModel> = AUDIO_MODELS.iter().filter(|c| !c.is_disabled).map(|c| c.model).collect();
|
||||
check_offerings(&AUDIO_PROVIDERS, &known);
|
||||
check_offerings(&AUDIO_PROVIDERS, &known, |config| config.model);
|
||||
assert_eq!(AUDIO_PROVIDERS.len(), 1, "audio is ArtCraft-only");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,234 @@
|
|||
//! The image models a first-party (cookie-session) Higgsfield account runs,
|
||||
//! and how their option menus differ from the base table.
|
||||
//!
|
||||
//! Option sets mirror the higgsfield.ai image generator (read off the web app
|
||||
//! on 2026-08-31, see `higgsfield_client::endpoints::generate::image`). Where
|
||||
//! Higgsfield offers fewer choices than ArtCraft the override narrows the
|
||||
//! menu so the picker only shows what the account can actually run, and the
|
||||
//! router snaps anything else to the nearest tier.
|
||||
|
||||
use crate::configs::image_model_config::ImageModelConfig;
|
||||
use crate::configs::image_models::image_model_config;
|
||||
use crate::enums::common_aspect_ratio::CommonAspectRatio;
|
||||
use crate::enums::common_resolution::CommonResolution;
|
||||
use crate::enums::generation_provider::GenerationProvider;
|
||||
use crate::enums::image_model::ImageModel;
|
||||
use crate::providers::image_providers::ImageProviderOffering;
|
||||
use crate::providers::provider_offering::OfferedModel;
|
||||
|
||||
/// Higgsfield caps every image batch at 4.
|
||||
pub const HIGGSFIELD_IMAGE_BATCH_MAX: u16 = 4;
|
||||
|
||||
/// Higgsfield's Nano Banana Pro / 2 / 2 Lite and GPT Image 2 aspect menu
|
||||
/// (Nano Banana adds 5:4 and 4:5).
|
||||
const NANO_BANANA_ASPECT_RATIOS: &[CommonAspectRatio] = &[
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideFiveByFour,
|
||||
CommonAspectRatio::TallFourByFive,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
];
|
||||
|
||||
const GPT_IMAGE_2_ASPECT_RATIOS: &[CommonAspectRatio] = &[
|
||||
CommonAspectRatio::Auto,
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
];
|
||||
|
||||
/// Higgsfield's Seedream menu: no Auto and no baked-in-resolution values.
|
||||
const SEEDREAM_ASPECT_RATIOS: &[CommonAspectRatio] = &[
|
||||
CommonAspectRatio::Square,
|
||||
CommonAspectRatio::WideFourByThree,
|
||||
CommonAspectRatio::TallThreeByFour,
|
||||
CommonAspectRatio::WideSixteenByNine,
|
||||
CommonAspectRatio::WideTwentyOneByNine,
|
||||
CommonAspectRatio::TallNineBySixteen,
|
||||
CommonAspectRatio::TallTwoByThree,
|
||||
CommonAspectRatio::WideThreeByTwo,
|
||||
];
|
||||
|
||||
/// Every image model Higgsfield offers, in picker order.
|
||||
pub const HIGGSFIELD_IMAGE_MODELS: &[ImageModel] = &[
|
||||
ImageModel::NanoBananaPro,
|
||||
ImageModel::NanoBanana2,
|
||||
ImageModel::NanoBanana2Lite,
|
||||
ImageModel::GptImage2,
|
||||
ImageModel::Seedream5p0Pro,
|
||||
ImageModel::Seedream5Lite,
|
||||
ImageModel::Seedream4p5,
|
||||
];
|
||||
|
||||
pub fn higgsfield_image_offering() -> ImageProviderOffering {
|
||||
ImageProviderOffering {
|
||||
provider: GenerationProvider::Higgsfield,
|
||||
models: HIGGSFIELD_IMAGE_MODELS.iter().copied().map(offered).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The base config, or a Higgsfield-specific replacement when the menus
|
||||
/// differ. Every override starts from the base so presentation stays in sync.
|
||||
fn offered(model: ImageModel) -> OfferedModel<ImageModel, ImageModelConfig> {
|
||||
let base = image_model_config(model).clone();
|
||||
let overrides = match model {
|
||||
// Nano Banana Pro: the same 11 ratios, 1K / 2K / 4K, up to 4 images.
|
||||
ImageModel::NanoBananaPro => None,
|
||||
// Nano Banana 2: no 0.5K tier on Higgsfield.
|
||||
ImageModel::NanoBanana2 => Some(ImageModelConfig {
|
||||
aspect_ratio_options: NANO_BANANA_ASPECT_RATIOS.to_vec(),
|
||||
resolution_options: vec![CommonResolution::OneK, CommonResolution::TwoK, CommonResolution::FourK],
|
||||
..capped(base)
|
||||
}),
|
||||
// Higgsfield-only: the base config already describes it.
|
||||
ImageModel::NanoBanana2Lite => None,
|
||||
// GPT Image 2: no "1:1 HD", no 3K.
|
||||
ImageModel::GptImage2 => Some(ImageModelConfig {
|
||||
aspect_ratio_options: GPT_IMAGE_2_ASPECT_RATIOS.to_vec(),
|
||||
resolution_options: vec![CommonResolution::OneK, CommonResolution::TwoK, CommonResolution::FourK],
|
||||
..capped(base)
|
||||
}),
|
||||
// Seedream 5.0 Pro: no Auto ratio; 1K / 2K (Higgsfield's 1.5K has no
|
||||
// common tier); batches of 4, not 8.
|
||||
ImageModel::Seedream5p0Pro => Some(ImageModelConfig {
|
||||
aspect_ratio_options: SEEDREAM_ASPECT_RATIOS.to_vec(),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::Square),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Square),
|
||||
resolution_options: vec![CommonResolution::OneK, CommonResolution::TwoK],
|
||||
resolution_default: Some(CommonResolution::OneK),
|
||||
..capped(base)
|
||||
}),
|
||||
// Seedream 5 Lite: a real resolution menu (2K / 3K / 4K) instead of the
|
||||
// baked-in "auto_2k" style ratios.
|
||||
ImageModel::Seedream5Lite => Some(ImageModelConfig {
|
||||
aspect_ratio_options: SEEDREAM_ASPECT_RATIOS.to_vec(),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: vec![CommonResolution::TwoK, CommonResolution::ThreeK, CommonResolution::FourK],
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
..capped(base)
|
||||
}),
|
||||
// Seedream 4.5: likewise, 2K / 4K.
|
||||
ImageModel::Seedream4p5 => Some(ImageModelConfig {
|
||||
aspect_ratio_options: SEEDREAM_ASPECT_RATIOS.to_vec(),
|
||||
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
aspect_ratio_default_when_editing: Some(CommonAspectRatio::WideSixteenByNine),
|
||||
resolution_options: vec![CommonResolution::TwoK, CommonResolution::FourK],
|
||||
resolution_default: Some(CommonResolution::TwoK),
|
||||
..capped(base)
|
||||
}),
|
||||
other => unreachable!("{other:?} is not a Higgsfield image model"),
|
||||
};
|
||||
match overrides {
|
||||
Some(overrides) => OfferedModel::with_overrides(model, overrides),
|
||||
None => OfferedModel::same_as_base(model),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the batch cap every Higgsfield image model shares.
|
||||
fn capped(base: ImageModelConfig) -> ImageModelConfig {
|
||||
ImageModelConfig {
|
||||
batch_size_max: HIGGSFIELD_IMAGE_BATCH_MAX,
|
||||
batch_size_options: None,
|
||||
batch_size_default: base.batch_size_default.min(HIGGSFIELD_IMAGE_BATCH_MAX),
|
||||
..base
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::image_providers::{provider_offers_image_model, providers_for_image_model, IMAGE_PROVIDERS};
|
||||
use crate::providers::provider_offering::effective_config;
|
||||
|
||||
fn effective(model: ImageModel) -> ImageModelConfig {
|
||||
effective_config(&IMAGE_PROVIDERS, GenerationProvider::Higgsfield, model, image_model_config(model)).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_exactly_the_supported_models() {
|
||||
let offering = higgsfield_image_offering();
|
||||
let offered: Vec<ImageModel> = offering.models.iter().map(|m| m.model).collect();
|
||||
assert_eq!(offered, HIGGSFIELD_IMAGE_MODELS.to_vec());
|
||||
assert!(!provider_offers_image_model(GenerationProvider::Higgsfield, ImageModel::Flux1Dev));
|
||||
assert!(!provider_offers_image_model(GenerationProvider::Higgsfield, ImageModel::Midjourney8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nano_banana_2_lite_is_higgsfield_only() {
|
||||
assert_eq!(providers_for_image_model(ImageModel::NanoBanana2Lite), vec![GenerationProvider::Higgsfield]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_keep_identity_and_presentation() {
|
||||
for offered in &higgsfield_image_offering().models {
|
||||
let base = image_model_config(offered.model);
|
||||
if let Some(overrides) = &offered.overrides {
|
||||
assert_eq!(overrides.model, base.model);
|
||||
assert_eq!(overrides.full_name, base.full_name);
|
||||
assert_eq!(overrides.selector_name, base.selector_name);
|
||||
assert_eq!(overrides.can_edit_images, base.can_edit_images);
|
||||
assert_eq!(overrides.image_refs_supported, base.image_refs_supported);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_override_menu() {
|
||||
for model in HIGGSFIELD_IMAGE_MODELS {
|
||||
let config = effective(*model);
|
||||
assert!(config.batch_size_max <= HIGGSFIELD_IMAGE_BATCH_MAX, "{model:?}");
|
||||
assert!(config.batch_size_default <= config.batch_size_max, "{model:?}");
|
||||
if let Some(default) = config.aspect_ratio_default {
|
||||
assert!(config.aspect_ratio_options.contains(&default), "{model:?} aspect default not offered");
|
||||
}
|
||||
if let Some(default) = config.aspect_ratio_default_when_editing {
|
||||
assert!(config.aspect_ratio_options.contains(&default), "{model:?} editing aspect default not offered");
|
||||
}
|
||||
if let Some(default) = config.resolution_default {
|
||||
assert!(config.resolution_options.contains(&default), "{model:?} resolution default not offered");
|
||||
}
|
||||
if let Some(default) = config.quality_default {
|
||||
assert!(config.quality_options.contains(&default), "{model:?} quality default not offered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_narrow_to_what_higgsfield_runs() {
|
||||
// 0.5K is an ArtCraft-only tier for Nano Banana 2.
|
||||
assert!(image_model_config(ImageModel::NanoBanana2).resolution_options.contains(&CommonResolution::HalfK));
|
||||
assert!(!effective(ImageModel::NanoBanana2).resolution_options.contains(&CommonResolution::HalfK));
|
||||
// GPT Image 2 has no 3K and no "1:1 HD" on Higgsfield.
|
||||
let gpt = effective(ImageModel::GptImage2);
|
||||
assert!(!gpt.resolution_options.contains(&CommonResolution::ThreeK));
|
||||
assert!(!gpt.aspect_ratio_options.contains(&CommonAspectRatio::SquareHd));
|
||||
assert!(gpt.aspect_ratio_options.contains(&CommonAspectRatio::WideTwentyOneByNine));
|
||||
// Seedream 5.0 Pro: batches of 4, no Auto.
|
||||
let pro = effective(ImageModel::Seedream5p0Pro);
|
||||
assert_eq!(pro.batch_size_max, 4);
|
||||
assert!(!pro.aspect_ratio_options.contains(&CommonAspectRatio::Auto));
|
||||
// Seedream 5 Lite / 4.5 gain a real resolution menu.
|
||||
assert_eq!(effective(ImageModel::Seedream5Lite).resolution_options, vec![CommonResolution::TwoK, CommonResolution::ThreeK, CommonResolution::FourK]);
|
||||
assert_eq!(effective(ImageModel::Seedream4p5).resolution_options, vec![CommonResolution::TwoK, CommonResolution::FourK]);
|
||||
// Nano Banana Pro is identical to the base config.
|
||||
assert!(higgsfield_image_offering().overrides_for(ImageModel::NanoBananaPro).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_config_falls_back_to_base_for_other_providers() {
|
||||
let base = image_model_config(ImageModel::NanoBanana2);
|
||||
let artcraft = effective_config(&IMAGE_PROVIDERS, GenerationProvider::Artcraft, ImageModel::NanoBanana2, base);
|
||||
assert_eq!(artcraft, base);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
//! The video models a first-party (cookie-session) Higgsfield account runs,
|
||||
//! and how their option menus differ from the base table.
|
||||
//!
|
||||
//! Option sets mirror the higgsfield.ai video generator (see
|
||||
//! `higgsfield_client::endpoints::generate::video`). Kling 3.0's "mode" menu
|
||||
//! (std / pro / 4K) is split across the two Kling 3.0 model ids: Standard is
|
||||
//! the 720p tier, Pro the 1080p / 4K tiers.
|
||||
|
||||
use crate::configs::video_model_config::VideoModelConfig;
|
||||
use crate::configs::video_models::video_model_config;
|
||||
use crate::enums::common_bitrate::CommonBitrate;
|
||||
use crate::enums::common_resolution::CommonResolution;
|
||||
use crate::enums::generation_provider::GenerationProvider;
|
||||
use crate::enums::video_model::VideoModel;
|
||||
use crate::providers::provider_offering::OfferedModel;
|
||||
use crate::providers::video_providers::VideoProviderOffering;
|
||||
|
||||
/// Higgsfield caps every video batch at 4.
|
||||
pub const HIGGSFIELD_VIDEO_BATCH_MAX: u16 = 4;
|
||||
|
||||
/// Every video model Higgsfield offers, in picker order.
|
||||
pub const HIGGSFIELD_VIDEO_MODELS: &[VideoModel] = &[
|
||||
VideoModel::Seedance2p5,
|
||||
VideoModel::Seedance2p5Edit,
|
||||
VideoModel::Seedance2p0,
|
||||
VideoModel::Seedance2p0Mini,
|
||||
VideoModel::MinimaxH3,
|
||||
VideoModel::Kling3p0Pro,
|
||||
VideoModel::Kling3p0Standard,
|
||||
VideoModel::GrokImagineVideo1p5,
|
||||
];
|
||||
|
||||
pub fn higgsfield_video_offering() -> VideoProviderOffering {
|
||||
VideoProviderOffering {
|
||||
provider: GenerationProvider::Higgsfield,
|
||||
models: HIGGSFIELD_VIDEO_MODELS.iter().copied().map(offered).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The base config, or a Higgsfield-specific replacement when the menus
|
||||
/// differ. Every override starts from the base so presentation stays in sync.
|
||||
fn offered(model: VideoModel) -> OfferedModel<VideoModel, VideoModelConfig> {
|
||||
let base = video_model_config(model).clone();
|
||||
let overrides = match model {
|
||||
// Higgsfield-only models: the base config already describes them.
|
||||
VideoModel::Seedance2p5 | VideoModel::Seedance2p5Edit | VideoModel::MinimaxH3 => None,
|
||||
// Seedance 2.0: no character references; the web app defaults to the
|
||||
// high bitrate.
|
||||
VideoModel::Seedance2p0 => Some(VideoModelConfig {
|
||||
character_references_supported: false,
|
||||
character_references_max: None,
|
||||
bitrate_default: Some(CommonBitrate::High),
|
||||
..capped(base)
|
||||
}),
|
||||
// Seedance 2.0 Mini: no character references and no bitrate menu.
|
||||
VideoModel::Seedance2p0Mini => Some(VideoModelConfig {
|
||||
character_references_supported: false,
|
||||
character_references_max: None,
|
||||
bitrate_options: Vec::new(),
|
||||
bitrate_default: None,
|
||||
..capped(base)
|
||||
}),
|
||||
// Kling 3.0 Standard = the "std" mode: 720p only.
|
||||
VideoModel::Kling3p0Standard => Some(VideoModelConfig {
|
||||
resolution_options: vec![CommonResolution::SevenTwentyP],
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
..capped(base)
|
||||
}),
|
||||
// Kling 3.0 Pro = the "pro" and "4K" modes.
|
||||
VideoModel::Kling3p0Pro => Some(VideoModelConfig {
|
||||
resolution_options: vec![CommonResolution::TenEightyP, CommonResolution::FourK],
|
||||
resolution_default: Some(CommonResolution::TenEightyP),
|
||||
..capped(base)
|
||||
}),
|
||||
// Grok Imagine 1.5: text-to-video works, a 1080p tier exists, and image
|
||||
// references are accepted alongside the start frame.
|
||||
VideoModel::GrokImagineVideo1p5 => Some(VideoModelConfig {
|
||||
text_to_video_supported: true,
|
||||
starting_keyframe_required: false,
|
||||
image_references_supported: true,
|
||||
image_references_max: Some(4),
|
||||
resolution_options: vec![CommonResolution::FourEightyP, CommonResolution::SevenTwentyP, CommonResolution::TenEightyP],
|
||||
resolution_default: Some(CommonResolution::SevenTwentyP),
|
||||
duration_seconds_min: Some(1),
|
||||
duration_seconds_max: Some(15),
|
||||
duration_seconds_max_with_image_references: None,
|
||||
duration_seconds_options: Some((1..=15).collect()),
|
||||
duration_seconds_default: Some(6),
|
||||
..capped(base)
|
||||
}),
|
||||
other => unreachable!("{other:?} is not a Higgsfield video model"),
|
||||
};
|
||||
match overrides {
|
||||
Some(overrides) => OfferedModel::with_overrides(model, overrides),
|
||||
None => OfferedModel::same_as_base(model),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the batch cap every Higgsfield video model shares.
|
||||
fn capped(base: VideoModelConfig) -> VideoModelConfig {
|
||||
VideoModelConfig {
|
||||
batch_size_max: base.batch_size_max.min(HIGGSFIELD_VIDEO_BATCH_MAX),
|
||||
batch_size_options: base.batch_size_options.map(|options| {
|
||||
options.into_iter().filter(|count| *count <= HIGGSFIELD_VIDEO_BATCH_MAX).collect()
|
||||
}),
|
||||
batch_size_default: base.batch_size_default.min(HIGGSFIELD_VIDEO_BATCH_MAX),
|
||||
..base
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::provider_offering::effective_config;
|
||||
use crate::providers::video_providers::{provider_offers_video_model, providers_for_video_model, VIDEO_PROVIDERS};
|
||||
|
||||
fn effective(model: VideoModel) -> VideoModelConfig {
|
||||
effective_config(&VIDEO_PROVIDERS, GenerationProvider::Higgsfield, model, video_model_config(model)).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_exactly_the_supported_models() {
|
||||
let offering = higgsfield_video_offering();
|
||||
let offered: Vec<VideoModel> = offering.models.iter().map(|m| m.model).collect();
|
||||
assert_eq!(offered, HIGGSFIELD_VIDEO_MODELS.to_vec());
|
||||
assert!(!provider_offers_video_model(GenerationProvider::Higgsfield, VideoModel::Veo3));
|
||||
assert!(!provider_offers_video_model(GenerationProvider::Higgsfield, VideoModel::Seedance2p0Fast));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higgsfield_only_models_have_no_other_provider() {
|
||||
for model in [VideoModel::Seedance2p5, VideoModel::Seedance2p5Edit, VideoModel::MinimaxH3] {
|
||||
assert_eq!(providers_for_video_model(model), vec![GenerationProvider::Higgsfield], "{model:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_keep_identity_and_presentation() {
|
||||
for offered in &higgsfield_video_offering().models {
|
||||
let base = video_model_config(offered.model);
|
||||
if let Some(overrides) = &offered.overrides {
|
||||
assert_eq!(overrides.model, base.model);
|
||||
assert_eq!(overrides.full_name, base.full_name);
|
||||
assert_eq!(overrides.selector_name, base.selector_name);
|
||||
assert_eq!(overrides.starting_keyframe_supported, base.starting_keyframe_supported);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_override_menu() {
|
||||
for model in HIGGSFIELD_VIDEO_MODELS {
|
||||
let config = effective(*model);
|
||||
assert!(config.batch_size_max <= HIGGSFIELD_VIDEO_BATCH_MAX, "{model:?}");
|
||||
assert!(config.batch_size_default <= config.batch_size_max, "{model:?}");
|
||||
if let Some(default) = config.aspect_ratio_default {
|
||||
assert!(config.aspect_ratio_options.contains(&default), "{model:?} aspect default not offered");
|
||||
}
|
||||
if let Some(default) = config.resolution_default {
|
||||
assert!(config.resolution_options.contains(&default), "{model:?} resolution default not offered");
|
||||
}
|
||||
if let Some(default) = config.bitrate_default {
|
||||
assert!(config.bitrate_options.contains(&default), "{model:?} bitrate default not offered");
|
||||
}
|
||||
if let (Some(default), Some(options)) = (config.duration_seconds_default, &config.duration_seconds_options) {
|
||||
assert!(options.contains(&default), "{model:?} duration default not offered");
|
||||
}
|
||||
if let (Some(min), Some(max), Some(options)) = (config.duration_seconds_min, config.duration_seconds_max, &config.duration_seconds_options) {
|
||||
assert_eq!(options.first().copied(), Some(min), "{model:?} duration options start below min");
|
||||
assert_eq!(options.last().copied(), Some(max), "{model:?} duration options end above max");
|
||||
}
|
||||
if !config.starting_keyframe_supported {
|
||||
assert!(!config.starting_keyframe_required, "{model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kling_3p0_splits_the_mode_menu_by_model() {
|
||||
assert_eq!(effective(VideoModel::Kling3p0Standard).resolution_options, vec![CommonResolution::SevenTwentyP]);
|
||||
assert_eq!(effective(VideoModel::Kling3p0Pro).resolution_options, vec![CommonResolution::TenEightyP, CommonResolution::FourK]);
|
||||
// The base (ArtCraft) Kling 3.0 has no resolution menu at all.
|
||||
assert!(video_model_config(VideoModel::Kling3p0Pro).resolution_options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p0_drops_characters_and_mini_drops_bitrate() {
|
||||
assert!(video_model_config(VideoModel::Seedance2p0).character_references_supported);
|
||||
assert!(!effective(VideoModel::Seedance2p0).character_references_supported);
|
||||
assert!(!effective(VideoModel::Seedance2p0Mini).character_references_supported);
|
||||
assert!(effective(VideoModel::Seedance2p0Mini).bitrate_options.is_empty());
|
||||
assert_eq!(effective(VideoModel::Seedance2p0).resolution_options, video_model_config(VideoModel::Seedance2p0).resolution_options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_imagine_1p5_gains_text_to_video_and_1080p() {
|
||||
let base = video_model_config(VideoModel::GrokImagineVideo1p5);
|
||||
let higgsfield = effective(VideoModel::GrokImagineVideo1p5);
|
||||
assert!(!base.text_to_video_supported);
|
||||
assert!(higgsfield.text_to_video_supported);
|
||||
assert!(!higgsfield.starting_keyframe_required);
|
||||
assert!(higgsfield.resolution_options.contains(&CommonResolution::TenEightyP));
|
||||
assert!(higgsfield.image_references_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedance_2p5_runs_longer_than_2p0_but_without_4k() {
|
||||
let seedance_2p5 = effective(VideoModel::Seedance2p5);
|
||||
assert_eq!(seedance_2p5.duration_seconds_max, Some(30));
|
||||
assert!(!seedance_2p5.resolution_options.contains(&CommonResolution::FourK));
|
||||
assert!(seedance_2p5.video_references_supported && seedance_2p5.audio_references_supported);
|
||||
let edit = effective(VideoModel::Seedance2p5Edit);
|
||||
assert!(!edit.text_to_video_supported);
|
||||
assert_eq!(edit.video_references_max, Some(1));
|
||||
assert!(edit.duration_seconds_options.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
use crate::configs::image_model_config::ImageModelConfig;
|
||||
use crate::enums::generation_provider::GenerationProvider;
|
||||
use crate::enums::image_model::ImageModel;
|
||||
use crate::providers::higgsfield_image_offering::higgsfield_image_offering;
|
||||
use crate::providers::provider_offering::{is_offered, providers_for_model, ProviderOffering};
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
|
|
@ -69,6 +70,9 @@ fn image_providers() -> Vec<ImageProviderOffering> {
|
|||
ImageModel::Midjourney7,
|
||||
ImageModel::Midjourney7Niji,
|
||||
]),
|
||||
// First-party (cookie-session) Higgsfield, with per-model overrides where
|
||||
// its menus differ from ArtCraft's.
|
||||
higgsfield_image_offering(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +85,7 @@ mod tests {
|
|||
#[test]
|
||||
fn offerings_are_consistent_with_the_model_table() {
|
||||
let known: Vec<ImageModel> = IMAGE_MODELS.iter().filter(|c| !c.is_disabled).map(|c| c.model).collect();
|
||||
check_offerings(&IMAGE_PROVIDERS, &known);
|
||||
check_offerings(&IMAGE_PROVIDERS, &known, |config| config.model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -94,5 +98,6 @@ mod tests {
|
|||
assert!(!provider_offers_image_model(GenerationProvider::Grok, ImageModel::Midjourney7));
|
||||
// ArtCraft is the default provider for the shared models.
|
||||
assert_eq!(providers_for_image_model(ImageModel::Flux1Dev)[0], GenerationProvider::Artcraft);
|
||||
assert_eq!(providers_for_image_model(ImageModel::NanoBananaPro), vec![GenerationProvider::Artcraft, GenerationProvider::Fal, GenerationProvider::Higgsfield]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ mod tests {
|
|||
#[test]
|
||||
fn offerings_are_consistent_with_the_model_table() {
|
||||
let known: Vec<MeshModel> = MESH_MODELS.iter().filter(|c| !c.is_disabled).map(|c| c.model).collect();
|
||||
check_offerings(&MESH_PROVIDERS, &known);
|
||||
check_offerings(&MESH_PROVIDERS, &known, |config| config.model);
|
||||
assert_eq!(MESH_PROVIDERS.len(), 1, "mesh is ArtCraft-only");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,15 @@
|
|||
//! router still guard on their own.
|
||||
//!
|
||||
//! Each offering may carry `overrides`: a provider-specific replacement config
|
||||
//! for a model whose behavior differs on that provider. None do today.
|
||||
//! for a model whose behavior differs on that provider. Higgsfield uses these
|
||||
//! (see [`higgsfield_image_offering`] / [`higgsfield_video_offering`]): it
|
||||
//! runs several shared models with narrower resolution / duration / reference
|
||||
//! menus than ArtCraft. Read a model's options for a provider through
|
||||
//! [`provider_offering::effective_config`].
|
||||
|
||||
pub mod audio_providers;
|
||||
pub mod higgsfield_image_offering;
|
||||
pub mod higgsfield_video_offering;
|
||||
pub mod image_providers;
|
||||
pub mod mesh_providers;
|
||||
pub mod provider_offering;
|
||||
|
|
@ -26,8 +32,13 @@ pub(crate) mod tests_common {
|
|||
use std::hash::Hash;
|
||||
|
||||
/// Every enabled model is offered by at least one provider, every offered
|
||||
/// model is a known enabled model, and no provider lists a model twice.
|
||||
pub fn check_offerings<M: Copy + Eq + Hash + Debug, C>(offerings: &[ProviderOffering<M, C>], enabled_models: &[M]) {
|
||||
/// model is a known enabled model, no provider lists a model twice, and
|
||||
/// every override describes the model it's attached to.
|
||||
pub fn check_offerings<M: Copy + Eq + Hash + Debug, C>(
|
||||
offerings: &[ProviderOffering<M, C>],
|
||||
enabled_models: &[M],
|
||||
model_of_config: impl Fn(&C) -> M,
|
||||
) {
|
||||
let enabled: HashSet<M> = enabled_models.iter().copied().collect();
|
||||
let mut offered_anywhere: HashSet<M> = HashSet::new();
|
||||
let mut providers_seen = HashSet::new();
|
||||
|
|
@ -37,6 +48,9 @@ pub(crate) mod tests_common {
|
|||
for offered in &offering.models {
|
||||
assert!(seen.insert(offered.model), "{:?} lists {:?} twice", offering.provider, offered.model);
|
||||
assert!(enabled.contains(&offered.model), "{:?} offers unknown/disabled {:?}", offering.provider, offered.model);
|
||||
if let Some(overrides) = &offered.overrides {
|
||||
assert_eq!(model_of_config(overrides), offered.model, "{:?} override is for another model", offering.provider);
|
||||
}
|
||||
offered_anywhere.insert(offered.model);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ impl<Model, Config> OfferedModel<Model, Config> {
|
|||
pub fn same_as_base(model: Model) -> Self {
|
||||
Self { model, overrides: None }
|
||||
}
|
||||
|
||||
/// This provider runs the model with a different capability surface than
|
||||
/// the base config (fewer resolutions, other durations, ...).
|
||||
pub fn with_overrides(model: Model, overrides: Config) -> Self {
|
||||
Self { model, overrides: Some(overrides) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: Copy + PartialEq, Config> ProviderOffering<Model, Config> {
|
||||
|
|
@ -37,6 +43,28 @@ impl<Model: Copy + PartialEq, Config> ProviderOffering<Model, Config> {
|
|||
pub fn offers(&self, model: Model) -> bool {
|
||||
self.models.iter().any(|offered| offered.model == model)
|
||||
}
|
||||
|
||||
/// This provider's replacement config for `model`, if it has one.
|
||||
pub fn overrides_for(&self, model: Model) -> Option<&Config> {
|
||||
self.models.iter()
|
||||
.find(|offered| offered.model == model)
|
||||
.and_then(|offered| offered.overrides.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// The config `provider` runs `model` with: its override when the offering
|
||||
/// carries one, else `base`. Callers that plan a request against a specific
|
||||
/// provider should read options from this, not from the base table.
|
||||
pub fn effective_config<'a, Model: Copy + PartialEq, Config>(
|
||||
offerings: &'a [ProviderOffering<Model, Config>],
|
||||
provider: GenerationProvider,
|
||||
model: Model,
|
||||
base: &'a Config,
|
||||
) -> &'a Config {
|
||||
offerings.iter()
|
||||
.find(|offering| offering.provider == provider)
|
||||
.and_then(|offering| offering.overrides_for(model))
|
||||
.unwrap_or(base)
|
||||
}
|
||||
|
||||
/// The providers that offer `model`, in table order (the first is the
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ mod tests {
|
|||
#[test]
|
||||
fn offerings_are_consistent_with_the_model_table() {
|
||||
let known: Vec<SplatModel> = SPLAT_MODELS.iter().filter(|c| !c.is_disabled).map(|c| c.model).collect();
|
||||
check_offerings(&SPLAT_PROVIDERS, &known);
|
||||
check_offerings(&SPLAT_PROVIDERS, &known, |config| config.model);
|
||||
assert_eq!(providers_for_splat_model(SplatModel::TripoSplat), vec![GenerationProvider::Artcraft]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use crate::configs::video_model_config::VideoModelConfig;
|
||||
use crate::enums::generation_provider::GenerationProvider;
|
||||
use crate::enums::video_model::VideoModel;
|
||||
use crate::providers::higgsfield_video_offering::higgsfield_video_offering;
|
||||
use crate::providers::provider_offering::{is_offered, providers_for_model, ProviderOffering};
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
|
|
@ -20,8 +21,9 @@ pub fn provider_offers_video_model(provider: GenerationProvider, model: VideoMod
|
|||
|
||||
fn video_providers() -> Vec<VideoProviderOffering> {
|
||||
vec![
|
||||
// ArtCraft runs every video model (Grok Imagine video is served through
|
||||
// storyteller-web too). Grok and Midjourney offer no video models.
|
||||
// ArtCraft runs every video model except the Higgsfield-only ones (Grok
|
||||
// Imagine video is served through storyteller-web too). Grok and
|
||||
// Midjourney offer no video models.
|
||||
VideoProviderOffering::of(GenerationProvider::Artcraft, &[
|
||||
VideoModel::Seedance2p0,
|
||||
VideoModel::Seedance2p0Fast,
|
||||
|
|
@ -62,6 +64,9 @@ fn video_providers() -> Vec<VideoProviderOffering> {
|
|||
VideoProviderOffering::of(GenerationProvider::Sora, &[
|
||||
VideoModel::Sora2,
|
||||
]),
|
||||
// First-party (cookie-session) Higgsfield, with per-model overrides where
|
||||
// its menus differ from ArtCraft's.
|
||||
higgsfield_video_offering(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +79,7 @@ mod tests {
|
|||
#[test]
|
||||
fn offerings_are_consistent_with_the_model_table() {
|
||||
let known: Vec<VideoModel> = VIDEO_MODELS.iter().filter(|c| !c.is_disabled).map(|c| c.model).collect();
|
||||
check_offerings(&VIDEO_PROVIDERS, &known);
|
||||
check_offerings(&VIDEO_PROVIDERS, &known, |config| config.model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -83,5 +88,6 @@ mod tests {
|
|||
assert!(!VIDEO_PROVIDERS.iter().any(|o| o.provider == GenerationProvider::Midjourney));
|
||||
assert_eq!(providers_for_video_model(VideoModel::Sora2), vec![GenerationProvider::Artcraft, GenerationProvider::Sora]);
|
||||
assert!(provider_offers_video_model(GenerationProvider::Artcraft, VideoModel::GrokImagineVideo));
|
||||
assert_eq!(providers_for_video_model(VideoModel::Seedance2p0), vec![GenerationProvider::Artcraft, GenerationProvider::Higgsfield]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ fal_client.workspace = true
|
|||
gmicloud_client.workspace = true
|
||||
grok_api_client.workspace = true
|
||||
grok_consumer_client.workspace = true
|
||||
higgsfield_client.workspace = true
|
||||
midjourney_client.workspace = true
|
||||
seedance2pro_client.workspace = true
|
||||
sqlite_identifiers.workspace = true
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ pub enum RouterImageModel {
|
|||
NanoBanana,
|
||||
#[serde(rename = "nano_banana_2")]
|
||||
NanoBanana2,
|
||||
/// Higgsfield's Nano Banana 2 Lite (fixed 1K, High / Minimal quality).
|
||||
#[serde(rename = "nano_banana_2_lite")]
|
||||
NanoBanana2Lite,
|
||||
#[serde(rename = "nano_banana_pro")]
|
||||
NanoBananaPro,
|
||||
#[serde(rename = "seedream_4")]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ pub enum RouterProvider {
|
|||
GrokApi,
|
||||
/// First-party (cookie-session) Grok Imagine.
|
||||
Grok,
|
||||
/// First-party (cookie-session) Higgsfield.
|
||||
Higgsfield,
|
||||
/// First-party (cookie-session) Midjourney.
|
||||
Midjourney,
|
||||
Seedance2Pro,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,13 @@ pub enum RouterVideoModel {
|
|||
|
||||
#[serde(rename = "seedance_2p0_bpu_mini")]
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
#[serde(rename = "seedance_2p5")]
|
||||
Seedance2p5,
|
||||
/// Seedance 2.5 video-to-video editing.
|
||||
#[serde(rename = "seedance_2p5_edit")]
|
||||
Seedance2p5Edit,
|
||||
#[serde(rename = "minimax_h3")]
|
||||
MinimaxH3,
|
||||
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod router_fal_client;
|
|||
pub mod router_gmicloud_client;
|
||||
pub mod router_grok_api_client;
|
||||
pub mod router_grok_client;
|
||||
pub mod router_higgsfield_client;
|
||||
pub mod router_midjourney_client;
|
||||
pub mod router_seedance2pro_client;
|
||||
pub mod router_worldlabs_client;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
|||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_grok_client::RouterGrokClient;
|
||||
use crate::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
|
|
@ -14,6 +15,7 @@ pub struct MultiRouterClient {
|
|||
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
pub(crate) grok_api_client: Option<RouterGrokApiClient>,
|
||||
pub(crate) grok_client: Option<RouterGrokClient>,
|
||||
pub(crate) higgsfield_client: Option<RouterHiggsfieldClient>,
|
||||
pub(crate) midjourney_client: Option<RouterMidjourneyClient>,
|
||||
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
|
||||
pub(crate) worldlabs_client: Option<RouterWorldLabsClient>,
|
||||
|
|
@ -45,6 +47,11 @@ impl MultiRouterClient {
|
|||
.ok_or(ClientError::ClientNotConfigured(ClientType::Grok))
|
||||
}
|
||||
|
||||
pub fn get_higgsfield_client_ref(&self) -> Result<&RouterHiggsfieldClient, ClientError> {
|
||||
self.higgsfield_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Higgsfield))
|
||||
}
|
||||
|
||||
pub fn get_midjourney_client_ref(&self) -> Result<&RouterMidjourneyClient, ClientError> {
|
||||
self.midjourney_client.as_ref()
|
||||
.ok_or(ClientError::ClientNotConfigured(ClientType::Midjourney))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
|||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_grok_client::RouterGrokClient;
|
||||
use crate::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
|
|
@ -14,6 +15,7 @@ pub struct MultiRouterClientBuilder {
|
|||
gmicloud_client: Option<RouterGmiCloudClient>,
|
||||
grok_api_client: Option<RouterGrokApiClient>,
|
||||
grok_client: Option<RouterGrokClient>,
|
||||
higgsfield_client: Option<RouterHiggsfieldClient>,
|
||||
midjourney_client: Option<RouterMidjourneyClient>,
|
||||
seedance2pro_client: Option<RouterSeedance2ProClient>,
|
||||
worldlabs_client: Option<RouterWorldLabsClient>,
|
||||
|
|
@ -27,6 +29,7 @@ impl MultiRouterClientBuilder {
|
|||
gmicloud_client: None,
|
||||
grok_api_client: None,
|
||||
grok_client: None,
|
||||
higgsfield_client: None,
|
||||
midjourney_client: None,
|
||||
seedance2pro_client: None,
|
||||
worldlabs_client: None,
|
||||
|
|
@ -58,6 +61,11 @@ impl MultiRouterClientBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_higgsfield_client(mut self, client: RouterHiggsfieldClient) -> Self {
|
||||
self.higgsfield_client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_midjourney_client(mut self, client: RouterMidjourneyClient) -> Self {
|
||||
self.midjourney_client = Some(client);
|
||||
self
|
||||
|
|
@ -80,6 +88,7 @@ impl MultiRouterClientBuilder {
|
|||
gmicloud_client: self.gmicloud_client,
|
||||
grok_api_client: self.grok_api_client,
|
||||
grok_client: self.grok_client,
|
||||
higgsfield_client: self.higgsfield_client,
|
||||
midjourney_client: self.midjourney_client,
|
||||
seedance2pro_client: self.seedance2pro_client,
|
||||
worldlabs_client: self.worldlabs_client,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::client::router_fal_client::RouterFalClient;
|
|||
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
|
||||
use crate::client::router_grok_api_client::RouterGrokApiClient;
|
||||
use crate::client::router_grok_client::RouterGrokClient;
|
||||
use crate::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use crate::client::router_midjourney_client::RouterMidjourneyClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::client::router_worldlabs_client::RouterWorldLabsClient;
|
||||
|
|
@ -16,6 +17,7 @@ pub enum RouterClient {
|
|||
GmiCloud(RouterGmiCloudClient),
|
||||
GrokApi(RouterGrokApiClient),
|
||||
Grok(RouterGrokClient),
|
||||
Higgsfield(RouterHiggsfieldClient),
|
||||
Midjourney(RouterMidjourneyClient),
|
||||
Seedance2Pro(RouterSeedance2ProClient),
|
||||
WorldLabs(RouterWorldLabsClient),
|
||||
|
|
@ -62,6 +64,14 @@ impl RouterClient {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_higgsfield_client_ref(&self) -> Result<&RouterHiggsfieldClient, ClientError> {
|
||||
match self {
|
||||
RouterClient::Higgsfield(client) => Ok(client),
|
||||
RouterClient::Multi(multi) => multi.get_higgsfield_client_ref(),
|
||||
_ => Err(ClientError::ClientNotConfigured(ClientType::Higgsfield)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_midjourney_client_ref(&self) -> Result<&RouterMidjourneyClient, ClientError> {
|
||||
match self {
|
||||
RouterClient::Midjourney(client) => Ok(client),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
use higgsfield_client::session::higgsfield_session::HiggsfieldSession;
|
||||
|
||||
/// The first-party (cookie-session) Higgsfield client used by the router.
|
||||
///
|
||||
/// Drives the user's own logged-in higgsfield.ai account through a
|
||||
/// [`HiggsfieldSession`], which mints and refreshes the short-lived Clerk
|
||||
/// bearer token on its own and replays the captured cookies under the
|
||||
/// User-Agent that earned them. The router enqueues jobs (and uploads
|
||||
/// reference media first); the app polls job status itself.
|
||||
pub struct RouterHiggsfieldClient {
|
||||
pub(crate) session: HiggsfieldSession,
|
||||
}
|
||||
|
||||
impl RouterHiggsfieldClient {
|
||||
pub fn new(session: HiggsfieldSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &HiggsfieldSession {
|
||||
&self.session
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ use crate::errors::client_error::ClientError;
|
|||
use crate::errors::download_error::DownloadError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use fal_client::error::fal_error_plus::FalErrorPlus;
|
||||
use higgsfield_client::error::higgsfield_api_error::HiggsfieldApiError;
|
||||
use higgsfield_client::error::higgsfield_error::HiggsfieldError;
|
||||
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
|
||||
use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
|
||||
use std::error::Error;
|
||||
|
|
@ -31,6 +33,9 @@ pub enum ArtcraftRouterError {
|
|||
|
||||
/// A billing error from an underlying provider.
|
||||
ProviderBillingError(ProviderError),
|
||||
/// The provider accepted the request but answered without the data the
|
||||
/// router needs to track it (e.g. no job ids).
|
||||
ProviderResponseInvalid(String),
|
||||
}
|
||||
|
||||
impl Error for ArtcraftRouterError {}
|
||||
|
|
@ -45,6 +50,7 @@ impl Display for ArtcraftRouterError {
|
|||
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
|
||||
Self::Provider(e) => write!(f, "Provider error: {}", e),
|
||||
Self::ProviderBillingError(e) => write!(f, "Provider billing error: {}", e),
|
||||
Self::ProviderResponseInvalid(msg) => write!(f, "Provider response invalid: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +73,7 @@ impl From<ProviderError> for ArtcraftRouterError {
|
|||
ProviderError::Fal(FalErrorPlus::FalBillingError(_)) => true,
|
||||
ProviderError::Seedance2Pro(Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::BillingError { .. })) => true,
|
||||
ProviderError::Storyteller(StorytellerError::Api(ApiError::PaymentRequired(_))) => true,
|
||||
ProviderError::Higgsfield(HiggsfieldError::Api(HiggsfieldApiError::InsufficientCredits { .. })) => true,
|
||||
_ => false,
|
||||
};
|
||||
if is_billing_error {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub enum ClientType {
|
|||
GmiCloud,
|
||||
GrokApi,
|
||||
Grok,
|
||||
Higgsfield,
|
||||
Midjourney,
|
||||
Seedance2Pro,
|
||||
WorldLabs,
|
||||
|
|
@ -23,6 +24,7 @@ impl Display for ClientType {
|
|||
Self::GmiCloud => write!(f, "GmiCloud"),
|
||||
Self::GrokApi => write!(f, "GrokApi"),
|
||||
Self::Grok => write!(f, "Grok"),
|
||||
Self::Higgsfield => write!(f, "Higgsfield"),
|
||||
Self::Midjourney => write!(f, "Midjourney"),
|
||||
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
|
||||
Self::WorldLabs => write!(f, "WorldLabs"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use artcraft_client::error::storyteller_error::StorytellerError;
|
|||
use fal_client::error::fal_error_plus::FalErrorPlus;
|
||||
use gmicloud_client::error::gmicloud_error::GmiCloudError;
|
||||
use grok_api_client::error::grok_error::GrokError;
|
||||
use higgsfield_client::error::higgsfield_error::HiggsfieldError;
|
||||
use midjourney_client::error::midjourney_error::MidjourneyError;
|
||||
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
|
||||
use worldlabs_api_client::error::world_labs_error::WorldLabsError;
|
||||
|
|
@ -18,6 +19,10 @@ pub enum ProviderError {
|
|||
Midjourney(MidjourneyError),
|
||||
/// First-party (cookie-session) Grok Imagine failed (websocket/POST).
|
||||
Grok(String),
|
||||
/// First-party (cookie-session) Higgsfield failed. Check
|
||||
/// `needs_browser_reauth()` on the inner error to know whether the user has
|
||||
/// to log in again.
|
||||
Higgsfield(HiggsfieldError),
|
||||
/// First-party Midjourney rejected the submit because the account has no
|
||||
/// active paid subscription. First-class so callers can prompt to subscribe.
|
||||
MidjourneySubscriptionRequired(String),
|
||||
|
|
@ -39,6 +44,7 @@ impl Display for ProviderError {
|
|||
Self::GrokApi(e) => write!(f, "Grok API provider error: {}", e),
|
||||
Self::Midjourney(e) => write!(f, "Midjourney provider error: {}", e),
|
||||
Self::Grok(detail) => write!(f, "Grok provider error: {}", detail),
|
||||
Self::Higgsfield(e) => write!(f, "Higgsfield provider error: {}", e),
|
||||
Self::MidjourneySubscriptionRequired(detail) => write!(f, "Midjourney subscription required: {}", detail),
|
||||
Self::MidjourneySubmitRejected(detail) => write!(f, "Midjourney submit rejected: {}", detail),
|
||||
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
|
||||
|
|
@ -71,6 +77,12 @@ impl From<GrokError> for ProviderError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<HiggsfieldError> for ProviderError {
|
||||
fn from(error: HiggsfieldError) -> Self {
|
||||
Self::Higgsfield(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MidjourneyError> for ProviderError {
|
||||
fn from(error: MidjourneyError) -> Self {
|
||||
Self::Midjourney(error)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,13 @@ use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::build
|
|||
use crate::generate::generate_image::providers::kinovi::midjourney_8::build::build_kinovi_midjourney_8;
|
||||
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::build::build_kinovi_seedream_5p0_pro;
|
||||
use crate::generate::generate_image::providers::grok::grok_imagine_image::build::build_grok_imagine_image;
|
||||
use crate::generate::generate_image::providers::higgsfield::gpt_image_2::build::build_higgsfield_gpt_image_2;
|
||||
use crate::generate::generate_image::providers::higgsfield::nano_banana_2::build::build_higgsfield_nano_banana_2;
|
||||
use crate::generate::generate_image::providers::higgsfield::nano_banana_2_lite::build::build_higgsfield_nano_banana_2_lite;
|
||||
use crate::generate::generate_image::providers::higgsfield::nano_banana_pro::build::build_higgsfield_nano_banana_pro;
|
||||
use crate::generate::generate_image::providers::higgsfield::seedream_4p5::build::build_higgsfield_seedream_4p5;
|
||||
use crate::generate::generate_image::providers::higgsfield::seedream_5p0_lite::build::build_higgsfield_seedream_5p0_lite;
|
||||
use crate::generate::generate_image::providers::higgsfield::seedream_5p0_pro::build::build_higgsfield_seedream_5p0_pro;
|
||||
use crate::generate::generate_image::providers::midjourney::midjourney_8::build::build_midjourney_midjourney_8;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -161,6 +168,14 @@ impl GenerateImageRequestBuilder {
|
|||
// First-party (cookie-session) Grok Imagine. Fast vs quality ("pro") mode.
|
||||
(RouterProvider::Grok, RouterImageModel::GrokImagineImage) => build_grok_imagine_image(self, false),
|
||||
(RouterProvider::Grok, RouterImageModel::GrokImagineImageQuality) => build_grok_imagine_image(self, true),
|
||||
// First-party (cookie-session) Higgsfield.
|
||||
(RouterProvider::Higgsfield, RouterImageModel::NanoBananaPro) => build_higgsfield_nano_banana_pro(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::NanoBanana2) => build_higgsfield_nano_banana_2(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::NanoBanana2Lite) => build_higgsfield_nano_banana_2_lite(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::GptImage2) => build_higgsfield_gpt_image_2(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::Seedream5p0Pro) => build_higgsfield_seedream_5p0_pro(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::Seedream5Lite) => build_higgsfield_seedream_5p0_lite(self),
|
||||
(RouterProvider::Higgsfield, RouterImageModel::Seedream4p5) => build_higgsfield_seedream_4p5(self),
|
||||
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,30 @@ pub struct GrokImageResponsePayload {
|
|||
pub request_id: String,
|
||||
}
|
||||
|
||||
/// Response from the first-party (cookie-session) Higgsfield provider. One
|
||||
/// enqueue creates a job set with one job per requested image; the caller
|
||||
/// polls the jobs (`HiggsfieldSession::job_status_batch`) by id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldImageResponsePayload {
|
||||
pub job_set_id: String,
|
||||
/// In generation order; never empty.
|
||||
pub job_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl HiggsfieldImageResponsePayload {
|
||||
/// The first job, which stands in for the whole set as the task's
|
||||
/// `provider_job_id`.
|
||||
pub fn primary_job_id(&self) -> &str {
|
||||
&self.job_ids[0]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GenerateImageResponse {
|
||||
Artcraft(ArtcraftImageResponsePayload),
|
||||
Fal(FalImageResponsePayload),
|
||||
Grok(GrokImageResponsePayload),
|
||||
Higgsfield(HiggsfieldImageResponsePayload),
|
||||
Midjourney(MidjourneyImageResponsePayload),
|
||||
Seedance2Pro(Seedance2proImageResponsePayload),
|
||||
}
|
||||
|
|
@ -84,6 +103,13 @@ impl GenerateImageResponse {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_higgsfield_payload(&self) -> Option<HiggsfieldImageResponsePayload> {
|
||||
match self {
|
||||
Self::Higgsfield(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_midjourney_payload(&self) -> Option<MidjourneyImageResponsePayload> {
|
||||
match self {
|
||||
Self::Midjourney(p) => Some(p.clone()),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
|||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
|
||||
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::cost::HiggsfieldImageCostState;
|
||||
use crate::generate::generate_image::providers::higgsfield::draft::HiggsfieldImageDraftState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7::draft::KinoviMidjourney7DraftState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
|
||||
|
|
@ -16,15 +18,17 @@ use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::draft:
|
|||
///
|
||||
/// Drafts hold validated/planned parameters but may still need media
|
||||
/// resolution (e.g. uploading local files to a provider) before they can
|
||||
/// be sent. Today only the Kinovi models need this — when the caller
|
||||
/// supplies reference images, we must upload them to the Seedance2Pro
|
||||
/// CDN before sending the request.
|
||||
/// be sent. The Kinovi models need this (reference images are uploaded to
|
||||
/// the Seedance2Pro CDN first), and so does first-party Higgsfield
|
||||
/// (reference images become Higgsfield media ids).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageGenerationDraftRequest {
|
||||
KinoviMidjourney7(KinoviMidjourney7DraftState),
|
||||
KinoviMidjourney7Niji(KinoviMidjourney7NijiDraftState),
|
||||
KinoviMidjourney8(KinoviMidjourney8DraftState),
|
||||
KinoviSeedream5p0Pro(KinoviSeedream5p0ProDraftState),
|
||||
/// Any Higgsfield image model (the state says which).
|
||||
HiggsfieldImage(HiggsfieldImageDraftState),
|
||||
}
|
||||
|
||||
impl ImageGenerationDraftRequest {
|
||||
|
|
@ -34,6 +38,7 @@ impl ImageGenerationDraftRequest {
|
|||
Self::KinoviMidjourney7Niji(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviMidjourney8(_) => RouterProvider::Seedance2Pro,
|
||||
Self::KinoviSeedream5p0Pro(_) => RouterProvider::Seedance2Pro,
|
||||
Self::HiggsfieldImage(_) => RouterProvider::Higgsfield,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +48,7 @@ impl ImageGenerationDraftRequest {
|
|||
Self::KinoviMidjourney7Niji(draft) => Ok(KinoviMidjourney7NijiCostState::from_draft(draft).estimate_cost()),
|
||||
Self::KinoviMidjourney8(draft) => Ok(KinoviMidjourney8CostState::from_draft(draft).estimate_cost()),
|
||||
Self::KinoviSeedream5p0Pro(draft) => Ok(KinoviSeedream5p0ProCostState::from_draft(draft).estimate_cost()),
|
||||
Self::HiggsfieldImage(draft) => Ok(HiggsfieldImageCostState::from_draft(draft).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +70,10 @@ impl ImageGenerationDraftRequest {
|
|||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(ImageGenerationRequest::KinoviSeedream5p0Pro(result))
|
||||
}
|
||||
Self::HiggsfieldImage(mut draft) => {
|
||||
let result = draft.to_request(&draft_context).await?;
|
||||
Ok(ImageGenerationRequest::HiggsfieldImage(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::fmt::Debug;
|
|||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
use crate::client::router_client::RouterClient;
|
||||
use crate::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::client_error::ClientError;
|
||||
|
|
@ -11,8 +12,9 @@ use crate::errors::client_error::ClientError;
|
|||
/// Context passed when finalizing an image draft into a concrete request.
|
||||
///
|
||||
/// Drafts only exist for providers that need to upload assets before
|
||||
/// sending the request. Today that's the Seedance2Pro/Kinovi pipeline
|
||||
/// (used for Midjourney image generation with reference images); other
|
||||
/// sending the request: the Seedance2Pro/Kinovi pipeline (used for
|
||||
/// Midjourney image generation with reference images) and first-party
|
||||
/// Higgsfield (reference images become Higgsfield media ids); other
|
||||
/// providers — Artcraft, Fal — accept image URLs directly and skip the
|
||||
/// draft phase entirely.
|
||||
#[derive(Clone, Default)]
|
||||
|
|
@ -34,6 +36,12 @@ impl<'a> ImageGenerationDraftContext<'a> {
|
|||
client.get_seedance2pro_client_ref()
|
||||
.map_err(ArtcraftRouterError::Client)
|
||||
}
|
||||
|
||||
pub fn get_higgsfield_client_ref(&self) -> Result<&RouterHiggsfieldClient, ArtcraftRouterError> {
|
||||
let client = self.client.ok_or(ArtcraftRouterError::Client(ClientError::RouterClientNotProvided))?;
|
||||
client.get_higgsfield_client_ref()
|
||||
.map_err(ArtcraftRouterError::Client)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for ImageGenerationDraftContext<'_> {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ use crate::generate::generate_image::providers::fal::seedream_5_lite::cost::FalS
|
|||
use crate::generate::generate_image::providers::fal::seedream_5_lite::request::FalSeedream5LiteRequestState;
|
||||
use crate::generate::generate_image::providers::grok::grok_imagine_image::cost::GrokImagineImageCostState;
|
||||
use crate::generate::generate_image::providers::grok::grok_imagine_image::request::GrokImagineImageRequestState;
|
||||
use crate::generate::generate_image::providers::higgsfield::cost::HiggsfieldImageCostState;
|
||||
use crate::generate::generate_image::providers::higgsfield::request::HiggsfieldImageRequestState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7::cost::KinoviMidjourney7CostState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7::request::KinoviMidjourney7RequestState;
|
||||
use crate::generate::generate_image::providers::kinovi::midjourney_7_niji::cost::KinoviMidjourney7NijiCostState;
|
||||
|
|
@ -138,6 +140,8 @@ pub enum ImageGenerationRequest {
|
|||
|
||||
// ── First-party (cookie-session) Grok Imagine provider ──
|
||||
GrokImagineImage(GrokImagineImageRequestState),
|
||||
// ── First-party (cookie-session) Higgsfield provider (any of its models) ──
|
||||
HiggsfieldImage(HiggsfieldImageRequestState),
|
||||
}
|
||||
|
||||
impl ImageGenerationRequest {
|
||||
|
|
@ -188,6 +192,7 @@ impl ImageGenerationRequest {
|
|||
Self::MidjourneyMidjourney8(_) => RouterProvider::Midjourney,
|
||||
|
||||
Self::GrokImagineImage(_) => RouterProvider::Grok,
|
||||
Self::HiggsfieldImage(_) => RouterProvider::Higgsfield,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +285,7 @@ impl ImageGenerationRequest {
|
|||
Self::MidjourneyMidjourney8(request) => Ok(MidjourneyMidjourney8CostState::from_request(request).estimate_cost()),
|
||||
|
||||
Self::GrokImagineImage(request) => Ok(GrokImagineImageCostState::from_request(request).estimate_cost()),
|
||||
Self::HiggsfieldImage(request) => Ok(HiggsfieldImageCostState::from_request(request).estimate_cost()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -469,6 +475,12 @@ impl ImageGenerationRequest {
|
|||
.map_err(ArtcraftRouterError::Client)?;
|
||||
request.send(grok_client).await
|
||||
}
|
||||
// ── First-party (cookie-session) Higgsfield ──
|
||||
Self::HiggsfieldImage(request) => {
|
||||
let higgsfield_client = client.get_higgsfield_client_ref()
|
||||
.map_err(ArtcraftRouterError::Client)?;
|
||||
request.send(higgsfield_client).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,424 @@
|
|||
//! Planning helpers shared by every Higgsfield image model: prompt and batch
|
||||
//! validation, and "nearest supported option" snapping for resolutions and
|
||||
//! aspect ratios.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::gpt_image_2::GptImage2AspectRatio;
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
use higgsfield_client::types::nano_banana_aspect_ratio::NanoBananaAspectRatio;
|
||||
use higgsfield_client::types::seedream_aspect_ratio::SeedreamAspectRatio;
|
||||
use log::warn;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
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_image::image_generation_draft::ImageGenerationDraftRequest;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::draft::{HiggsfieldImageDraftState, HiggsfieldImageRemainingItems};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::request::HiggsfieldImageRequestState;
|
||||
|
||||
/// Higgsfield needs a text prompt for every image model.
|
||||
pub(crate) fn require_prompt(prompt: Option<String>) -> Result<String, ArtcraftRouterError> {
|
||||
let prompt = prompt.unwrap_or_default().trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(ArtcraftRouterError::InvalidInput("Higgsfield needs a text prompt".to_string()));
|
||||
}
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
pub(crate) fn image_input_count(image_inputs: Option<&ImageListRef>) -> usize {
|
||||
match image_inputs {
|
||||
None => 0,
|
||||
Some(ImageListRef::Urls(urls)) => urls.len(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject over-limit reference lists before anything is uploaded.
|
||||
pub(crate) fn check_reference_limit(count: usize, max: usize, model: &str) -> Result<(), ArtcraftRouterError> {
|
||||
if count > max {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: format!("{count} reference images ({model} on Higgsfield takes at most {max})"),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every Higgsfield image model renders 1–4 images per request. Larger
|
||||
/// batches clamp to 4 (or error out under `ErrorOut`).
|
||||
pub(crate) fn plan_batch_size(
|
||||
image_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<ImageBatchSize, ArtcraftRouterError> {
|
||||
let count = image_batch_count.unwrap_or(1);
|
||||
if count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
|
||||
}
|
||||
if let Some(batch_size) = ImageBatchSize::from_u32(count as u32) {
|
||||
return Ok(batch_size);
|
||||
}
|
||||
match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_batch_count",
|
||||
value: format!("{count} (Higgsfield renders at most {} per request)", ImageBatchSize::MAX),
|
||||
})),
|
||||
_ => {
|
||||
warn!("Higgsfield renders at most {} images per request; clamping {}", ImageBatchSize::MAX, count);
|
||||
Ok(ImageBatchSize::Four)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A total order over resolutions so tiers can be compared across the
|
||||
/// image (`*K`) and video (`*p`) vocabularies.
|
||||
pub(crate) fn resolution_rank(resolution: RouterResolution) -> u8 {
|
||||
match resolution {
|
||||
RouterResolution::HalfK => 0,
|
||||
RouterResolution::FourEightyP => 1,
|
||||
RouterResolution::SevenTwentyP => 2,
|
||||
RouterResolution::OneK => 3,
|
||||
RouterResolution::TenEightyP => 4,
|
||||
RouterResolution::TwoK => 5,
|
||||
RouterResolution::ThreeK => 6,
|
||||
RouterResolution::FourK => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the tier for a requested resolution from a model's supported
|
||||
/// `tiers` (ascending). An exact match wins; otherwise `PayMoreUpgrade`
|
||||
/// takes the next tier up (or the top one), `PayLessDowngrade` the next tier
|
||||
/// down (or the bottom one), and `ErrorOut` refuses. `None` picks `default`.
|
||||
pub(crate) fn snap_resolution<T: Copy>(
|
||||
requested: Option<RouterResolution>,
|
||||
tiers: &[(RouterResolution, T)],
|
||||
default: T,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<T, ArtcraftRouterError> {
|
||||
debug_assert!(tiers.windows(2).all(|pair| resolution_rank(pair[0].0) < resolution_rank(pair[1].0)), "tiers must ascend");
|
||||
let Some(requested) = requested else {
|
||||
return Ok(default);
|
||||
};
|
||||
let rank = resolution_rank(requested);
|
||||
if let Some((_, tier)) = tiers.iter().find(|(supported, _)| resolution_rank(*supported) == rank) {
|
||||
return Ok(*tier);
|
||||
}
|
||||
let snapped = match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "resolution",
|
||||
value: format!("{requested:?}"),
|
||||
}));
|
||||
}
|
||||
RequestMismatchMitigationStrategy::PayMoreUpgrade => tiers.iter()
|
||||
.find(|(supported, _)| resolution_rank(*supported) > rank)
|
||||
.or_else(|| tiers.last()),
|
||||
RequestMismatchMitigationStrategy::PayLessDowngrade => tiers.iter()
|
||||
.rev()
|
||||
.find(|(supported, _)| resolution_rank(*supported) < rank)
|
||||
.or_else(|| tiers.first()),
|
||||
};
|
||||
let (snapped_resolution, tier) = snapped.expect("a model offers at least one resolution tier");
|
||||
warn!("Higgsfield does not offer {:?} for this model; using {:?} ({:?})", requested, snapped_resolution, strategy);
|
||||
Ok(*tier)
|
||||
}
|
||||
|
||||
/// A ratio's numeric value (width / height). `None` for the auto values;
|
||||
/// the loose `Wide` / `Tall` read as 16:9 / 9:16.
|
||||
pub(crate) fn aspect_ratio_value(aspect_ratio: RouterAspectRatio) -> Option<f64> {
|
||||
let value = match aspect_ratio {
|
||||
RouterAspectRatio::Auto
|
||||
| RouterAspectRatio::Auto2k
|
||||
| RouterAspectRatio::Auto3k
|
||||
| RouterAspectRatio::Auto4k => return None,
|
||||
RouterAspectRatio::Square | RouterAspectRatio::SquareHd => 1.0,
|
||||
RouterAspectRatio::WideThreeByTwo => 3.0 / 2.0,
|
||||
RouterAspectRatio::WideFourByThree => 4.0 / 3.0,
|
||||
RouterAspectRatio::WideFiveByFour => 5.0 / 4.0,
|
||||
RouterAspectRatio::WideSixteenByNine | RouterAspectRatio::Wide => 16.0 / 9.0,
|
||||
RouterAspectRatio::WideTwentyOneByNine => 21.0 / 9.0,
|
||||
RouterAspectRatio::TallTwoByThree => 2.0 / 3.0,
|
||||
RouterAspectRatio::TallThreeByFour => 3.0 / 4.0,
|
||||
RouterAspectRatio::TallFourByFive => 4.0 / 5.0,
|
||||
RouterAspectRatio::TallNineBySixteen | RouterAspectRatio::Tall => 9.0 / 16.0,
|
||||
RouterAspectRatio::TallNineByTwentyOne => 9.0 / 21.0,
|
||||
};
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// The candidate whose numeric ratio is closest to `value` (ties go to the
|
||||
/// earlier candidate).
|
||||
pub(crate) fn nearest_aspect_ratio<T: Copy>(value: f64, candidates: &[(f64, T)]) -> T {
|
||||
let mut best = candidates[0];
|
||||
for candidate in candidates {
|
||||
if (candidate.0 - value).abs() < (best.0 - value).abs() - f64::EPSILON {
|
||||
best = *candidate;
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// The Nano Banana aspect menu (Pro / 2 / 2 Lite): every named ratio plus
|
||||
/// Auto. With reference images Auto follows the reference, so an unset ratio
|
||||
/// means Auto when editing and 16:9 for text-to-image.
|
||||
pub(crate) fn plan_nano_banana_aspect_ratio(requested: Option<RouterAspectRatio>, editing: bool) -> NanoBananaAspectRatio {
|
||||
use NanoBananaAspectRatio as Ar;
|
||||
const CANDIDATES: &[(f64, Ar)] = &[
|
||||
(1.0, Ar::Square1x1),
|
||||
(3.0 / 4.0, Ar::Portrait3x4),
|
||||
(4.0 / 3.0, Ar::Landscape4x3),
|
||||
(2.0 / 3.0, Ar::Portrait2x3),
|
||||
(3.0 / 2.0, Ar::Landscape3x2),
|
||||
(9.0 / 16.0, Ar::Portrait9x16),
|
||||
(16.0 / 9.0, Ar::Landscape16x9),
|
||||
(5.0 / 4.0, Ar::Landscape5x4),
|
||||
(4.0 / 5.0, Ar::Portrait4x5),
|
||||
(21.0 / 9.0, Ar::Landscape21x9),
|
||||
];
|
||||
match requested {
|
||||
None if editing => Ar::Auto,
|
||||
None => Ar::Landscape16x9,
|
||||
Some(ratio) => match aspect_ratio_value(ratio) {
|
||||
None => Ar::Auto,
|
||||
Some(value) => nearest_aspect_ratio(value, CANDIDATES),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// GPT Image 2's aspect menu: like Nano Banana's but without 5:4 / 4:5.
|
||||
pub(crate) fn plan_gpt_image_2_aspect_ratio(requested: Option<RouterAspectRatio>, editing: bool) -> GptImage2AspectRatio {
|
||||
use GptImage2AspectRatio as Ar;
|
||||
const CANDIDATES: &[(f64, Ar)] = &[
|
||||
(1.0, Ar::Square1x1),
|
||||
(3.0 / 2.0, Ar::Landscape3x2),
|
||||
(2.0 / 3.0, Ar::Portrait2x3),
|
||||
(16.0 / 9.0, Ar::Landscape16x9),
|
||||
(9.0 / 16.0, Ar::Portrait9x16),
|
||||
(4.0 / 3.0, Ar::Landscape4x3),
|
||||
(3.0 / 4.0, Ar::Portrait3x4),
|
||||
(21.0 / 9.0, Ar::Landscape21x9),
|
||||
];
|
||||
match requested {
|
||||
None if editing => Ar::Auto,
|
||||
None => Ar::Landscape16x9,
|
||||
Some(ratio) => match aspect_ratio_value(ratio) {
|
||||
None => Ar::Auto,
|
||||
Some(value) => nearest_aspect_ratio(value, CANDIDATES),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The Seedream menu has no Auto: unset and auto ratios fall back to
|
||||
/// `default`, everything else snaps to the nearest of its eight ratios.
|
||||
pub(crate) fn plan_seedream_aspect_ratio(requested: Option<RouterAspectRatio>, default: SeedreamAspectRatio) -> SeedreamAspectRatio {
|
||||
use SeedreamAspectRatio as Ar;
|
||||
const CANDIDATES: &[(f64, Ar)] = &[
|
||||
(1.0, Ar::Square1x1),
|
||||
(4.0 / 3.0, Ar::Landscape4x3),
|
||||
(3.0 / 4.0, Ar::Portrait3x4),
|
||||
(16.0 / 9.0, Ar::Landscape16x9),
|
||||
(21.0 / 9.0, Ar::Landscape21x9),
|
||||
(9.0 / 16.0, Ar::Portrait9x16),
|
||||
(2.0 / 3.0, Ar::Portrait2x3),
|
||||
(3.0 / 2.0, Ar::Landscape3x2),
|
||||
];
|
||||
match requested.and_then(aspect_ratio_value) {
|
||||
None => default,
|
||||
Some(value) => nearest_aspect_ratio(value, CANDIDATES),
|
||||
}
|
||||
}
|
||||
|
||||
/// ArtCraft's Seedream models bake a resolution into their "auto" ratios
|
||||
/// (`auto_2k`, ...). Higgsfield has a real resolution menu, so when no
|
||||
/// resolution was given the baked-in one becomes the request.
|
||||
pub(crate) fn seedream_auto_resolution_hint(requested: Option<RouterAspectRatio>) -> Option<RouterResolution> {
|
||||
match requested? {
|
||||
RouterAspectRatio::Auto2k => Some(RouterResolution::TwoK),
|
||||
RouterAspectRatio::Auto3k => Some(RouterResolution::ThreeK),
|
||||
RouterAspectRatio::Auto4k => Some(RouterResolution::FourK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a planned request: with reference images it's a draft (they still
|
||||
/// have to be uploaded), otherwise it's ready to send.
|
||||
pub(crate) fn finish(request: HiggsfieldImageRequest, image_inputs: Option<ImageListRef>) -> ImageGenerationDraftOrRequest {
|
||||
if image_input_count(image_inputs.as_ref()) == 0 {
|
||||
return ImageGenerationDraftOrRequest::Request(
|
||||
ImageGenerationRequest::HiggsfieldImage(HiggsfieldImageRequestState { request }),
|
||||
);
|
||||
}
|
||||
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::HiggsfieldImage(HiggsfieldImageDraftState {
|
||||
request,
|
||||
unhandled_request_state: Some(HiggsfieldImageRemainingItems { reference_images: image_inputs }),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_pro::NanoBananaProResolution;
|
||||
|
||||
const NANO_BANANA_PRO_TIERS: &[(RouterResolution, NanoBananaProResolution)] = &[
|
||||
(RouterResolution::OneK, NanoBananaProResolution::OneK),
|
||||
(RouterResolution::TwoK, NanoBananaProResolution::TwoK),
|
||||
(RouterResolution::FourK, NanoBananaProResolution::FourK),
|
||||
];
|
||||
|
||||
mod prompts_and_batches {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_is_trimmed_and_required() {
|
||||
assert_eq!(require_prompt(Some(" a cat ".to_string())).unwrap(), "a cat");
|
||||
assert!(matches!(require_prompt(Some(" ".to_string())), Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
assert!(matches!(require_prompt(None), Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_sizes_clamp_to_four() {
|
||||
assert_eq!(plan_batch_size(None, RequestMismatchMitigationStrategy::ErrorOut).unwrap(), ImageBatchSize::One);
|
||||
assert_eq!(plan_batch_size(Some(3), RequestMismatchMitigationStrategy::ErrorOut).unwrap(), ImageBatchSize::Three);
|
||||
assert_eq!(plan_batch_size(Some(8), RequestMismatchMitigationStrategy::PayMoreUpgrade).unwrap(), ImageBatchSize::Four);
|
||||
assert_eq!(plan_batch_size(Some(8), RequestMismatchMitigationStrategy::PayLessDowngrade).unwrap(), ImageBatchSize::Four);
|
||||
assert!(matches!(
|
||||
plan_batch_size(Some(8), RequestMismatchMitigationStrategy::ErrorOut),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })),
|
||||
));
|
||||
assert!(matches!(
|
||||
plan_batch_size(Some(0), RequestMismatchMitigationStrategy::PayMoreUpgrade),
|
||||
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_limits() {
|
||||
assert!(check_reference_limit(4, 4, "x").is_ok());
|
||||
assert!(matches!(
|
||||
check_reference_limit(5, 4, "x"),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mod resolution_snapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_tiers_pass_through_under_every_strategy() {
|
||||
for strategy in [RequestMismatchMitigationStrategy::ErrorOut, RequestMismatchMitigationStrategy::PayMoreUpgrade, RequestMismatchMitigationStrategy::PayLessDowngrade] {
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::TwoK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::TwoK);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_uses_the_default() {
|
||||
assert_eq!(snap_resolution(None, NANO_BANANA_PRO_TIERS, NanoBananaProResolution::TwoK, RequestMismatchMitigationStrategy::ErrorOut).unwrap(), NanoBananaProResolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_takes_the_next_tier_up_or_the_top() {
|
||||
let strategy = RequestMismatchMitigationStrategy::PayMoreUpgrade;
|
||||
// 3K sits between 2K and 4K → 4K.
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::ThreeK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::FourK);
|
||||
// Sub-1K tiers → 1K.
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::HalfK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::OneK);
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::SevenTwentyP), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::OneK);
|
||||
// 1080p sits between 1K and 2K → 2K.
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::TenEightyP), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downgrade_takes_the_next_tier_down_or_the_bottom() {
|
||||
let strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::ThreeK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::TwoK);
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::HalfK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::OneK);
|
||||
assert_eq!(snap_resolution(Some(RouterResolution::TenEightyP), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, strategy).unwrap(), NanoBananaProResolution::OneK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_out_refuses_unsupported_tiers() {
|
||||
let err = snap_resolution(Some(RouterResolution::ThreeK), NANO_BANANA_PRO_TIERS, NanoBananaProResolution::OneK, RequestMismatchMitigationStrategy::ErrorOut).unwrap_err();
|
||||
assert!(matches!(err, ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "resolution", .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_interleave_image_and_video_tiers() {
|
||||
let ordered = [
|
||||
RouterResolution::HalfK, RouterResolution::FourEightyP, RouterResolution::SevenTwentyP, RouterResolution::OneK,
|
||||
RouterResolution::TenEightyP, RouterResolution::TwoK, RouterResolution::ThreeK, RouterResolution::FourK,
|
||||
];
|
||||
assert!(ordered.windows(2).all(|pair| resolution_rank(pair[0]) < resolution_rank(pair[1])));
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratio_snapping {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nano_banana_maps_every_router_ratio() {
|
||||
use NanoBananaAspectRatio as Ar;
|
||||
let cases = [
|
||||
(RouterAspectRatio::Square, Ar::Square1x1),
|
||||
(RouterAspectRatio::SquareHd, Ar::Square1x1),
|
||||
(RouterAspectRatio::WideThreeByTwo, Ar::Landscape3x2),
|
||||
(RouterAspectRatio::WideFourByThree, Ar::Landscape4x3),
|
||||
(RouterAspectRatio::WideFiveByFour, Ar::Landscape5x4),
|
||||
(RouterAspectRatio::WideSixteenByNine, Ar::Landscape16x9),
|
||||
(RouterAspectRatio::Wide, Ar::Landscape16x9),
|
||||
(RouterAspectRatio::WideTwentyOneByNine, Ar::Landscape21x9),
|
||||
(RouterAspectRatio::TallTwoByThree, Ar::Portrait2x3),
|
||||
(RouterAspectRatio::TallThreeByFour, Ar::Portrait3x4),
|
||||
(RouterAspectRatio::TallFourByFive, Ar::Portrait4x5),
|
||||
(RouterAspectRatio::TallNineBySixteen, Ar::Portrait9x16),
|
||||
(RouterAspectRatio::Tall, Ar::Portrait9x16),
|
||||
// 9:21 isn't offered; 9:16 is the closest.
|
||||
(RouterAspectRatio::TallNineByTwentyOne, Ar::Portrait9x16),
|
||||
(RouterAspectRatio::Auto, Ar::Auto),
|
||||
(RouterAspectRatio::Auto2k, Ar::Auto),
|
||||
];
|
||||
for (requested, expected) in cases {
|
||||
assert_eq!(plan_nano_banana_aspect_ratio(Some(requested), false), expected, "{requested:?}");
|
||||
}
|
||||
assert_eq!(plan_nano_banana_aspect_ratio(None, false), Ar::Landscape16x9);
|
||||
assert_eq!(plan_nano_banana_aspect_ratio(None, true), Ar::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_image_2_snaps_the_missing_ratios() {
|
||||
use GptImage2AspectRatio as Ar;
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(Some(RouterAspectRatio::WideFiveByFour), false), Ar::Landscape4x3);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(Some(RouterAspectRatio::TallFourByFive), false), Ar::Portrait3x4);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(Some(RouterAspectRatio::SquareHd), false), Ar::Square1x1);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(Some(RouterAspectRatio::WideTwentyOneByNine), false), Ar::Landscape21x9);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(Some(RouterAspectRatio::Auto), false), Ar::Auto);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(None, true), Ar::Auto);
|
||||
assert_eq!(plan_gpt_image_2_aspect_ratio(None, false), Ar::Landscape16x9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedream_has_no_auto_and_snaps_the_rest() {
|
||||
use SeedreamAspectRatio as Ar;
|
||||
assert_eq!(plan_seedream_aspect_ratio(None, Ar::Square1x1), Ar::Square1x1);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::Auto), Ar::Landscape16x9), Ar::Landscape16x9);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::Auto4k), Ar::Landscape16x9), Ar::Landscape16x9);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::WideFiveByFour), Ar::Square1x1), Ar::Landscape4x3);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::TallFourByFive), Ar::Square1x1), Ar::Portrait3x4);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::TallNineByTwentyOne), Ar::Square1x1), Ar::Portrait9x16);
|
||||
assert_eq!(plan_seedream_aspect_ratio(Some(RouterAspectRatio::WideThreeByTwo), Ar::Square1x1), Ar::Landscape3x2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seedream_auto_ratios_carry_a_resolution() {
|
||||
assert!(matches!(seedream_auto_resolution_hint(Some(RouterAspectRatio::Auto2k)), Some(RouterResolution::TwoK)));
|
||||
assert!(matches!(seedream_auto_resolution_hint(Some(RouterAspectRatio::Auto3k)), Some(RouterResolution::ThreeK)));
|
||||
assert!(matches!(seedream_auto_resolution_hint(Some(RouterAspectRatio::Auto4k)), Some(RouterResolution::FourK)));
|
||||
assert!(seedream_auto_resolution_hint(Some(RouterAspectRatio::Auto)).is_none());
|
||||
assert!(seedream_auto_resolution_hint(None).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
//! Test-only helpers shared by the per-model `build.rs` tests.
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft::ImageGenerationDraftRequest;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::draft::HiggsfieldImageDraftState;
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub fn base_builder(model: RouterImageModel) -> GenerateImageRequestBuilder {
|
||||
GenerateImageRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Higgsfield,
|
||||
prompt: Some("a shiba inu doing a kickflip".to_string()),
|
||||
image_inputs: None,
|
||||
resolution: None,
|
||||
aspect_ratio: None,
|
||||
quality: None,
|
||||
image_batch_count: None,
|
||||
horizontal_angle: None,
|
||||
vertical_angle: None,
|
||||
zoom: None,
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
generation_mode_mismatch_strategy: None,
|
||||
idempotency_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_references(mut builder: GenerateImageRequestBuilder, count: usize) -> GenerateImageRequestBuilder {
|
||||
let urls = (0..count).map(|i| format!("https://cdn.example.com/ref_{i}.png")).collect();
|
||||
builder.image_inputs = Some(ImageListRef::Urls(urls));
|
||||
builder
|
||||
}
|
||||
|
||||
/// The planned request out of a direct (no references) build.
|
||||
pub fn unwrap_request(result: ImageGenerationDraftOrRequest) -> HiggsfieldImageRequest {
|
||||
match result {
|
||||
ImageGenerationDraftOrRequest::Request(ImageGenerationRequest::HiggsfieldImage(state)) => state.request,
|
||||
other => panic!("expected a direct Higgsfield request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft out of a build with references.
|
||||
pub fn unwrap_draft(result: ImageGenerationDraftOrRequest) -> HiggsfieldImageDraftState {
|
||||
match result {
|
||||
ImageGenerationDraftOrRequest::Draft(ImageGenerationDraftRequest::HiggsfieldImage(draft)) => draft,
|
||||
other => panic!("expected a Higgsfield draft, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
|
||||
use crate::generate::generate_image::providers::higgsfield::draft::HiggsfieldImageDraftState;
|
||||
use crate::generate::generate_image::providers::higgsfield::request::HiggsfieldImageRequestState;
|
||||
|
||||
/// Cost state for first-party (cookie-session) Higgsfield image generation.
|
||||
///
|
||||
/// This runs on the USER'S OWN Higgsfield account (its credits or unlimited
|
||||
/// plan), so it consumes no ArtCraft credits. Reported as free / unlimited
|
||||
/// like the other first-party providers; Higgsfield's own credit pricing
|
||||
/// isn't modelled here.
|
||||
pub struct HiggsfieldImageCostState;
|
||||
|
||||
impl HiggsfieldImageCostState {
|
||||
pub fn from_request(_request: &HiggsfieldImageRequestState) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn from_draft(_draft: &HiggsfieldImageDraftState) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
|
||||
ImageGenerationCostEstimate {
|
||||
cost_in_credits: Some(0),
|
||||
cost_in_usd_cents: None,
|
||||
is_free: true,
|
||||
is_unlimited: true,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
use log::info;
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::image_generation_draft_context::ImageGenerationDraftContext;
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::request::HiggsfieldImageRequestState;
|
||||
use crate::utils::higgsfield_media::{upload_media_list, HiggsfieldMediaKind};
|
||||
|
||||
/// A planned Higgsfield image request whose reference images still have to
|
||||
/// be uploaded. `to_request` does the uploads and produces the sendable state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldImageDraftState {
|
||||
/// Everything but the references, already snapped to Higgsfield's menus.
|
||||
pub request: HiggsfieldImageRequest,
|
||||
pub unhandled_request_state: Option<HiggsfieldImageRemainingItems>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldImageRemainingItems {
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
}
|
||||
|
||||
impl HiggsfieldImageDraftState {
|
||||
pub fn reference_image_count(&self) -> usize {
|
||||
match self.unhandled_request_state.as_ref().and_then(|remaining| remaining.reference_images.as_ref()) {
|
||||
None => 0,
|
||||
Some(ImageListRef::Urls(urls)) => urls.len(),
|
||||
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &ImageGenerationDraftContext<'_>,
|
||||
) -> Result<HiggsfieldImageRequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_higgsfield_client_ref()?;
|
||||
let session = &client.session;
|
||||
|
||||
let mut reference_images = Vec::new();
|
||||
if let Some(remaining) = self.unhandled_request_state.take() {
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
// The image generator's references skip the IP check (as the web app
|
||||
// does); only the Seedance video models insist on it.
|
||||
reference_images = upload_media_list(
|
||||
session,
|
||||
remaining.reference_images.map(Into::into),
|
||||
HiggsfieldMediaKind::Image,
|
||||
false,
|
||||
map,
|
||||
).await?;
|
||||
info!("Uploaded {} reference image(s) to Higgsfield for {}", reference_images.len(), self.request.model_label());
|
||||
}
|
||||
|
||||
Ok(HiggsfieldImageRequestState {
|
||||
request: self.request.clone().with_reference_images(reference_images),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
//! GPT Image 2 on Higgsfield: 9 aspect ratios (incl. Auto; no 5:4 / 4:5,
|
||||
//! no "1:1 HD"), Low / Medium / High quality, 1K / 2K / 4K (no 3K), 1–4
|
||||
//! images, up to 6 reference images.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::gpt_image_2::{GptImage2Quality, GptImage2Request, GptImage2Resolution};
|
||||
|
||||
use crate::api::router_quality::RouterQuality;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_gpt_image_2_aspect_ratio, require_prompt, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 6;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, GptImage2Resolution)] = &[
|
||||
(RouterResolution::OneK, GptImage2Resolution::OneK),
|
||||
(RouterResolution::TwoK, GptImage2Resolution::TwoK),
|
||||
(RouterResolution::FourK, GptImage2Resolution::FourK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_gpt_image_2(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
let reference_count = image_input_count(image_inputs.as_ref());
|
||||
check_reference_limit(reference_count, MAX_REFERENCE_IMAGES, "GPT Image 2")?;
|
||||
|
||||
let aspect_ratio = plan_gpt_image_2_aspect_ratio(builder.aspect_ratio.take(), reference_count > 0);
|
||||
let quality = plan_quality(builder.quality.take());
|
||||
let resolution = snap_resolution(builder.resolution.take(), RESOLUTION_TIERS, GptImage2Resolution::OneK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = GptImage2Request::text_to_image(prompt, aspect_ratio, quality, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::GptImage2(request), image_inputs))
|
||||
}
|
||||
|
||||
/// The three tiers line up; unset means the web app's default (Medium).
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> GptImage2Quality {
|
||||
match quality {
|
||||
None | Some(RouterQuality::Medium) => GptImage2Quality::Medium,
|
||||
Some(RouterQuality::High) => GptImage2Quality::High,
|
||||
Some(RouterQuality::Low) => GptImage2Quality::Low,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request};
|
||||
use higgsfield_client::endpoints::generate::image::gpt_image_2::GptImage2AspectRatio;
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> GptImage2Request {
|
||||
match unwrap_request(build_higgsfield_gpt_image_2(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::GptImage2(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_k_snaps_up_or_down() {
|
||||
let mut b = base_builder(RouterImageModel::GptImage2);
|
||||
b.resolution = Some(RouterResolution::ThreeK);
|
||||
assert_eq!(built(b.clone()).resolution, GptImage2Resolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert_eq!(built(b).resolution, GptImage2Resolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_and_missing_ratios() {
|
||||
let mut b = base_builder(RouterImageModel::GptImage2);
|
||||
b.quality = Some(RouterQuality::Low);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::SquareHd);
|
||||
let request = built(b);
|
||||
assert_eq!(request.quality, GptImage2Quality::Low);
|
||||
assert_eq!(request.aspect_ratio, GptImage2AspectRatio::Square1x1);
|
||||
assert_eq!(plan_quality(None), GptImage2Quality::Medium);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
//! One planned Higgsfield image request, whichever model it's for. The
|
||||
//! per-model `build.rs` files produce these; the shared draft / request /
|
||||
//! cost states carry them.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::gpt_image_2::GptImage2Request;
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_2::NanoBanana2Request;
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_2_lite::NanoBanana2LiteRequest;
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_pro::NanoBananaProRequest;
|
||||
use higgsfield_client::endpoints::generate::image::seedream_4p5::Seedream4p5Request;
|
||||
use higgsfield_client::endpoints::generate::image::seedream_5p0_lite::Seedream5p0LiteRequest;
|
||||
use higgsfield_client::endpoints::generate::image::seedream_5p0_pro::Seedream5p0ProRequest;
|
||||
use higgsfield_client::error::higgsfield_error::HiggsfieldError;
|
||||
use higgsfield_client::session::higgsfield_session::HiggsfieldSession;
|
||||
use higgsfield_client::types::enqueue_jobs_response::EnqueueJobsResponse;
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
use higgsfield_client::types::media_input::MediaInput;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HiggsfieldImageRequest {
|
||||
NanoBananaPro(NanoBananaProRequest),
|
||||
NanoBanana2(NanoBanana2Request),
|
||||
NanoBanana2Lite(NanoBanana2LiteRequest),
|
||||
GptImage2(GptImage2Request),
|
||||
Seedream5p0Pro(Seedream5p0ProRequest),
|
||||
Seedream5p0Lite(Seedream5p0LiteRequest),
|
||||
Seedream4p5(Seedream4p5Request),
|
||||
}
|
||||
|
||||
impl HiggsfieldImageRequest {
|
||||
/// Enqueue on the session (which mints the bearer token and retries once
|
||||
/// on a rejected token).
|
||||
pub async fn send(&self, session: &HiggsfieldSession) -> Result<EnqueueJobsResponse, HiggsfieldError> {
|
||||
match self {
|
||||
Self::NanoBananaPro(request) => session.nano_banana_pro(request.clone()).await,
|
||||
Self::NanoBanana2(request) => session.nano_banana_2(request.clone()).await,
|
||||
Self::NanoBanana2Lite(request) => session.nano_banana_2_lite(request.clone()).await,
|
||||
Self::GptImage2(request) => session.gpt_image_2(request.clone()).await,
|
||||
Self::Seedream5p0Pro(request) => session.seedream_5p0_pro(request.clone()).await,
|
||||
Self::Seedream5p0Lite(request) => session.seedream_5p0_lite(request.clone()).await,
|
||||
Self::Seedream4p5(request) => session.seedream_4p5(request.clone()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach uploaded reference images (image-to-image).
|
||||
pub fn with_reference_images(self, reference_images: Vec<MediaInput>) -> Self {
|
||||
match self {
|
||||
Self::NanoBananaPro(request) => Self::NanoBananaPro(request.with_reference_images(reference_images)),
|
||||
Self::NanoBanana2(request) => Self::NanoBanana2(request.with_reference_images(reference_images)),
|
||||
Self::NanoBanana2Lite(request) => Self::NanoBanana2Lite(request.with_reference_images(reference_images)),
|
||||
Self::GptImage2(request) => Self::GptImage2(request.with_reference_images(reference_images)),
|
||||
Self::Seedream5p0Pro(request) => Self::Seedream5p0Pro(request.with_reference_images(reference_images)),
|
||||
Self::Seedream5p0Lite(request) => Self::Seedream5p0Lite(request.with_reference_images(reference_images)),
|
||||
Self::Seedream4p5(request) => Self::Seedream4p5(request.with_reference_images(reference_images)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> &str {
|
||||
match self {
|
||||
Self::NanoBananaPro(request) => &request.prompt,
|
||||
Self::NanoBanana2(request) => &request.prompt,
|
||||
Self::NanoBanana2Lite(request) => &request.prompt,
|
||||
Self::GptImage2(request) => &request.prompt,
|
||||
Self::Seedream5p0Pro(request) => &request.prompt,
|
||||
Self::Seedream5p0Lite(request) => &request.prompt,
|
||||
Self::Seedream4p5(request) => &request.prompt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_size(&self) -> ImageBatchSize {
|
||||
match self {
|
||||
Self::NanoBananaPro(request) => request.batch_size,
|
||||
Self::NanoBanana2(request) => request.batch_size,
|
||||
Self::NanoBanana2Lite(request) => request.batch_size,
|
||||
Self::GptImage2(request) => request.batch_size,
|
||||
Self::Seedream5p0Pro(request) => request.batch_size,
|
||||
Self::Seedream5p0Lite(request) => request.batch_size,
|
||||
Self::Seedream4p5(request) => request.batch_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reference_images(&self) -> &[MediaInput] {
|
||||
match self {
|
||||
Self::NanoBananaPro(request) => &request.reference_images,
|
||||
Self::NanoBanana2(request) => &request.reference_images,
|
||||
Self::NanoBanana2Lite(request) => &request.reference_images,
|
||||
Self::GptImage2(request) => &request.reference_images,
|
||||
Self::Seedream5p0Pro(request) => &request.reference_images,
|
||||
Self::Seedream5p0Lite(request) => &request.reference_images,
|
||||
Self::Seedream4p5(request) => &request.reference_images,
|
||||
}
|
||||
}
|
||||
|
||||
/// A short label for logs.
|
||||
pub fn model_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NanoBananaPro(_) => "Nano Banana Pro",
|
||||
Self::NanoBanana2(_) => "Nano Banana 2",
|
||||
Self::NanoBanana2Lite(_) => "Nano Banana 2 Lite",
|
||||
Self::GptImage2(_) => "GPT Image 2",
|
||||
Self::Seedream5p0Pro(_) => "Seedream 5.0 Pro",
|
||||
Self::Seedream5p0Lite(_) => "Seedream 5 Lite",
|
||||
Self::Seedream4p5(_) => "Seedream 4.5",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
//! First-party (cookie-session) Higgsfield image generation.
|
||||
//!
|
||||
//! Runs on the user's own higgsfield.ai account through a
|
||||
//! [`HiggsfieldSession`](higgsfield_client::session::higgsfield_session::HiggsfieldSession).
|
||||
//! Reference images have to live on Higgsfield first, so a request with
|
||||
//! image inputs goes through the draft phase: `finalize()` downloads each
|
||||
//! reference (ArtCraft CDN URL or media token) and uploads it as Higgsfield
|
||||
//! reference media, then the request goes out with the resulting media ids.
|
||||
//!
|
||||
//! Each model has its own `build.rs` that plans the request against the
|
||||
//! option menus Higgsfield actually offers — unsupported resolutions,
|
||||
//! ratios and batch sizes snap to the nearest tier per the mismatch strategy.
|
||||
//! The plumbing after that (draft, upload, send, cost) is shared across all
|
||||
//! models via [`image_request::HiggsfieldImageRequest`].
|
||||
|
||||
pub mod common;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod common_test_helpers;
|
||||
pub mod cost;
|
||||
pub mod draft;
|
||||
pub mod gpt_image_2;
|
||||
pub mod image_request;
|
||||
pub mod nano_banana_2;
|
||||
pub mod nano_banana_2_lite;
|
||||
pub mod nano_banana_pro;
|
||||
pub mod request;
|
||||
pub mod seedream_4p5;
|
||||
pub mod seedream_5p0_lite;
|
||||
pub mod seedream_5p0_pro;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
//! Nano Banana 2 on Higgsfield: 11 aspect ratios (incl. Auto), 1K / 2K / 4K
|
||||
//! (no 0.5K tier), 1–4 images, up to 6 reference images.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_2::{NanoBanana2Request, NanoBanana2Resolution};
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_nano_banana_aspect_ratio, require_prompt, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 6;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, NanoBanana2Resolution)] = &[
|
||||
(RouterResolution::OneK, NanoBanana2Resolution::OneK),
|
||||
(RouterResolution::TwoK, NanoBanana2Resolution::TwoK),
|
||||
(RouterResolution::FourK, NanoBanana2Resolution::FourK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_nano_banana_2(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
let reference_count = image_input_count(image_inputs.as_ref());
|
||||
check_reference_limit(reference_count, MAX_REFERENCE_IMAGES, "Nano Banana 2")?;
|
||||
|
||||
let aspect_ratio = plan_nano_banana_aspect_ratio(builder.aspect_ratio.take(), reference_count > 0);
|
||||
let resolution = snap_resolution(builder.resolution.take(), RESOLUTION_TIERS, NanoBanana2Resolution::OneK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = NanoBanana2Request::text_to_image(prompt, aspect_ratio, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::NanoBanana2(request), image_inputs))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request, with_references};
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> NanoBanana2Request {
|
||||
match unwrap_request(build_higgsfield_nano_banana_2(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::NanoBanana2(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_k_is_not_offered_and_rounds_to_one_k() {
|
||||
let mut b = base_builder(RouterImageModel::NanoBanana2);
|
||||
b.resolution = Some(RouterResolution::HalfK);
|
||||
assert_eq!(built(b.clone()).resolution, NanoBanana2Resolution::OneK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert_eq!(built(b.clone()).resolution, NanoBanana2Resolution::OneK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_higgsfield_nano_banana_2(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_tiers_pass_through() {
|
||||
let mut b = base_builder(RouterImageModel::NanoBanana2);
|
||||
b.resolution = Some(RouterResolution::TwoK);
|
||||
assert_eq!(built(b).resolution, NanoBanana2Resolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_limit_is_six() {
|
||||
assert!(build_higgsfield_nano_banana_2(with_references(base_builder(RouterImageModel::NanoBanana2), 6)).is_ok());
|
||||
assert!(build_higgsfield_nano_banana_2(with_references(base_builder(RouterImageModel::NanoBanana2), 7)).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
//! Nano Banana 2 Lite on Higgsfield: 11 aspect ratios (incl. Auto), a fixed
|
||||
//! 1K with a High / Minimal quality ("thinking") menu, 1–4 images, up to 6
|
||||
//! reference images. The router's Low / Medium / High quality maps onto the
|
||||
//! two tiers; a requested resolution is ignored (there is no menu).
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_2_lite::{NanoBanana2LiteQuality, NanoBanana2LiteRequest};
|
||||
use log::warn;
|
||||
|
||||
use crate::api::router_quality::RouterQuality;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_nano_banana_aspect_ratio, require_prompt,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 6;
|
||||
|
||||
pub fn build_higgsfield_nano_banana_2_lite(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
let reference_count = image_input_count(image_inputs.as_ref());
|
||||
check_reference_limit(reference_count, MAX_REFERENCE_IMAGES, "Nano Banana 2 Lite")?;
|
||||
|
||||
if let Some(resolution) = builder.resolution.take() {
|
||||
warn!("Nano Banana 2 Lite always renders at 1K on Higgsfield; ignoring requested {resolution:?}");
|
||||
}
|
||||
|
||||
let aspect_ratio = plan_nano_banana_aspect_ratio(builder.aspect_ratio.take(), reference_count > 0);
|
||||
let quality = plan_quality(builder.quality.take());
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = NanoBanana2LiteRequest::text_to_image(prompt, aspect_ratio, quality);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::NanoBanana2Lite(request), image_inputs))
|
||||
}
|
||||
|
||||
/// High stays High; anything lower is the "Minimal" tier.
|
||||
fn plan_quality(quality: Option<RouterQuality>) -> NanoBanana2LiteQuality {
|
||||
match quality {
|
||||
None | Some(RouterQuality::High) => NanoBanana2LiteQuality::High,
|
||||
Some(RouterQuality::Medium) | Some(RouterQuality::Low) => NanoBanana2LiteQuality::Minimal,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request};
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> NanoBanana2LiteRequest {
|
||||
match unwrap_request(build_higgsfield_nano_banana_2_lite(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::NanoBanana2Lite(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_maps_onto_the_two_tiers() {
|
||||
assert_eq!(plan_quality(None), NanoBanana2LiteQuality::High);
|
||||
assert_eq!(plan_quality(Some(RouterQuality::High)), NanoBanana2LiteQuality::High);
|
||||
assert_eq!(plan_quality(Some(RouterQuality::Medium)), NanoBanana2LiteQuality::Minimal);
|
||||
assert_eq!(plan_quality(Some(RouterQuality::Low)), NanoBanana2LiteQuality::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_is_ignored_rather_than_rejected() {
|
||||
let mut b = base_builder(RouterImageModel::NanoBanana2Lite);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.quality = Some(RouterQuality::Low);
|
||||
let request = built(b);
|
||||
assert_eq!(request.quality, NanoBanana2LiteQuality::Minimal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
//! Nano Banana Pro on Higgsfield: 11 aspect ratios (incl. Auto), 1K / 2K /
|
||||
//! 4K, 1–4 images, up to 4 reference images.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::nano_banana_pro::{NanoBananaProRequest, NanoBananaProResolution};
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_nano_banana_aspect_ratio, require_prompt, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 4;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, NanoBananaProResolution)] = &[
|
||||
(RouterResolution::OneK, NanoBananaProResolution::OneK),
|
||||
(RouterResolution::TwoK, NanoBananaProResolution::TwoK),
|
||||
(RouterResolution::FourK, NanoBananaProResolution::FourK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_nano_banana_pro(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
let reference_count = image_input_count(image_inputs.as_ref());
|
||||
check_reference_limit(reference_count, MAX_REFERENCE_IMAGES, "Nano Banana Pro")?;
|
||||
|
||||
let aspect_ratio = plan_nano_banana_aspect_ratio(builder.aspect_ratio.take(), reference_count > 0);
|
||||
let resolution = snap_resolution(builder.resolution.take(), RESOLUTION_TIERS, NanoBananaProResolution::OneK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = NanoBananaProRequest::text_to_image(prompt, aspect_ratio, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::NanoBananaPro(request), image_inputs))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_draft, unwrap_request, with_references};
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
use higgsfield_client::types::nano_banana_aspect_ratio::NanoBananaAspectRatio;
|
||||
|
||||
fn builder() -> GenerateImageRequestBuilder {
|
||||
base_builder(RouterImageModel::NanoBananaPro)
|
||||
}
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> NanoBananaProRequest {
|
||||
match unwrap_request(build_higgsfield_nano_banana_pro(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::NanoBananaPro(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_the_web_apps() {
|
||||
let request = built(builder());
|
||||
assert_eq!(request.prompt, "a shiba inu doing a kickflip");
|
||||
assert_eq!(request.aspect_ratio, NanoBananaAspectRatio::Landscape16x9);
|
||||
assert_eq!(request.resolution, NanoBananaProResolution::OneK);
|
||||
assert_eq!(request.batch_size, ImageBatchSize::One);
|
||||
assert!(request.reference_images.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_flow_through() {
|
||||
let mut b = builder();
|
||||
b.aspect_ratio = Some(RouterAspectRatio::TallThreeByFour);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.image_batch_count = Some(3);
|
||||
let request = built(b);
|
||||
assert_eq!(request.aspect_ratio, NanoBananaAspectRatio::Portrait3x4);
|
||||
assert_eq!(request.resolution, NanoBananaProResolution::FourK);
|
||||
assert_eq!(request.batch_size, ImageBatchSize::Three);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_resolutions_snap_per_strategy() {
|
||||
let mut b = builder();
|
||||
b.resolution = Some(RouterResolution::ThreeK);
|
||||
assert_eq!(built(b.clone()).resolution, NanoBananaProResolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert_eq!(built(b.clone()).resolution, NanoBananaProResolution::TwoK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_higgsfield_nano_banana_pro(b).is_err());
|
||||
// 0.5K (a Nano Banana 2 tier on ArtCraft) rounds up to 1K.
|
||||
let mut b = builder();
|
||||
b.resolution = Some(RouterResolution::HalfK);
|
||||
assert_eq!(built(b).resolution, NanoBananaProResolution::OneK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_make_a_draft_and_default_to_auto_aspect() {
|
||||
let draft = unwrap_draft(build_higgsfield_nano_banana_pro(with_references(builder(), 2)).expect("build"));
|
||||
assert_eq!(draft.reference_image_count(), 2);
|
||||
match draft.request {
|
||||
HiggsfieldImageRequest::NanoBananaPro(request) => assert_eq!(request.aspect_ratio, NanoBananaAspectRatio::Auto),
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_references_are_rejected_before_upload() {
|
||||
assert!(build_higgsfield_nano_banana_pro(with_references(builder(), MAX_REFERENCE_IMAGES + 1)).is_err());
|
||||
assert!(build_higgsfield_nano_banana_pro(with_references(builder(), MAX_REFERENCE_IMAGES)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_is_rejected() {
|
||||
let mut b = builder();
|
||||
b.prompt = Some(" ".to_string());
|
||||
assert!(matches!(build_higgsfield_nano_banana_pro(b), Err(ArtcraftRouterError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
use log::info;
|
||||
|
||||
use crate::client::router_higgsfield_client::RouterHiggsfieldClient;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::errors::provider_error::ProviderError;
|
||||
use crate::generate::generate_image::generate_image_response::{GenerateImageResponse, HiggsfieldImageResponsePayload};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
/// A ready-to-send Higgsfield image request: every reference image has been
|
||||
/// uploaded and is referenced by its Higgsfield media id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldImageRequestState {
|
||||
pub request: HiggsfieldImageRequest,
|
||||
}
|
||||
|
||||
impl HiggsfieldImageRequestState {
|
||||
pub async fn send(&self, client: &RouterHiggsfieldClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
|
||||
info!(
|
||||
"Enqueuing Higgsfield {} image job: batch={}, references={}",
|
||||
self.request.model_label(), self.request.batch_size(), self.request.reference_images().len(),
|
||||
);
|
||||
|
||||
let response = self.request.send(&client.session).await
|
||||
.map_err(|err| ArtcraftRouterError::from(ProviderError::Higgsfield(err)))?;
|
||||
|
||||
let job_ids: Vec<String> = response.job_ids().into_iter().map(|id| id.into_string()).collect();
|
||||
let job_set_id = response.first_job_set()
|
||||
.map(|job_set| job_set.id.to_string())
|
||||
.filter(|_| !job_ids.is_empty())
|
||||
.ok_or_else(|| ArtcraftRouterError::ProviderResponseInvalid(
|
||||
"Higgsfield accepted the request but returned no jobs".to_string(),
|
||||
))?;
|
||||
|
||||
info!("Higgsfield enqueued job set {} with {} job(s)", job_set_id, job_ids.len());
|
||||
|
||||
Ok(GenerateImageResponse::Higgsfield(HiggsfieldImageResponsePayload { job_set_id, job_ids }))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
//! Seedream 4.5 on Higgsfield: 8 aspect ratios (no Auto), a 2K / 4K menu,
|
||||
//! 1–4 images. ArtCraft's baked-in `auto_2k` / `auto_4k` ratios become a
|
||||
//! 16:9 request at that resolution.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::seedream_4p5::{Seedream4p5Request, Seedream4p5Resolution};
|
||||
use higgsfield_client::types::seedream_aspect_ratio::SeedreamAspectRatio;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_seedream_aspect_ratio, require_prompt,
|
||||
seedream_auto_resolution_hint, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 6;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, Seedream4p5Resolution)] = &[
|
||||
(RouterResolution::TwoK, Seedream4p5Resolution::TwoK),
|
||||
(RouterResolution::FourK, Seedream4p5Resolution::FourK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_seedream_4p5(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
check_reference_limit(image_input_count(image_inputs.as_ref()), MAX_REFERENCE_IMAGES, "Seedream 4.5")?;
|
||||
|
||||
let requested_aspect_ratio = builder.aspect_ratio.take();
|
||||
let aspect_ratio = plan_seedream_aspect_ratio(requested_aspect_ratio, SeedreamAspectRatio::Landscape16x9);
|
||||
let requested_resolution = builder.resolution.take().or_else(|| seedream_auto_resolution_hint(requested_aspect_ratio));
|
||||
let resolution = snap_resolution(requested_resolution, RESOLUTION_TIERS, Seedream4p5Resolution::TwoK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = Seedream4p5Request::text_to_image(prompt, aspect_ratio, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::Seedream4p5(request), image_inputs))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request};
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> Seedream4p5Request {
|
||||
match unwrap_request(build_higgsfield_seedream_4p5(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::Seedream4p5(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_k_sits_between_the_two_tiers() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream4p5);
|
||||
b.resolution = Some(RouterResolution::ThreeK);
|
||||
assert_eq!(built(b.clone()).resolution, Seedream4p5Resolution::FourK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
assert_eq!(built(b).resolution, Seedream4p5Resolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_4k_becomes_16x9_at_4k() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream4p5);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto4k);
|
||||
let request = built(b);
|
||||
assert_eq!(request.aspect_ratio, SeedreamAspectRatio::Landscape16x9);
|
||||
assert_eq!(request.resolution, Seedream4p5Resolution::FourK);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
//! Seedream 5 Lite on Higgsfield: 8 aspect ratios (no Auto), a 2K / 3K / 4K
|
||||
//! menu, 1–4 images. ArtCraft's baked-in `auto_2k` / `auto_3k` ratios become
|
||||
//! a 16:9 request at that resolution.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::seedream_5p0_lite::{Seedream5p0LiteRequest, Seedream5p0LiteResolution};
|
||||
use higgsfield_client::types::seedream_aspect_ratio::SeedreamAspectRatio;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_seedream_aspect_ratio, require_prompt,
|
||||
seedream_auto_resolution_hint, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 6;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, Seedream5p0LiteResolution)] = &[
|
||||
(RouterResolution::TwoK, Seedream5p0LiteResolution::TwoK),
|
||||
(RouterResolution::ThreeK, Seedream5p0LiteResolution::ThreeK),
|
||||
(RouterResolution::FourK, Seedream5p0LiteResolution::FourK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_seedream_5p0_lite(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
check_reference_limit(image_input_count(image_inputs.as_ref()), MAX_REFERENCE_IMAGES, "Seedream 5 Lite")?;
|
||||
|
||||
let requested_aspect_ratio = builder.aspect_ratio.take();
|
||||
let aspect_ratio = plan_seedream_aspect_ratio(requested_aspect_ratio, SeedreamAspectRatio::Landscape16x9);
|
||||
let requested_resolution = builder.resolution.take().or_else(|| seedream_auto_resolution_hint(requested_aspect_ratio));
|
||||
let resolution = snap_resolution(requested_resolution, RESOLUTION_TIERS, Seedream5p0LiteResolution::TwoK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = Seedream5p0LiteRequest::text_to_image(prompt, aspect_ratio, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::Seedream5p0Lite(request), image_inputs))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request};
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> Seedream5p0LiteRequest {
|
||||
match unwrap_request(build_higgsfield_seedream_5p0_lite(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::Seedream5p0Lite(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_3k_becomes_16x9_at_3k() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5Lite);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto3k);
|
||||
let request = built(b);
|
||||
assert_eq!(request.aspect_ratio, SeedreamAspectRatio::Landscape16x9);
|
||||
assert_eq!(request.resolution, Seedream5p0LiteResolution::ThreeK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_resolution_beats_the_auto_hint() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5Lite);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto2k);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
assert_eq!(built(b).resolution, Seedream5p0LiteResolution::FourK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_k_upgrades_to_two_k() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5Lite);
|
||||
b.resolution = Some(RouterResolution::OneK);
|
||||
assert_eq!(built(b.clone()).resolution, Seedream5p0LiteResolution::TwoK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::PayLessDowngrade;
|
||||
// Nothing below 2K exists; the bottom tier is still 2K.
|
||||
assert_eq!(built(b).resolution, Seedream5p0LiteResolution::TwoK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_hd_is_square() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5Lite);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::SquareHd);
|
||||
assert_eq!(built(b).aspect_ratio, SeedreamAspectRatio::Square1x1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
//! Seedream 5.0 Pro on Higgsfield: 8 aspect ratios (no Auto), 1K / 1.5K /
|
||||
//! 2K, 1–4 images. The router has no 1.5K tier, so it offers 1K and 2K.
|
||||
|
||||
use higgsfield_client::endpoints::generate::image::seedream_5p0_pro::{Seedream5p0ProRequest, Seedream5p0ProResolution};
|
||||
use higgsfield_client::types::seedream_aspect_ratio::SeedreamAspectRatio;
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
|
||||
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
|
||||
use crate::generate::generate_image::providers::higgsfield::common::{
|
||||
check_reference_limit, finish, image_input_count, plan_batch_size, plan_seedream_aspect_ratio, require_prompt,
|
||||
seedream_auto_resolution_hint, snap_resolution,
|
||||
};
|
||||
use crate::generate::generate_image::providers::higgsfield::image_request::HiggsfieldImageRequest;
|
||||
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 14;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, Seedream5p0ProResolution)] = &[
|
||||
(RouterResolution::OneK, Seedream5p0ProResolution::OneK),
|
||||
(RouterResolution::TwoK, Seedream5p0ProResolution::TwoK),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_seedream_5p0_pro(mut builder: GenerateImageRequestBuilder) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
let image_inputs = builder.image_inputs.take();
|
||||
check_reference_limit(image_input_count(image_inputs.as_ref()), MAX_REFERENCE_IMAGES, "Seedream 5.0 Pro")?;
|
||||
|
||||
let requested_aspect_ratio = builder.aspect_ratio.take();
|
||||
let aspect_ratio = plan_seedream_aspect_ratio(requested_aspect_ratio, SeedreamAspectRatio::Square1x1);
|
||||
let requested_resolution = builder.resolution.take().or_else(|| seedream_auto_resolution_hint(requested_aspect_ratio));
|
||||
let resolution = snap_resolution(requested_resolution, RESOLUTION_TIERS, Seedream5p0ProResolution::OneK, strategy)?;
|
||||
let batch_size = plan_batch_size(builder.image_batch_count.take(), strategy)?;
|
||||
|
||||
let mut request = Seedream5p0ProRequest::text_to_image(prompt, aspect_ratio, resolution);
|
||||
request.batch_size = batch_size;
|
||||
Ok(finish(HiggsfieldImageRequest::Seedream5p0Pro(request), image_inputs))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_image_model::RouterImageModel;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_image::providers::higgsfield::common_test_helpers::{base_builder, unwrap_request};
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
|
||||
fn built(builder: GenerateImageRequestBuilder) -> Seedream5p0ProRequest {
|
||||
match unwrap_request(build_higgsfield_seedream_5p0_pro(builder).expect("build")) {
|
||||
HiggsfieldImageRequest::Seedream5p0Pro(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_square_at_one_k() {
|
||||
let request = built(base_builder(RouterImageModel::Seedream5p0Pro));
|
||||
assert_eq!(request.aspect_ratio, SeedreamAspectRatio::Square1x1);
|
||||
assert_eq!(request.resolution, Seedream5p0ProResolution::OneK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_downgrades_to_two_k_even_when_upgrading() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5p0Pro);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
assert_eq!(built(b.clone()).resolution, Seedream5p0ProResolution::TwoK);
|
||||
b.request_mismatch_mitigation_strategy = RequestMismatchMitigationStrategy::ErrorOut;
|
||||
assert!(build_higgsfield_seedream_5p0_pro(b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artcraft_batches_of_eight_clamp_to_four() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5p0Pro);
|
||||
b.image_batch_count = Some(8);
|
||||
assert_eq!(built(b).batch_size, ImageBatchSize::Four);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_ratio_falls_back_to_square() {
|
||||
let mut b = base_builder(RouterImageModel::Seedream5p0Pro);
|
||||
b.aspect_ratio = Some(RouterAspectRatio::Auto);
|
||||
assert_eq!(built(b).aspect_ratio, SeedreamAspectRatio::Square1x1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod artcraft;
|
||||
pub mod fal;
|
||||
pub mod grok;
|
||||
pub mod higgsfield;
|
||||
pub mod kinovi;
|
||||
pub mod midjourney;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,14 @@ use crate::generate::generate_video::providers::fal::vidu_q3::build::build_fal_v
|
|||
use crate::generate::generate_video::providers::fal::vidu_q3_turbo::build::build_fal_vidu_q3_turbo;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_fast::build::build_kinovi_seedance_2p0_fast;
|
||||
use crate::generate::generate_video::providers::kinovi::seedance_2p0_mini::build::build_kinovi_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::higgsfield::grok_imagine_1p5::build::build_higgsfield_grok_imagine_1p5;
|
||||
use crate::generate::generate_video::providers::higgsfield::kling_3p0_pro::build::build_higgsfield_kling_3p0_pro;
|
||||
use crate::generate::generate_video::providers::higgsfield::kling_3p0_standard::build::build_higgsfield_kling_3p0_standard;
|
||||
use crate::generate::generate_video::providers::higgsfield::minimax_h3::build::build_higgsfield_minimax_h3;
|
||||
use crate::generate::generate_video::providers::higgsfield::seedance_2p0::build::build_higgsfield_seedance_2p0;
|
||||
use crate::generate::generate_video::providers::higgsfield::seedance_2p0_mini::build::build_higgsfield_seedance_2p0_mini;
|
||||
use crate::generate::generate_video::providers::higgsfield::seedance_2p5::build::build_higgsfield_seedance_2p5;
|
||||
use crate::generate::generate_video::providers::higgsfield::seedance_2p5_edit::build::build_higgsfield_seedance_2p5_edit;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
/// RouterProvider-agnostic video generation request. Distilled by `build2()` into a
|
||||
|
|
@ -236,6 +244,15 @@ impl GenerateVideoRequestBuilder {
|
|||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0Mini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0BytePlusUltraMini) => build_kinovi_seedance_2p0_mini(self),
|
||||
// First-party (cookie-session) Higgsfield
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Seedance2p5) => build_higgsfield_seedance_2p5(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Seedance2p5Edit) => build_higgsfield_seedance_2p5_edit(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Seedance2p0) => build_higgsfield_seedance_2p0(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Seedance2p0Mini) => build_higgsfield_seedance_2p0_mini(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::MinimaxH3) => build_higgsfield_minimax_h3(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Kling3p0Standard) => build_higgsfield_kling_3p0_standard(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::Kling3p0Pro) => build_higgsfield_kling_3p0_pro(self),
|
||||
(RouterProvider::Higgsfield, RouterVideoModel::GrokImagineVideo1p5) => build_higgsfield_grok_imagine_1p5(self),
|
||||
_ => self.unsupported_provider_and_model(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,30 @@ pub struct GrokVideoResponsePayload {
|
|||
pub maybe_outbound_request: Option<Arc<dyn Debug + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// Response from the first-party (cookie-session) Higgsfield provider. One
|
||||
/// enqueue creates a job set with one job per requested video; the caller
|
||||
/// polls the jobs (`HiggsfieldSession::job_status_batch`) by id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldVideoResponsePayload {
|
||||
pub job_set_id: String,
|
||||
/// In generation order; never empty.
|
||||
pub job_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl HiggsfieldVideoResponsePayload {
|
||||
/// The first job, which stands in for the whole set as the task's
|
||||
/// `provider_job_id`.
|
||||
pub fn primary_job_id(&self) -> &str {
|
||||
&self.job_ids[0]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GenerateVideoResponse {
|
||||
Artcraft(ArtcraftVideoResponsePayload),
|
||||
GmiCloud(GmiCloudVideoResponsePayload),
|
||||
Grok(GrokVideoResponsePayload),
|
||||
Higgsfield(HiggsfieldVideoResponsePayload),
|
||||
Seedance2Pro(Seedance2proVideoResponsePayload),
|
||||
Fal(FalVideoResponsePayload),
|
||||
}
|
||||
|
|
@ -91,6 +110,13 @@ impl GenerateVideoResponse {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_higgsfield_payload(&self) -> Option<HiggsfieldVideoResponsePayload> {
|
||||
match self {
|
||||
Self::Higgsfield(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_fal_payload(&self) -> Option<FalVideoResponsePayload> {
|
||||
match self {
|
||||
Self::Fal(p) => Some(p.clone()),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,419 @@
|
|||
//! Planning helpers shared by every Higgsfield video model. The resolution
|
||||
//! and aspect-ratio snapping primitives come from the image provider's
|
||||
//! `common`; this adds durations, batches, bitrates, and reference triage.
|
||||
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
use higgsfield_client::types::media_role::MediaRole;
|
||||
use higgsfield_client::types::video_aspect_ratio::{KlingAspectRatio, SeedanceVideoAspectRatio};
|
||||
use higgsfield_client::types::video_bitrate_mode::VideoBitrateMode;
|
||||
use higgsfield_client::types::video_duration::{VideoDurationRange, VideoDurationSeconds};
|
||||
use log::warn;
|
||||
|
||||
use crate::api::audio_list_ref::AudioListRef;
|
||||
use crate::api::character_list_ref::CharacterListRef;
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_aspect_ratio::RouterAspectRatio;
|
||||
use crate::api::router_bitrate::RouterBitrate;
|
||||
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_image::providers::higgsfield::common::{aspect_ratio_value, nearest_aspect_ratio};
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::higgsfield::draft::{HiggsfieldVideoDraftState, HiggsfieldVideoPlan};
|
||||
use crate::generate::generate_video::providers::higgsfield::request::HiggsfieldVideoRequestState;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub(crate) use crate::generate::generate_image::providers::higgsfield::common::{require_prompt, snap_resolution};
|
||||
|
||||
/// Clip length: in range passes through; out of range clamps (or errors out
|
||||
/// under `ErrorOut`). `None` means `default`.
|
||||
pub(crate) fn plan_duration(
|
||||
requested: Option<u16>,
|
||||
range: VideoDurationRange,
|
||||
default: u32,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<VideoDurationSeconds, ArtcraftRouterError> {
|
||||
let Some(requested) = requested else {
|
||||
return Ok(VideoDurationSeconds::new(default));
|
||||
};
|
||||
let duration = VideoDurationSeconds::new(requested as u32);
|
||||
if range.contains(duration) {
|
||||
return Ok(duration);
|
||||
}
|
||||
if let RequestMismatchMitigationStrategy::ErrorOut = strategy {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "duration_seconds",
|
||||
value: format!("{requested} (Higgsfield offers {}–{}s for this model)", range.min_seconds, range.max_seconds),
|
||||
}));
|
||||
}
|
||||
let clamped = (requested as u32).clamp(range.min_seconds, range.max_seconds);
|
||||
warn!("Higgsfield offers {}–{}s for this model; clamping {}s to {}s", range.min_seconds, range.max_seconds, requested, clamped);
|
||||
Ok(VideoDurationSeconds::new(clamped))
|
||||
}
|
||||
|
||||
/// The Seedance models render 1–4 clips per request; larger batches clamp
|
||||
/// to 4 (or error out under `ErrorOut`).
|
||||
pub(crate) fn plan_batch_size(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
) -> Result<ImageBatchSize, ArtcraftRouterError> {
|
||||
let count = video_batch_count.unwrap_or(1);
|
||||
if count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations));
|
||||
}
|
||||
if let Some(batch_size) = ImageBatchSize::from_u32(count as u32) {
|
||||
return Ok(batch_size);
|
||||
}
|
||||
match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{count} (Higgsfield renders at most {} per request)", ImageBatchSize::MAX),
|
||||
})),
|
||||
_ => {
|
||||
warn!("Higgsfield renders at most {} videos per request; clamping {}", ImageBatchSize::MAX, count);
|
||||
Ok(ImageBatchSize::Four)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Models with no batch control render one clip; a bigger request is an
|
||||
/// error under `ErrorOut` and otherwise renders one.
|
||||
pub(crate) fn plan_single_video(
|
||||
video_batch_count: Option<u16>,
|
||||
strategy: RequestMismatchMitigationStrategy,
|
||||
model: &str,
|
||||
) -> Result<(), ArtcraftRouterError> {
|
||||
match video_batch_count.unwrap_or(1) {
|
||||
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
|
||||
1 => Ok(()),
|
||||
count => match strategy {
|
||||
RequestMismatchMitigationStrategy::ErrorOut => Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "video_batch_count",
|
||||
value: format!("{count} ({model} on Higgsfield renders one video per request)"),
|
||||
})),
|
||||
_ => {
|
||||
warn!("{model} on Higgsfield renders one video per request; ignoring batch of {count}");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The Seedance menu: 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, and (for the models
|
||||
/// that take it) Auto. Unset / auto ratios become Auto when `allow_auto`,
|
||||
/// else 16:9; everything else snaps to the nearest ratio.
|
||||
pub(crate) fn plan_seedance_aspect_ratio(requested: Option<RouterAspectRatio>, allow_auto: bool) -> SeedanceVideoAspectRatio {
|
||||
use SeedanceVideoAspectRatio as Ar;
|
||||
const CANDIDATES: &[(f64, Ar)] = &[
|
||||
(21.0 / 9.0, Ar::Landscape21x9),
|
||||
(16.0 / 9.0, Ar::Landscape16x9),
|
||||
(4.0 / 3.0, Ar::Landscape4x3),
|
||||
(1.0, Ar::Square1x1),
|
||||
(3.0 / 4.0, Ar::Portrait3x4),
|
||||
(9.0 / 16.0, Ar::Portrait9x16),
|
||||
];
|
||||
match requested.and_then(aspect_ratio_value) {
|
||||
None if allow_auto => Ar::Auto,
|
||||
None => Ar::Landscape16x9,
|
||||
Some(value) => nearest_aspect_ratio(value, CANDIDATES),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kling's menu: 16:9, 9:16, 1:1. Unset / auto ratios are 16:9.
|
||||
pub(crate) fn plan_kling_aspect_ratio(requested: Option<RouterAspectRatio>) -> KlingAspectRatio {
|
||||
use KlingAspectRatio as Ar;
|
||||
const CANDIDATES: &[(f64, Ar)] = &[
|
||||
(16.0 / 9.0, Ar::Landscape16x9),
|
||||
(9.0 / 16.0, Ar::Portrait9x16),
|
||||
(1.0, Ar::Square1x1),
|
||||
];
|
||||
match requested.and_then(aspect_ratio_value) {
|
||||
None => Ar::Landscape16x9,
|
||||
Some(value) => nearest_aspect_ratio(value, CANDIDATES),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seedance bitrate: the web app defaults to high.
|
||||
pub(crate) fn plan_bitrate(bitrate: Option<RouterBitrate>) -> VideoBitrateMode {
|
||||
match bitrate {
|
||||
None | Some(RouterBitrate::High) => VideoBitrateMode::High,
|
||||
Some(RouterBitrate::Normal) => VideoBitrateMode::Standard,
|
||||
}
|
||||
}
|
||||
|
||||
/// A model has no menu for a field the caller set: say so, once.
|
||||
pub(crate) fn warn_ignored<T: std::fmt::Debug>(model: &str, field: &str, value: Option<T>) {
|
||||
if let Some(value) = value {
|
||||
warn!("{model} on Higgsfield has no {field} control; ignoring {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The media a video request came with, before upload. Taken off the
|
||||
/// builder as a unit so every model triages it the same way.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HiggsfieldVideoReferences {
|
||||
pub start_frame: Option<ImageRef>,
|
||||
pub end_frame: Option<ImageRef>,
|
||||
pub reference_images: Option<ImageListRef>,
|
||||
pub reference_videos: Option<VideoListRef>,
|
||||
pub reference_audio: Option<AudioListRef>,
|
||||
}
|
||||
|
||||
impl HiggsfieldVideoReferences {
|
||||
/// Pull every reference off the builder. Character references have no
|
||||
/// Higgsfield equivalent and are dropped with a warning.
|
||||
pub fn take_from(builder: &mut GenerateVideoRequestBuilder, model: &str) -> Self {
|
||||
if let Some(CharacterListRef::CharacterTokens(tokens)) = builder.reference_character_tokens.take() {
|
||||
if !tokens.is_empty() {
|
||||
warn!("{model} on Higgsfield has no character references; dropping {}", tokens.len());
|
||||
}
|
||||
}
|
||||
Self {
|
||||
start_frame: builder.start_frame.take(),
|
||||
end_frame: builder.end_frame.take(),
|
||||
reference_images: builder.reference_images.take().filter(|list| list_len(list) > 0),
|
||||
reference_videos: builder.reference_videos.take().filter(|list| video_list_len(list) > 0),
|
||||
reference_audio: builder.reference_audio.take().filter(|list| audio_list_len(list) > 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every reference kind the model's endpoint doesn't accept, with a
|
||||
/// warning per kind, so a stray attachment doesn't fail the whole request.
|
||||
pub fn retain_roles(&mut self, allowed: &[MediaRole], model: &str) {
|
||||
if !allowed.contains(&MediaRole::StartImage) && self.start_frame.take().is_some() {
|
||||
warn!("{model} on Higgsfield takes no start frame; dropping it");
|
||||
}
|
||||
if !allowed.contains(&MediaRole::EndImage) && self.end_frame.take().is_some() {
|
||||
warn!("{model} on Higgsfield takes no end frame; dropping it");
|
||||
}
|
||||
if !allowed.contains(&MediaRole::Image) && self.reference_images.take().is_some() {
|
||||
warn!("{model} on Higgsfield takes no reference images; dropping them");
|
||||
}
|
||||
if !allowed.contains(&MediaRole::Video) && self.reference_videos.take().is_some() {
|
||||
warn!("{model} on Higgsfield takes no reference videos; dropping them");
|
||||
}
|
||||
if !allowed.contains(&MediaRole::Audio) && self.reference_audio.take().is_some() {
|
||||
warn!("{model} on Higgsfield takes no reference audio; dropping it");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject over-limit lists before anything is uploaded.
|
||||
pub fn check_limits(&self, max_images: usize, max_videos: usize, max_audio: usize, model: &str) -> Result<(), ArtcraftRouterError> {
|
||||
check_limit(self.reference_image_count(), max_images, "reference_images", "reference images", model)?;
|
||||
check_limit(self.reference_video_count(), max_videos, "reference_videos", "reference videos", model)?;
|
||||
check_limit(self.reference_audio_count(), max_audio, "reference_audio", "reference audio files", model)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.start_frame.is_none()
|
||||
&& self.end_frame.is_none()
|
||||
&& self.reference_images.is_none()
|
||||
&& self.reference_videos.is_none()
|
||||
&& self.reference_audio.is_none()
|
||||
}
|
||||
|
||||
pub fn reference_image_count(&self) -> usize {
|
||||
self.reference_images.as_ref().map(list_len).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn reference_video_count(&self) -> usize {
|
||||
self.reference_videos.as_ref().map(video_list_len).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn reference_audio_count(&self) -> usize {
|
||||
self.reference_audio.as_ref().map(audio_list_len).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn has_any_image(&self) -> bool {
|
||||
self.start_frame.is_some() || self.end_frame.is_some() || self.reference_image_count() > 0
|
||||
}
|
||||
}
|
||||
|
||||
fn check_limit(count: usize, max: usize, field: &'static str, what: &str, model: &str) -> Result<(), ArtcraftRouterError> {
|
||||
if count > max {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field,
|
||||
value: format!("{count} {what} ({model} on Higgsfield takes at most {max})"),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_len(list: &ImageListRef) -> usize {
|
||||
match list {
|
||||
ImageListRef::Urls(urls) => urls.len(),
|
||||
ImageListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn video_list_len(list: &VideoListRef) -> usize {
|
||||
match list {
|
||||
VideoListRef::Urls(urls) => urls.len(),
|
||||
VideoListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_list_len(list: &AudioListRef) -> usize {
|
||||
match list {
|
||||
AudioListRef::Urls(urls) => urls.len(),
|
||||
AudioListRef::MediaFileTokens(tokens) => tokens.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plan: with media to upload it's a draft, otherwise it's ready to
|
||||
/// send. `ip_check` is whether uploads should run Higgsfield's IP check (the
|
||||
/// Seedance models refuse unchecked media).
|
||||
pub(crate) fn finish(plan: HiggsfieldVideoPlan, references: HiggsfieldVideoReferences, ip_check: bool) -> VideoGenerationDraftOrRequest {
|
||||
if references.is_empty() {
|
||||
if let HiggsfieldVideoPlan::Request(request) = plan {
|
||||
return VideoGenerationDraftOrRequest::Request(
|
||||
VideoGenerationRequest::HiggsfieldVideo(HiggsfieldVideoRequestState { request }),
|
||||
);
|
||||
}
|
||||
}
|
||||
VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::HiggsfieldVideo(HiggsfieldVideoDraftState {
|
||||
plan,
|
||||
unhandled_request_state: Some(references),
|
||||
ip_check,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod durations_and_batches {
|
||||
use super::*;
|
||||
|
||||
const RANGE: VideoDurationRange = VideoDurationRange::new(4, 15);
|
||||
|
||||
#[test]
|
||||
fn in_range_passes_and_none_defaults() {
|
||||
assert_eq!(plan_duration(Some(10), RANGE, 5, RequestMismatchMitigationStrategy::ErrorOut).unwrap().seconds(), 10);
|
||||
assert_eq!(plan_duration(None, RANGE, 5, RequestMismatchMitigationStrategy::ErrorOut).unwrap().seconds(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_clamps_or_errors() {
|
||||
assert_eq!(plan_duration(Some(30), RANGE, 5, RequestMismatchMitigationStrategy::PayMoreUpgrade).unwrap().seconds(), 15);
|
||||
assert_eq!(plan_duration(Some(1), RANGE, 5, RequestMismatchMitigationStrategy::PayLessDowngrade).unwrap().seconds(), 4);
|
||||
assert!(matches!(
|
||||
plan_duration(Some(30), RANGE, 5, RequestMismatchMitigationStrategy::ErrorOut),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "duration_seconds", .. })),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches() {
|
||||
assert_eq!(plan_batch_size(Some(2), RequestMismatchMitigationStrategy::ErrorOut).unwrap(), ImageBatchSize::Two);
|
||||
assert_eq!(plan_batch_size(Some(9), RequestMismatchMitigationStrategy::PayLessDowngrade).unwrap(), ImageBatchSize::Four);
|
||||
assert!(plan_batch_size(Some(9), RequestMismatchMitigationStrategy::ErrorOut).is_err());
|
||||
assert!(plan_batch_size(Some(0), RequestMismatchMitigationStrategy::ErrorOut).is_err());
|
||||
assert!(plan_single_video(Some(1), RequestMismatchMitigationStrategy::ErrorOut, "x").is_ok());
|
||||
assert!(plan_single_video(Some(3), RequestMismatchMitigationStrategy::PayMoreUpgrade, "x").is_ok());
|
||||
assert!(plan_single_video(Some(3), RequestMismatchMitigationStrategy::ErrorOut, "x").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod aspect_ratios_and_bitrate {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seedance() {
|
||||
use SeedanceVideoAspectRatio as Ar;
|
||||
assert_eq!(plan_seedance_aspect_ratio(None, true), Ar::Auto);
|
||||
assert_eq!(plan_seedance_aspect_ratio(None, false), Ar::Landscape16x9);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::Auto), false), Ar::Landscape16x9);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::Auto), true), Ar::Auto);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::WideThreeByTwo), true), Ar::Landscape4x3);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::TallTwoByThree), true), Ar::Portrait3x4);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::TallNineByTwentyOne), true), Ar::Portrait9x16);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::WideTwentyOneByNine), true), Ar::Landscape21x9);
|
||||
assert_eq!(plan_seedance_aspect_ratio(Some(RouterAspectRatio::Wide), true), Ar::Landscape16x9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kling() {
|
||||
use KlingAspectRatio as Ar;
|
||||
assert_eq!(plan_kling_aspect_ratio(None), Ar::Landscape16x9);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::Auto)), Ar::Landscape16x9);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::WideThreeByTwo)), Ar::Landscape16x9);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::WideTwentyOneByNine)), Ar::Landscape16x9);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::TallTwoByThree)), Ar::Portrait9x16);
|
||||
// Nearest by ratio value: 4:3 (1.33) is closer to 1:1 than to 16:9,
|
||||
// while 3:4 (0.75) is closer to 9:16 (0.56) than to 1:1.
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::WideFourByThree)), Ar::Square1x1);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::TallThreeByFour)), Ar::Portrait9x16);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::WideFiveByFour)), Ar::Square1x1);
|
||||
assert_eq!(plan_kling_aspect_ratio(Some(RouterAspectRatio::Square)), Ar::Square1x1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate() {
|
||||
assert_eq!(plan_bitrate(None), VideoBitrateMode::High);
|
||||
assert_eq!(plan_bitrate(Some(RouterBitrate::High)), VideoBitrateMode::High);
|
||||
assert_eq!(plan_bitrate(Some(RouterBitrate::Normal)), VideoBitrateMode::Standard);
|
||||
}
|
||||
}
|
||||
|
||||
mod references {
|
||||
use super::*;
|
||||
use sqlite_identifiers::ids::media_file_token::MediaFileToken;
|
||||
|
||||
fn full_builder() -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
start_frame: Some(ImageRef::Url("s".into())),
|
||||
end_frame: Some(ImageRef::Url("e".into())),
|
||||
reference_images: Some(ImageListRef::Urls(vec!["a".into(), "b".into()])),
|
||||
reference_videos: Some(VideoListRef::MediaFileTokens(vec![MediaFileToken::new_from_str("m_v")])),
|
||||
reference_audio: Some(AudioListRef::Urls(vec!["x".into()])),
|
||||
reference_character_tokens: Some(CharacterListRef::CharacterTokens(vec![])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_everything_and_drops_empty_lists() {
|
||||
let mut builder = full_builder();
|
||||
builder.reference_audio = Some(AudioListRef::Urls(vec![]));
|
||||
let references = HiggsfieldVideoReferences::take_from(&mut builder, "x");
|
||||
assert!(references.start_frame.is_some() && references.end_frame.is_some());
|
||||
assert_eq!(references.reference_image_count(), 2);
|
||||
assert_eq!(references.reference_video_count(), 1);
|
||||
assert_eq!(references.reference_audio_count(), 0);
|
||||
assert!(references.reference_audio.is_none());
|
||||
assert!(builder.start_frame.is_none() && builder.reference_images.is_none());
|
||||
assert!(!references.is_empty());
|
||||
assert!(HiggsfieldVideoReferences::default().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_roles_drops_what_the_model_cannot_take() {
|
||||
let mut references = HiggsfieldVideoReferences::take_from(&mut full_builder(), "x");
|
||||
references.retain_roles(&[MediaRole::StartImage, MediaRole::Image], "x");
|
||||
assert!(references.start_frame.is_some());
|
||||
assert!(references.end_frame.is_none());
|
||||
assert_eq!(references.reference_image_count(), 2);
|
||||
assert!(references.reference_videos.is_none());
|
||||
assert!(references.reference_audio.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits() {
|
||||
let references = HiggsfieldVideoReferences::take_from(&mut full_builder(), "x");
|
||||
assert!(references.check_limits(2, 1, 1, "x").is_ok());
|
||||
assert!(matches!(
|
||||
references.check_limits(1, 1, 1, "x"),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "reference_images", .. })),
|
||||
));
|
||||
assert!(matches!(
|
||||
references.check_limits(2, 0, 1, "x"),
|
||||
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "reference_videos", .. })),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
//! Test-only helpers shared by the per-model `build.rs` tests.
|
||||
|
||||
use crate::api::image_list_ref::ImageListRef;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_provider::RouterProvider;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::api::video_list_ref::VideoListRef;
|
||||
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::higgsfield::draft::{HiggsfieldVideoDraftState, HiggsfieldVideoPlan};
|
||||
use crate::generate::generate_video::providers::higgsfield::video_request::HiggsfieldVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
|
||||
|
||||
pub fn base_builder(model: RouterVideoModel) -> GenerateVideoRequestBuilder {
|
||||
GenerateVideoRequestBuilder {
|
||||
model,
|
||||
provider: RouterProvider::Higgsfield,
|
||||
prompt: Some("a shiba inu surfing a big wave".to_string()),
|
||||
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_start_frame(mut builder: GenerateVideoRequestBuilder) -> GenerateVideoRequestBuilder {
|
||||
builder.start_frame = Some(ImageRef::Url("https://cdn.example.com/start.png".to_string()));
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn with_reference_images(mut builder: GenerateVideoRequestBuilder, count: usize) -> GenerateVideoRequestBuilder {
|
||||
let urls = (0..count).map(|i| format!("https://cdn.example.com/ref_{i}.png")).collect();
|
||||
builder.reference_images = Some(ImageListRef::Urls(urls));
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn with_reference_videos(mut builder: GenerateVideoRequestBuilder, count: usize) -> GenerateVideoRequestBuilder {
|
||||
let urls = (0..count).map(|i| format!("https://cdn.example.com/clip_{i}.mp4")).collect();
|
||||
builder.reference_videos = Some(VideoListRef::Urls(urls));
|
||||
builder
|
||||
}
|
||||
|
||||
/// The planned request out of a direct (no media) build.
|
||||
pub fn unwrap_request(result: VideoGenerationDraftOrRequest) -> HiggsfieldVideoRequest {
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::HiggsfieldVideo(state)) => state.request,
|
||||
other => panic!("expected a direct Higgsfield request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft out of a build with media.
|
||||
pub fn unwrap_draft(result: VideoGenerationDraftOrRequest) -> HiggsfieldVideoDraftState {
|
||||
match result {
|
||||
VideoGenerationDraftOrRequest::Draft(VideoGenerationDraftRequest::HiggsfieldVideo(draft)) => draft,
|
||||
other => panic!("expected a Higgsfield draft, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The planned request inside a draft (media not yet attached).
|
||||
pub fn unwrap_draft_request(result: VideoGenerationDraftOrRequest) -> (HiggsfieldVideoRequest, HiggsfieldVideoDraftState) {
|
||||
let draft = unwrap_draft(result);
|
||||
match &draft.plan {
|
||||
HiggsfieldVideoPlan::Request(request) => (request.clone(), draft),
|
||||
other => panic!("expected a planned request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
use crate::generate::generate_video::providers::higgsfield::draft::HiggsfieldVideoDraftState;
|
||||
use crate::generate::generate_video::providers::higgsfield::request::HiggsfieldVideoRequestState;
|
||||
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
|
||||
|
||||
/// Cost state for first-party (cookie-session) Higgsfield video generation.
|
||||
///
|
||||
/// This runs on the USER'S OWN Higgsfield account (its credits or unlimited
|
||||
/// plan), so it consumes no ArtCraft credits. Reported as free / unlimited
|
||||
/// like the other first-party providers; Higgsfield's own credit pricing
|
||||
/// isn't modelled here.
|
||||
pub struct HiggsfieldVideoCostState;
|
||||
|
||||
impl HiggsfieldVideoCostState {
|
||||
pub fn from_request(_request: &HiggsfieldVideoRequestState) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn from_draft(_draft: &HiggsfieldVideoDraftState) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
|
||||
VideoGenerationCostEstimate {
|
||||
cost_in_credits: Some(0),
|
||||
cost_in_usd_cents: None,
|
||||
is_free: true,
|
||||
is_unlimited: true,
|
||||
is_rate_limited: false,
|
||||
has_watermark: false,
|
||||
failures_are_refunded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
use higgsfield_client::endpoints::generate::video::seedance_2p5::Seedance2p5Resolution;
|
||||
use higgsfield_client::endpoints::generate::video::seedance_2p5_edit::Seedance2p5EditRequest;
|
||||
use higgsfield_client::types::image_batch_size::ImageBatchSize;
|
||||
use higgsfield_client::types::media_reference::MediaReference;
|
||||
use higgsfield_client::types::video_bitrate_mode::VideoBitrateMode;
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::providers::higgsfield::common::HiggsfieldVideoReferences;
|
||||
use crate::generate::generate_video::providers::higgsfield::request::HiggsfieldVideoRequestState;
|
||||
use crate::generate::generate_video::providers::higgsfield::video_request::HiggsfieldVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
|
||||
use crate::utils::higgsfield_media::{upload_image_ref, upload_media_list, HiggsfieldMediaKind};
|
||||
|
||||
/// What a `build.rs` planned, before media is uploaded.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HiggsfieldVideoPlan {
|
||||
/// A request that only needs its references attached.
|
||||
Request(HiggsfieldVideoRequest),
|
||||
/// Seedance 2.5 Edit can't be constructed until its source clip has a
|
||||
/// Higgsfield media id.
|
||||
Seedance2p5Edit(Seedance2p5EditPlan),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Seedance2p5EditPlan {
|
||||
pub prompt: String,
|
||||
pub resolution: Seedance2p5Resolution,
|
||||
pub batch_size: ImageBatchSize,
|
||||
pub generate_audio: bool,
|
||||
pub bitrate_mode: VideoBitrateMode,
|
||||
}
|
||||
|
||||
impl HiggsfieldVideoPlan {
|
||||
pub fn model_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Request(request) => request.model_label(),
|
||||
Self::Seedance2p5Edit(_) => "Seedance 2.5 Edit",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_size(&self) -> u32 {
|
||||
match self {
|
||||
Self::Request(request) => request.batch_size(),
|
||||
Self::Seedance2p5Edit(plan) => plan.batch_size.as_u32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A planned Higgsfield video request whose keyframes / references still
|
||||
/// have to be uploaded. `to_request` does the uploads, tags each by role,
|
||||
/// and produces the sendable state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HiggsfieldVideoDraftState {
|
||||
pub plan: HiggsfieldVideoPlan,
|
||||
pub unhandled_request_state: Option<HiggsfieldVideoReferences>,
|
||||
/// Run Higgsfield's IP check on uploaded images and clips (the Seedance
|
||||
/// models refuse media that hasn't been checked).
|
||||
pub ip_check: bool,
|
||||
}
|
||||
|
||||
impl HiggsfieldVideoDraftState {
|
||||
pub async fn to_request(
|
||||
&mut self,
|
||||
draft_context: &VideoGenerationDraftContext<'_>,
|
||||
) -> Result<HiggsfieldVideoRequestState, ArtcraftRouterError> {
|
||||
let client = draft_context.get_higgsfield_client_ref()?;
|
||||
let session = &client.session;
|
||||
let map = draft_context.media_file_to_artcraft_url_map;
|
||||
let label = self.plan.model_label();
|
||||
|
||||
let references = self.unhandled_request_state.take().unwrap_or_default();
|
||||
|
||||
let start_frame = upload_image_ref(session, references.start_frame, self.ip_check, map).await?;
|
||||
let end_frame = upload_image_ref(session, references.end_frame, self.ip_check, map).await?;
|
||||
let images = upload_media_list(session, references.reference_images.map(Into::into), HiggsfieldMediaKind::Image, self.ip_check, map).await?;
|
||||
let mut videos = upload_media_list(session, references.reference_videos.map(Into::into), HiggsfieldMediaKind::Video, self.ip_check, map).await?;
|
||||
// Audio has no IP check.
|
||||
let audio = upload_media_list(session, references.reference_audio.map(Into::into), HiggsfieldMediaKind::Audio, false, map).await?;
|
||||
|
||||
let mut request = match self.plan.clone() {
|
||||
HiggsfieldVideoPlan::Request(request) => request,
|
||||
HiggsfieldVideoPlan::Seedance2p5Edit(plan) => {
|
||||
if videos.is_empty() {
|
||||
return Err(ArtcraftRouterError::InvalidInput("Seedance 2.5 Edit needs the video to edit as a reference".to_string()));
|
||||
}
|
||||
let source_video = videos.remove(0);
|
||||
if !videos.is_empty() {
|
||||
warn!("Seedance 2.5 Edit edits one clip; ignoring {} extra reference video(s)", videos.len());
|
||||
videos.clear();
|
||||
}
|
||||
let mut request = Seedance2p5EditRequest::new(plan.prompt, source_video, plan.resolution);
|
||||
request.batch_size = plan.batch_size;
|
||||
request.generate_audio = plan.generate_audio;
|
||||
request.bitrate_mode = plan.bitrate_mode;
|
||||
HiggsfieldVideoRequest::Seedance2p5Edit(request)
|
||||
}
|
||||
};
|
||||
|
||||
attach(&mut request, start_frame.map(MediaReference::start_frame));
|
||||
attach(&mut request, end_frame.map(MediaReference::end_frame));
|
||||
for image in images {
|
||||
request.push_media(MediaReference::image(image));
|
||||
}
|
||||
for video in videos {
|
||||
request.push_media(MediaReference::video(video));
|
||||
}
|
||||
for clip in audio {
|
||||
request.push_media(MediaReference::audio(clip));
|
||||
}
|
||||
|
||||
info!("Higgsfield {} request finalized with {} reference(s)", label, request.medias().len());
|
||||
Ok(HiggsfieldVideoRequestState { request })
|
||||
}
|
||||
}
|
||||
|
||||
fn attach(request: &mut HiggsfieldVideoRequest, reference: Option<MediaReference>) {
|
||||
if let Some(reference) = reference {
|
||||
request.push_media(reference);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
//! Grok Imagine 1.5 on Higgsfield: text- or image-to-video at 480p / 720p /
|
||||
//! 1080p, 1–15s, one clip, a start frame and image references. The output
|
||||
//! follows the start frame's aspect ("auto"); a requested ratio is ignored.
|
||||
|
||||
use higgsfield_client::endpoints::generate::video::grok_imagine_1p5::{GrokImagine1p5Request, GrokImagine1p5Resolution};
|
||||
|
||||
use crate::api::router_resolution::RouterResolution;
|
||||
use crate::errors::artcraft_router_error::ArtcraftRouterError;
|
||||
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
use crate::generate::generate_video::providers::higgsfield::common::{
|
||||
finish, plan_duration, plan_single_video, require_prompt, snap_resolution, warn_ignored, HiggsfieldVideoReferences,
|
||||
};
|
||||
use crate::generate::generate_video::providers::higgsfield::draft::HiggsfieldVideoPlan;
|
||||
use crate::generate::generate_video::providers::higgsfield::video_request::HiggsfieldVideoRequest;
|
||||
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
|
||||
|
||||
const MODEL: &str = "Grok Imagine 1.5";
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 4;
|
||||
const DEFAULT_DURATION_SECONDS: u32 = 6;
|
||||
|
||||
const RESOLUTION_TIERS: &[(RouterResolution, GrokImagine1p5Resolution)] = &[
|
||||
(RouterResolution::FourEightyP, GrokImagine1p5Resolution::P480),
|
||||
(RouterResolution::SevenTwentyP, GrokImagine1p5Resolution::P720),
|
||||
(RouterResolution::TenEightyP, GrokImagine1p5Resolution::P1080),
|
||||
];
|
||||
|
||||
pub fn build_higgsfield_grok_imagine_1p5(mut builder: GenerateVideoRequestBuilder) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
|
||||
let strategy = builder.request_mismatch_mitigation_strategy;
|
||||
let prompt = require_prompt(builder.prompt.take())?;
|
||||
warn_ignored(MODEL, "negative prompt", builder.negative_prompt.take());
|
||||
warn_ignored(MODEL, "aspect ratio", builder.aspect_ratio.take());
|
||||
warn_ignored(MODEL, "bitrate", builder.bitrate.take());
|
||||
warn_ignored(MODEL, "audio toggle", builder.generate_audio.take());
|
||||
plan_single_video(builder.video_batch_count.take(), strategy, MODEL)?;
|
||||
|
||||
let mut references = HiggsfieldVideoReferences::take_from(&mut builder, MODEL);
|
||||
references.retain_roles(GrokImagine1p5Request::MEDIA_ROLES, MODEL);
|
||||
references.check_limits(MAX_REFERENCE_IMAGES, 0, 0, MODEL)?;
|
||||
|
||||
let resolution = snap_resolution(builder.resolution.take(), RESOLUTION_TIERS, GrokImagine1p5Resolution::P720, strategy)?;
|
||||
let duration = plan_duration(builder.duration_seconds.take(), GrokImagine1p5Request::DURATION, DEFAULT_DURATION_SECONDS, strategy)?;
|
||||
let request = GrokImagine1p5Request::text_to_video(prompt, resolution, duration);
|
||||
|
||||
Ok(finish(HiggsfieldVideoPlan::Request(HiggsfieldVideoRequest::GrokImagine1p5(request)), references, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::image_ref::ImageRef;
|
||||
use crate::api::router_video_model::RouterVideoModel;
|
||||
use crate::generate::generate_video::providers::higgsfield::common_test_helpers::{base_builder, unwrap_draft, unwrap_request, with_start_frame};
|
||||
|
||||
fn built(builder: GenerateVideoRequestBuilder) -> GrokImagine1p5Request {
|
||||
match unwrap_request(build_higgsfield_grok_imagine_1p5(builder).expect("build")) {
|
||||
HiggsfieldVideoRequest::GrokImagine1p5(request) => request,
|
||||
other => panic!("wrong model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_video_works_without_a_frame() {
|
||||
let request = built(base_builder(RouterVideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(request.resolution, GrokImagine1p5Resolution::P720);
|
||||
assert_eq!(request.duration.seconds(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_k_snaps_to_1080p_and_durations_clamp() {
|
||||
let mut b = base_builder(RouterVideoModel::GrokImagineVideo1p5);
|
||||
b.resolution = Some(RouterResolution::FourK);
|
||||
b.duration_seconds = Some(20);
|
||||
let request = built(b);
|
||||
assert_eq!(request.resolution, GrokImagine1p5Resolution::P1080);
|
||||
assert_eq!(request.duration.seconds(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_frames_are_dropped_but_start_frames_kept() {
|
||||
let mut b = with_start_frame(base_builder(RouterVideoModel::GrokImagineVideo1p5));
|
||||
b.end_frame = Some(ImageRef::Url("https://cdn.example.com/end.png".to_string()));
|
||||
let draft = unwrap_draft(build_higgsfield_grok_imagine_1p5(b).expect("build"));
|
||||
let references = draft.unhandled_request_state.unwrap();
|
||||
assert!(references.start_frame.is_some());
|
||||
assert!(references.end_frame.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod build;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue