adding hf to tauri+frontend, wip

This commit is contained in:
Hanashi 2026-09-01 02:05:19 -04:00
parent fd691beb10
commit 2368f9f824
148 changed files with 5730 additions and 54 deletions

View File

@ -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());
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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 ,

View File

@ -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 ,

View File

@ -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),
}
}
}

View File

@ -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),

View File

@ -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());
}
}

View File

@ -0,0 +1 @@
pub mod enqueue_via_higgsfield;

View File

@ -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;

View File

@ -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")]

View File

@ -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),

View File

@ -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());
}
}

View File

@ -0,0 +1 @@
pub mod handle_higgsfield_video_via_router;

View File

@ -2,3 +2,4 @@ pub mod generate_video_command;
mod handle_credential_router;
pub mod request;
pub mod artcraft;
pub mod higgsfield;

View File

@ -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,

View File

@ -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),

View File

@ -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,

View File

@ -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),

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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/",

View File

@ -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")]

View File

@ -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),

View File

@ -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));
}
}

View File

@ -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,
}
}

View File

@ -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
}
}

View File

@ -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;

View File

@ -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
}

View File

@ -1,2 +1,3 @@
pub mod fal;
pub mod grok;
pub mod higgsfield;

View File

@ -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() {

View File

@ -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,

View File

@ -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]

View File

@ -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,

View File

@ -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()
},
]
}

View File

@ -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;

View File

@ -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",

View File

@ -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());

View File

@ -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);
}
}

View File

@ -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\"");
}
}

View File

@ -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\"");
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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]);
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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]);
}
}

View File

@ -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]);
}
}

View File

@ -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

View File

@ -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")]

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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))

View File

@ -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,

View File

@ -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),

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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"),

View File

@ -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)

View File

@ -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(),
}

View File

@ -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()),

View File

@ -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))
}
}
}
}

View File

@ -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<'_> {

View File

@ -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
}
}
}
}

View File

@ -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());
}
}
}

View File

@ -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:?}"),
}
}

View File

@ -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,
}
}
}

View File

@ -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),
})
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
pub mod build;

View File

@ -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",
}
}
}

View File

@ -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;

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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(_))));
}
}

View File

@ -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 }))
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
pub mod build;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -1,5 +1,6 @@
pub mod artcraft;
pub mod fal;
pub mod grok;
pub mod higgsfield;
pub mod kinovi;
pub mod midjourney;

View File

@ -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(),
}
}

View File

@ -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()),

View File

@ -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", .. })),
));
}
}
}

View File

@ -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:?}"),
}
}

View File

@ -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,
}
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

Some files were not shown because too many files have changed in this diff Show More