Compare commits

...
Author SHA1 Message Date
Brandon Thomas 803212994b test 2026-04-08 02:16:23 -04:00
Brandon Thomas 701f6ca099 tests for flux1 dev 2026-04-08 02:14:49 -04:00
Brandon Thomas b94aa0bb55 reenable frontend 2026-04-08 01:48:50 -04:00
Brandon Thomas 00f773d08b distill module 2026-04-08 01:40:33 -04:00
Brandon Thomas 3d44c16ac4 rename 2026-04-08 01:04:06 -04:00
Brandon Thomas 2b76f6c53c test modules 2026-04-08 00:11:36 -04:00
24 changed files with 797 additions and 110 deletions
@@ -36,7 +36,9 @@ pub enum CommonAspectRatio {
// Auto values that bake in resolution
// These are from the Seedream models
#[serde(rename = "auto_2k")]
Auto2k,
#[serde(rename = "auto_4k")]
Auto4k,
// Defined aspect ratios that bake in resolution
@@ -33,7 +33,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::plan_generate_image_fal_nano_banana_2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct GenerateImageRequest<'a> {
/// Which model to use.
pub model: CommonImageModel,
@@ -1,4 +1,5 @@
/// The result of estimating the cost of an image generation plan.
#[derive(Clone, Debug)]
pub struct ImageGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
@@ -8,7 +8,7 @@ use artcraft_router::api::provider::Provider;
use log::warn;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::omni_gen::generate::image::transform_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::image::hydrate_to_router_request::hydrate_to_router_request;
use crate::state::server_state::ServerState;
/// Estimate the cost of an image generation.
@@ -9,7 +9,7 @@ use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use log::warn;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::omni_gen::generate::video::transform_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::hydrate_to_router_request::hydrate_to_router_request;
use crate::state::server_state::ServerState;
/// Estimate the cost of a video generation.
@@ -0,0 +1,232 @@
//! Distillation step for the omni-gen image endpoints.
//!
//! Takes a raw [`OmniGenImageCostAndGenerateRequest`] plus a (pre-computed)
//! `MediaFileToken -> Url` map and produces a fully self-contained
//! [`DistilledImageRequest`] holding:
//! - the (private) router request, kept for inspection in tests / debugging
//! - the [`ImageGenerationCostEstimate`] (Artcraft provider, what we bill on)
//! - the [`ImageGenerationPlan`] (Fal provider, what we actually execute)
//!
//! All borrows inside `request` and `plan` resolve to heap data owned by the
//! returned struct, so it has no caller-managed lifetimes and can be moved /
//! returned freely. Tests can construct one in isolation and assert against
//! its `cost` / `plan` directly.
use std::collections::HashMap;
use log::warn;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
use artcraft_router::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::hydrate_to_router_request::hydrate_to_router_request;
/// Self-contained, owned representation of a fully-distilled omni-gen image
/// request: the router request, the bill-on cost estimate, and the executable
/// plan, all in one place.
///
/// Borrows inside `request` and `plan` point into the boxed owned data fields
/// below; those boxes live for the lifetime of `Self`, so the borrows are
/// always valid. Construct exclusively via [`distill_image_request`].
pub struct DistilledImageRequest {
// Owned heap-stable backing data for the borrows in `request` / `plan`.
// These boxes are never moved out and outlive both `request` and `plan` by
// virtue of being dropped together with them when `Self` is dropped.
// SAFETY backing for the lifetime erasure performed in `distill_image_request`.
_owned_image_input_urls: Option<Box<Vec<String>>>,
_owned_prompt: Option<Box<String>>,
_owned_idempotency_token: Option<Box<String>>,
/// The fully-built router request, with `provider = Fal` so it matches the
/// plan we actually execute. Private — useful for tests / debugging only.
///
/// Borrows inside this request point into the `_owned_*` boxes above. Access
/// only via [`DistilledImageRequest::request`] (an accessor that ties the
/// returned reference to `&self`) — never moved out, otherwise the borrows
/// would dangle once the boxes are dropped with `Self`.
request: GenerateImageRequest<'static>,
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
pub cost: ImageGenerationCostEstimate,
/// Execution plan as computed by the Fal provider — what we hand to the router.
///
/// Borrows inside this plan point into the `_owned_*` boxes above (and into
/// `request`, which itself points into them). Access only via
/// [`DistilledImageRequest::plan`] — moving the plan out of `Self` would
/// dangle the borrows once the boxes are dropped.
plan: ImageGenerationPlan<'static>,
}
impl DistilledImageRequest {
/// Borrow the execution plan. The returned reference's lifetime is tied to
/// `&self`, so the plan can never outlive the boxes that back its borrows.
pub fn plan(&self) -> &ImageGenerationPlan<'_> {
// SAFETY: the plan was constructed with `'static` borrows that actually
// point into `self._owned_*`. Re-binding the lifetime to `&self` exposes
// the true invariant to callers.
&self.plan
}
/// Borrow the underlying router request. Useful for tests / debugging.
/// (Same lifetime invariant as [`Self::plan`].)
#[allow(dead_code)]
pub(crate) fn request(&self) -> &GenerateImageRequest<'_> {
&self.request
}
}
/// Build a [`DistilledImageRequest`] from a raw API request and a pre-computed
/// `MediaFileToken -> Url` hydration map.
///
/// The hydration map should already have been resolved by the caller (via
/// `lookup_image_urls_as_map` or equivalent) — distillation does no I/O.
///
/// Returns `BadInputWithSimpleMessage` if any image media token referenced by
/// the request is missing from the hydration map.
pub fn distill_image_request(
request: &OmniGenImageCostAndGenerateRequest,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<DistilledImageRequest, AdvancedCommonWebError> {
// 1. Convert the raw API request into a router request (still borrowing
// from `request`).
let initial = hydrate_to_router_request(request)?;
// 2. Hoist every borrowed field into an owned `Box` so the resulting
// DistilledImageRequest doesn't borrow from the caller's `request`.
let owned_prompt: Option<Box<String>> = initial.prompt.map(|s| Box::new(s.to_string()));
let owned_idempotency_token: Option<Box<String>> =
initial.idempotency_token.map(|s| Box::new(s.to_string()));
let owned_image_input_urls: Option<Box<Vec<String>>> = build_owned_image_input_urls(
request.image_media_tokens.as_ref(),
media_file_hydration_map,
)?;
// 3. Build a `GenerateImageRequest<'static>` whose borrows point into the
// owned boxes from step 2.
//
// SAFETY: each box lives in `Self` for the entire lifetime of the returned
// DistilledImageRequest, and the 'static-typed borrows we synthesize here
// are never exposed outside of `Self`. The plan / request fields can only
// be accessed through `&self`, so the borrows can never outlive the boxes.
let prompt_static: Option<&'static str> = owned_prompt.as_deref().map(|s: &String| {
let raw: *const str = s.as_str();
unsafe { &*raw }
});
let idempotency_static: Option<&'static str> = owned_idempotency_token
.as_deref()
.map(|s: &String| {
let raw: *const str = s.as_str();
unsafe { &*raw }
});
let image_inputs_static: Option<ImageListRef<'static>> =
owned_image_input_urls.as_deref().map(|v: &Vec<String>| {
let raw: *const Vec<String> = v;
ImageListRef::Urls(unsafe { &*raw })
});
// The "canonical" request used for both billing and execution.
// Provider here is Fal (what we'll actually execute against). The cost path
// below builds a sibling request with provider = Artcraft.
let request_static: GenerateImageRequest<'static> = GenerateImageRequest {
model: initial.model,
provider: Provider::Fal,
prompt: prompt_static,
image_inputs: image_inputs_static,
resolution: initial.resolution,
aspect_ratio: initial.aspect_ratio,
image_batch_count: initial.image_batch_count,
horizontal_angle: initial.horizontal_angle,
vertical_angle: initial.vertical_angle,
zoom: initial.zoom,
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
generation_mode_mismatch_strategy: initial.generation_mode_mismatch_strategy,
idempotency_token: idempotency_static,
};
// 4. Cost estimate. Always use the Artcraft provider for billing regardless
// of which provider executes the request — that's what the user is paying for.
let cost_request = GenerateImageRequest {
provider: Provider::Artcraft,
..request_static.clone()
};
let cost_plan = cost_request.build().map_err(|e| {
warn!("Failed to build cost plan during distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
let cost: ImageGenerationCostEstimate = cost_plan.estimate_costs();
// The cost plan borrows into `cost_request` which is about to be dropped;
// `cost` is owned data so this is fine.
drop(cost_plan);
drop(cost_request);
// 5. Execution plan. Built from the Fal-flavored canonical request. The plan
// borrows from the request, which in turn borrows from the owned boxes —
// so the plan's borrows resolve to heap-stable data inside `Self`.
//
// SAFETY: the borrow we hand to `build()` is `&'static`, justified by the
// same invariants as above (the underlying data lives for the lifetime of
// the returned DistilledImageRequest).
let plan: ImageGenerationPlan<'static> = {
let request_ref: &'static GenerateImageRequest<'static> = unsafe {
let raw: *const GenerateImageRequest<'static> = &request_static;
&*raw
};
request_ref.build().map_err(|e| {
warn!("Failed to build image generation plan during distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?
};
Ok(DistilledImageRequest {
_owned_image_input_urls: owned_image_input_urls,
_owned_prompt: owned_prompt,
_owned_idempotency_token: owned_idempotency_token,
request: request_static,
cost,
plan,
})
}
/// Build the owned `Vec<String>` of image input URLs from the raw token list +
/// hydration map. Errors out if any token referenced by the request is missing
/// from the hydration map.
fn build_owned_image_input_urls(
image_media_tokens: Option<&Vec<MediaFileToken>>,
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<Option<Box<Vec<String>>>, AdvancedCommonWebError> {
let tokens = match image_media_tokens {
Some(tokens) if !tokens.is_empty() => tokens,
_ => return Ok(None),
};
let map = hydration_map.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
"image_media_tokens supplied but no hydration map was provided".to_string(),
)
})?;
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
for token in tokens {
match map.get(token) {
Some(url) => urls.push(url.to_string()),
None => {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Image media token not found in hydration map: {:?}",
token
)));
}
}
}
Ok(Some(Box::new(urls)))
}
@@ -1,5 +1,9 @@
pub mod omni_gen_image_generate_handler;
pub(crate) mod distill_image_request;
mod request_to_costs;
mod request_to_plan;
mod resolve_media_tokens;
pub(crate) mod transform_request;
pub(crate) mod hydrate_to_router_request;
#[cfg(test)]
mod tests;
@@ -1,9 +1,11 @@
use std::collections::HashMap;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
@@ -25,17 +27,16 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
use super::request_to_costs::request_to_costs;
use super::request_to_plan::request_to_plan;
use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media};
use super::transform_request::hydrate_to_router_request;
use super::distill_image_request::distill_image_request;
/// Generate an image using the omni-gen unified endpoint.
#[utoipa::path(
@@ -59,22 +60,10 @@ pub async fn omni_gen_image_generate_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
// ==================== TRANSFORM REQUEST + PLAN ==================== //
let mut generate_request = hydrate_to_router_request(&request)?;
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
// ==================== COST ==================== //
info!("\n\n Generate request: {:?}\n\n", generate_request);
info!(">>> Building cost estimate...");
let cost_estimate = request_to_costs(&generate_request)?;
// ==================== SESSION ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
@@ -115,27 +104,42 @@ pub async fn omni_gen_image_generate_handler(
})?;
// ==================== RESOLVE MEDIA TOKENS ==================== //
// Look up media file tokens BEFORE distilling. distill_image_request takes
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
let resolved_media = resolve_media_tokens(
&request,
&http_request,
&mut mysql_connection,
server_state.server_environment,
).await?;
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = match request.image_media_tokens.as_ref() {
Some(tokens) if !tokens.is_empty() => {
info!("Resolving {} image media tokens to CDN URLs", tokens.len());
let raw = lookup_image_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
tokens,
).await?;
let parsed: HashMap<MediaFileToken, Url> = raw.into_iter()
.filter_map(|(token, url_str)| match Url::parse(&url_str) {
Ok(url) => Some((token, url)),
Err(err) => {
warn!("Failed to parse media file URL {:?}: {:?}", url_str, err);
None
}
})
.collect();
Some(parsed)
}
_ => None,
};
apply_resolved_media(&mut generate_request, &resolved_media);
// ==================== DISTILL ==================== //
info!("\n\n Updated request: {:?}\n\n", generate_request);
let distilled = distill_image_request(&request, media_file_hydration_map.as_ref())?;
// ==================== PLAN ==================== //
let plan = request_to_plan(&mut generate_request)?;
info!("\n\n Plan: {:?}\n\n", plan);
info!(">>> Distilled cost: {:?}", distilled.cost);
info!(">>> Distilled plan: {:?}", distilled.plan());
// ==================== BILLING ==================== //
let cost = cost_estimate.cost_in_credits.unwrap_or(0);
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
info!("Charging wallet: {} credits", cost);
@@ -159,7 +163,7 @@ pub async fn omni_gen_image_generate_handler(
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let generation_response = plan.generate_image(&router_client)
let generation_response = distilled.plan().generate_image(&router_client)
.await
.map_err(|e| {
warn!("Image generation failed: {:?}", e);
@@ -0,0 +1,421 @@
//! Tests for omni-gen image distillation against the Flux 1 Dev model.
//!
//! These exercise [`distill_image_request`] end-to-end without firing any
//! provider calls — both the cost estimate (Artcraft provider, what we bill
//! on) and the execution plan (Fal provider, what we'd send to fal.ai) are
//! computed synchronously from the request.
//!
//! Flux 1 Dev is text-to-image only, so no image refs are exercised.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Build a minimal Flux 1 Dev omni request with only the fields we care about
/// in tests filled in.
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
image_batch_count: Option<u16>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::Flux1Dev),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
/// Distill a request and panic with a descriptive message on failure.
/// Flux 1 Dev never uses image refs, so we always pass an empty hydration map.
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for Flux 1 Dev")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing comes from `estimate_image_cost_artcraft_flux_1_dev`:
// $0.02 / image (2 credits per image; 1 credit == 1 USD cent)
//
// The artcraft cost path is what omni bills on, so this matches what the
// user is actually charged.
//
// (Note: the Fal cost path would also be 3¢/image for flux_1_dev — but the
// omni endpoint always uses the Artcraft path for billing.)
mod cost {
use super::*;
fn cost_for_batch(image_batch_count: Option<u16>) -> (Option<u64>, Option<u64>) {
let request = make_request(Some("a happy puppy"), None, image_batch_count);
let distilled = distill(&request);
(
distilled.cost.cost_in_credits,
distilled.cost.cost_in_usd_cents,
)
}
#[test]
fn default_batch_costs_2_cents() {
// No batch specified → defaults to 1 image → 2¢
let (credits, cents) = cost_for_batch(None);
assert_eq!(credits, Some(2));
assert_eq!(cents, Some(2));
}
#[test]
fn batch_of_one_costs_2_cents() {
let (credits, cents) = cost_for_batch(Some(1));
assert_eq!(credits, Some(2));
assert_eq!(cents, Some(2));
}
#[test]
fn batch_of_two_costs_4_cents() {
let (credits, cents) = cost_for_batch(Some(2));
assert_eq!(credits, Some(4));
assert_eq!(cents, Some(4));
}
#[test]
fn batch_of_three_costs_6_cents() {
let (credits, cents) = cost_for_batch(Some(3));
assert_eq!(credits, Some(6));
assert_eq!(cents, Some(6));
}
#[test]
fn batch_of_four_costs_8_cents() {
let (credits, cents) = cost_for_batch(Some(4));
assert_eq!(credits, Some(8));
assert_eq!(cents, Some(8));
}
#[test]
fn batch_above_max_clamps_and_costs_8_cents() {
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4.
let (credits, cents) = cost_for_batch(Some(7));
assert_eq!(credits, Some(8));
assert_eq!(cents, Some(8));
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
None,
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::SquareHd),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Auto),
];
for ar in aspect_ratios {
let request = make_request(Some("p"), ar, Some(2));
let distilled = distill(&request);
assert_eq!(
distilled.cost.cost_in_usd_cents,
Some(4),
"expected 4¢ regardless of aspect ratio (got {:?})",
ar,
);
}
}
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
let without_prompt = distill(&make_request(None, None, Some(3)));
assert_eq!(with_prompt.cost.cost_in_usd_cents, Some(6));
assert_eq!(without_prompt.cost.cost_in_usd_cents, Some(6));
}
#[test]
fn cost_metadata_flags_are_default() {
let request = make_request(Some("p"), None, Some(1));
let distilled = distill(&request);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
//
// The distilled plan is the Fal-side `PlanFalFlux1Dev`:
// - prompt: passthrough
// - maybe_image_url: always None (Flux 1 Dev never gets image refs in tests)
// - aspect_ratio: mapped from CommonAspectRatio with sensible fallbacks
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::{
FalFlux1DevNumImages, PlanFalFlux1Dev,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::Flux1DevAspectRatio;
/// Distill and run an assertion against the inner FalFlux1Dev plan.
///
/// We pass the plan by reference (rather than moving it out of `distilled`)
/// because the plan's borrows are tied to data owned by `distilled` —
/// moving it out would dangle those borrows.
fn with_fal_plan<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill(request);
match distilled.plan() {
ImageGenerationPlan::FalFlux1Dev(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalFlux1Dev, got {:?}", other),
}
}
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
ar: Option<CommonAspectRatio>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
}
fn assert_plan_for_batch<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
batch: Option<u16>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
}
// ── Aspect ratio mapping ────────────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_square() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn square_input_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn square_hd_input_yields_square_hd() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::SquareHd));
});
}
#[test]
fn wide_4x3_yields_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_16x9_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_alias_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_3x4_yields_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x16_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_alias_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
// ── Aspect ratio fallback mapping ───────────────────────────────────────
//
// Aspect ratios that Flux 1 Dev doesn't natively support get mapped to the
// closest supported ratio under the PayMoreUpgrade mismatch strategy that
// omni-gen uses.
#[test]
fn wide_5x4_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_3x2_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_21x9_falls_back_to_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_4x5_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_2x3_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x21_falls_back_to_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
// ── Auto* aspect ratios ─────────────────────────────────────────────────
#[test]
fn auto_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn auto_2k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn auto_4k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
// ── Num images mapping ──────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
assert_plan_for_batch(None, |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
});
}
#[test]
fn batch_of_one_yields_one() {
assert_plan_for_batch(Some(1), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
});
}
#[test]
fn batch_of_two_yields_two() {
assert_plan_for_batch(Some(2), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Two));
});
}
#[test]
fn batch_of_three_yields_three() {
assert_plan_for_batch(Some(3), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Three));
});
}
#[test]
fn batch_of_four_yields_four() {
assert_plan_for_batch(Some(4), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
});
}
#[test]
fn batch_above_four_clamps_to_four() {
// PayMoreUpgrade strategy clamps over-large batches down to the max.
assert_plan_for_batch(Some(9), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
});
}
// ── Prompt passthrough ──────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
assert_eq!(plan.prompt, None);
});
}
// ── Image refs (Flux 1 Dev never has them) ──────────────────────────────
#[test]
fn image_url_is_always_none() {
// Flux 1 Dev is text-to-image only — `maybe_image_url` is always None
// because we never pass image_media_tokens.
with_fal_plan(&make_request(Some("p"), None, Some(1)), |plan| {
assert!(plan.maybe_image_url.is_none());
});
}
}
}
@@ -2,4 +2,7 @@ pub mod omni_gen_video_generate_handler;
mod request_to_costs;
mod request_to_plan;
mod resolve_media_tokens;
pub(crate) mod transform_request;
pub(crate) mod hydrate_to_router_request;
#[cfg(test)]
mod tests;
@@ -35,7 +35,7 @@ use crate::state::server_state::ServerState;
use super::request_to_costs::request_to_costs;
use super::request_to_plan::request_to_plan;
use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media};
use super::transform_request::hydrate_to_router_request;
use super::hydrate_to_router_request::hydrate_to_router_request;
/// Generate a video using the omni-gen unified endpoint.
#[utoipa::path(
@@ -4,8 +4,8 @@ import Media from "../pages/media";
import PressKit from "../pages/press-kit";
import Navbar from "../components/navbar";
import { ToastContainer } from "../components/toast/toast";
// import CreateImage from "../pages/create-image";
// import CreateVideo from "../pages/create-video";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import Landing2 from "../pages/landing2";
import LandingSD2 from "../pages/landing-sd2";
import TutorialsPage from "../pages/tutorials";
@@ -30,8 +30,8 @@ export function App() {
<Routes>
<Route path="/" element={<Landing2 />} />
{/* <Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} /> */}
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
@@ -17,8 +17,8 @@ import { TaskQueue } from "./task-queue";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
// { name: "Image", href: "/create-image" },
// { name: "Video", href: "/create-video" },
{ name: "Image", href: "/create-image" },
{ name: "Video", href: "/create-video" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
+10 -9
View File
@@ -11,15 +11,16 @@ const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
// In development, route API through the Vite dev server origin to avoid CORS
if (import.meta.env.DEV) {
try {
const origin = window.location.origin;
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(origin);
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
}
// In development, route API through the Vite dev server proxy to avoid CORS,
// unless VITE_API_HOST is explicitly set (eg. for an unsafe browser that ignores CORS).
//if (import.meta.env.DEV && !import.meta.env.VITE_API_HOST) {
// try {
// const origin = window.location.origin;
// StorytellerApiHostStore.getInstance().setApiSchemeAndHost(origin);
// } catch (e) {
// console.warn("Failed to set dev API host override", e);
// }
//}
// Cache the referrer immediately — it can change or disappear on navigation.
// Only set once so subsequent in-site navigations don't overwrite the original.
@@ -50,9 +50,7 @@ const ROUTE_TO_FILTER: Record<string, string> = {
meshes: "meshes",
};
const getFilterMediaClass = (
filter: string,
): FilterMediaClasses[] | undefined => {
const getFilterMediaClass = (filter: string): FilterMediaClasses[] | undefined => {
switch (filter) {
case "image":
return [FilterMediaClasses.IMAGE];
@@ -61,11 +59,7 @@ const getFilterMediaClass = (
case "meshes":
return [FilterMediaClasses.DIMENSIONAL];
default:
return [
FilterMediaClasses.IMAGE,
FilterMediaClasses.VIDEO,
FilterMediaClasses.DIMENSIONAL,
];
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO, FilterMediaClasses.DIMENSIONAL];
}
};
@@ -97,9 +91,7 @@ const getLabel = (item: any) => {
export default function Library() {
const { filter: filterParam } = useParams<{ filter?: string }>();
const navigate = useNavigate();
const activeFilter = filterParam
? (ROUTE_TO_FILTER[filterParam] ?? "all")
: "all";
const activeFilter = filterParam ? (ROUTE_TO_FILTER[filterParam] ?? "all") : "all";
const [username, setUsername] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
@@ -121,7 +113,11 @@ export default function Library() {
const checkSession = async () => {
const usersApi = new UsersApi();
const response = await usersApi.GetSession();
if (response.success && response.data?.loggedIn && response.data.user) {
if (
response.success &&
response.data?.loggedIn &&
response.data.user
) {
setUsername(response.data.user.username);
setIsLoggedIn(true);
} else {
@@ -139,8 +135,8 @@ export default function Library() {
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.MEDIUM,
});
size: THUMBNAIL_SIZES.MEDIUM,
});
return {
id: item.token,
@@ -173,9 +169,7 @@ export default function Library() {
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
)
.filter((item: any) => item.media_type !== FilterMediaType.SCENE_JSON)
.map(mapApiItem);
if (reset) {
@@ -256,17 +250,17 @@ export default function Library() {
const navigatePrev =
currentIndex > 0
? () => {
const prev = flatItems[currentIndex - 1];
setLightboxItem(prev);
}
const prev = flatItems[currentIndex - 1];
setLightboxItem(prev);
}
: undefined;
const navigateNext =
currentIndex >= 0 && currentIndex < flatItems.length - 1
? () => {
const next = flatItems[currentIndex + 1];
setLightboxItem(next);
}
const next = flatItems[currentIndex + 1];
setLightboxItem(next);
}
: undefined;
const handleItemDeleted = useCallback((id: string) => {
@@ -347,11 +341,10 @@ export default function Library() {
<button
key={filter.id}
onClick={() => navigate(filter.route)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeFilter === filter.id
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${activeFilter === filter.id
? "bg-ui-controls text-white"
: "text-white/60 hover:text-white"
}`}
}`}
>
<FontAwesomeIcon icon={filter.icon} className="text-xs" />
{filter.label}
@@ -393,8 +386,10 @@ export default function Library() {
</div>
) : allItems.length === 0 && !loading ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-white/40 text-sm mb-4">No items yet.</p>
{/* <div className="flex gap-3">
<p className="text-white/40 text-sm mb-4">
No items yet. Start generating!
</p>
<div className="flex gap-3">
<Link to="/create-image">
<Button variant="primary" className="text-sm px-4 py-2">
Create Image
@@ -408,7 +403,7 @@ export default function Library() {
Create Video
</Button>
</Link>
</div> */}
</div>
</div>
) : (
<div className="space-y-6">
+3 -1
View File
@@ -26,7 +26,9 @@ export class ApiManager {
}
protected getApiSchemeAndHost(): string {
return StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const host = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
console.log('getSchemaAndHost()', host);
return host;
}
public async fetch<B, T>(
@@ -1,5 +1,8 @@
// Artcraft / Storyteller API backend
const DEFAULT_API_HOST = "https://api.storyteller.ai";
// Set VITE_API_HOST=http://localhost:12345 for local dev, or leave unset for production.
//const DEFAULT_API_HOST = (import.meta as any).env?.VITE_API_HOST || "https://api.storyteller.ai";
const DEFAULT_API_HOST = "http://localhost:12345";
// NB(bt,2025-09-25): 'nx' is creating multiple copies of the library with name
// mangling, so the singleton pattern fails to resolve to a single instance.
@@ -137,7 +137,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
tabIndex={-1}
className={twMerge(
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none",
item.mediaClass === "video" ? "aspect-video" : "aspect-square",
"aspect-square",
selected || bulkSelected
? "border-primary"
: disableTooltipAndBadge
@@ -171,7 +171,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
alt={item.label}
className={twMerge(
"h-full w-full bg-black/30",
item.mediaClass === "video" || imageFit === "contain" ? "object-contain" : "object-cover",
imageFit === "contain" ? "object-contain" : "object-cover",
)}
draggable={false}
onError={onImageError}
@@ -187,7 +187,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
);
return (
<div className={twMerge("group relative w-full", item.mediaClass === "video" ? "aspect-video" : "aspect-square")}>
<div className={twMerge("group relative w-full", "aspect-square")}>
{/* dropdown menu */}
{mode !== "select" && (
<div
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../slider-v2"
},
{
"path": "../button-icon-select"
},
@@ -14,9 +17,6 @@
{
"path": "../camera-settings-modal"
},
{
"path": "../toaster"
},
{
"path": "../../tauri-utils"
},
@@ -26,29 +26,38 @@
{
"path": "../../model-list"
},
{
"path": "../modal"
},
{
"path": "../popover"
},
{
"path": "../slider-v2"
},
{
"path": "../../common"
},
{
"path": "../../api"
"path": "../tooltip"
},
{
"path": "../action-reminder-modal"
},
{
"path": "../label"
},
{
"path": "../button"
},
{
"path": "../input"
},
{
"path": "../gallery-modal"
},
{
"path": "../tooltip"
"path": "../toaster"
},
{
"path": "../button"
"path": "../../api"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../button-icon-select/tsconfig.lib.json"
},
@@ -55,9 +58,6 @@
{
"path": "../camera-settings-modal/tsconfig.lib.json"
},
{
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
@@ -67,29 +67,38 @@
{
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
"path": "../tooltip/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}